Skip to content
Closed
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
1 change: 1 addition & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@modelcontextprotocol/sdk": "^1.25.1",
"@qwen-code/channel-base": "file:../channels/base",
"@qwen-code/channel-dingtalk": "file:../channels/dingtalk",
"@qwen-code/sdk": "file:../sdk-typescript",
"@qwen-code/channel-telegram": "file:../channels/telegram",
"@qwen-code/channel-weixin": "file:../channels/weixin",
"@qwen-code/qwen-code-core": "file:../core",
Expand Down
234 changes: 234 additions & 0 deletions packages/cli/src/commands/daemon-tui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import type { CommandModule } from 'yargs';
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import { writeStderrLine } from '../utils/stdioHelpers.js';
import type {
DaemonTuiUpdate,
DaemonTuiSessionClient,
} from '../ui/daemon/DaemonTuiAdapter.js';
import { createDaemonTuiSession as createDaemonTuiSessionClient } from '../ui/daemon/createDaemonTuiSession.js';

interface DaemonTuiArgs {
'daemon-url': string;
token?: string;
workspace?: string;
model?: string;
'session-id'?: string;
'session-scope'?: 'single' | 'thread';
prompt?: string;
}

function writeLine(line = ''): void {
output.write(`${line}\n`);
}

function formatHistoryItem(item: unknown): string {
if (!item || typeof item !== 'object') {
return String(item);
}
const record = item as Record<string, unknown>;
const type = typeof record['type'] === 'string' ? record['type'] : 'history';
const text =
typeof record['text'] === 'string'
? record['text']
: JSON.stringify(record, null, 2);
return `[${type}] ${text}`;
}

function printDaemonUpdate(update: DaemonTuiUpdate): void {
switch (update.type) {
case 'history':
writeLine(formatHistoryItem(update.item));
break;
case 'tool_group_update':
writeLine('[tool]');
for (const tool of update.item.tools) {
writeLine(` - ${tool.name}: ${tool.status}`);
if (tool.resultDisplay !== undefined) {
writeLine(` ${JSON.stringify(tool.resultDisplay)}`);
}
}
break;
case 'permission_request':
writeLine(`[permission] ${update.requestId}`);
writeLine(
` tool: ${update.request.toolCall.kind} (${update.request.toolCall.toolCallId})`,
);
for (const option of update.request.options) {
writeLine(` - ${option.optionId}: ${option.name ?? option.optionId}`);
}
writeLine(` approve with: /approve ${update.requestId} <optionId>`);
writeLine(` reject with: /reject ${update.requestId}`);
break;
case 'permission_resolved':
writeLine(
`[permission_resolved] ${update.requestId} ${JSON.stringify(
update.outcome,
)}`,
);
break;
case 'model_switched':
writeLine(`[model] ${update.modelId}`);
break;
case 'disconnected':
writeLine(`[disconnected] ${update.reason}`);
break;
default: {
const neverUpdate: never = update;
writeLine(JSON.stringify(neverUpdate));
}
}
}

async function createDaemonTuiSession(
argv: DaemonTuiArgs,
): Promise<DaemonTuiSessionClient> {
const workspaceCwd = argv.workspace ?? process.cwd();
return await createDaemonTuiSessionClient({
daemonUrl: argv['daemon-url'],
token: argv.token,
workspaceCwd,
model: argv.model,
sessionId: argv['session-id'],
sessionScope: argv['session-scope'],
});
}

async function runPrompt(
session: DaemonTuiSessionClient,
prompt: string,
): Promise<void> {
const { DaemonTuiAdapter } = await import('../ui/daemon/DaemonTuiAdapter.js');
const adapter = new DaemonTuiAdapter({
session,
onUpdate: printDaemonUpdate,
});
await adapter.start();
try {
await adapter.sendPrompt(prompt);
} finally {
await adapter.stop();
}
}

async function runInteractive(session: DaemonTuiSessionClient): Promise<void> {
const { DaemonTuiAdapter } = await import('../ui/daemon/DaemonTuiAdapter.js');
const adapter = new DaemonTuiAdapter({
session,
onUpdate: printDaemonUpdate,
});
await adapter.start();
const rl = createInterface({ input, output });
try {
writeLine(
`Connected to daemon session ${session.sessionId} (${session.workspaceCwd})`,
);
writeLine(
'Commands: /quit, /cancel, /model <id>, /approve <id> <option>, /reject <id>',
);
for (;;) {
const line = (await rl.question('qwen-daemon> ')).trim();

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.

[Critical] No error handling around await adapter.* calls in the interactive REPL loop. Any transient RPC error (timeout, bad model name, network blip) on sendPrompt, cancel, setModel, approvePermission, or rejectPermission propagates to the outer try/finally, which calls process.exit(1). A single flaky command kills the entire session.

Suggested change
const line = (await rl.question('qwen-daemon> ')).trim();
for (;;) {
const line = (await rl.question('qwen-daemon> ')).trim();
if (!line) {
continue;
}
if (line === '/quit' || line === '/exit') {
return;
}
try {
if (line === '/cancel') {
await adapter.cancel();
continue;
}
if (line.startsWith('/model ')) {
await adapter.setModel(line.slice('/model '.length).trim());
continue;
}
if (line.startsWith('/approve ')) {
const [, requestId, optionId] = line.split(/\s+/, 3);
if (!requestId || !optionId) {
writeLine('usage: /approve <requestId> <optionId>');
continue;
}
await adapter.approvePermission(requestId, optionId);
continue;
}
if (line.startsWith('/reject ')) {
const [, requestId] = line.split(/\s+/, 2);
if (!requestId) {
writeLine('usage: /reject <requestId>');
continue;
}
await adapter.rejectPermission(requestId);
continue;
}
await adapter.sendPrompt(line);
} catch (err) {
writeLine(`Error: ${err instanceof Error ? err.message : String(err)}`);
}
}

— qwen-latest-series-invite-beta-v28 via Qwen Code /review

if (!line) {
continue;
}
if (line === '/quit' || line === '/exit') {
return;
}
if (line === '/cancel') {
await adapter.cancel();
continue;
}
if (line.startsWith('/model ')) {
await adapter.setModel(line.slice('/model '.length).trim());
continue;
}
if (line.startsWith('/approve ')) {
const [, requestId, optionId] = line.split(/\s+/, 3);
if (!requestId || !optionId) {
writeLine('usage: /approve <requestId> <optionId>');
continue;
}
await adapter.approvePermission(requestId, optionId);
continue;
}
if (line.startsWith('/reject ')) {
const [, requestId] = line.split(/\s+/, 2);
if (!requestId) {
writeLine('usage: /reject <requestId>');
continue;
}
await adapter.rejectPermission(requestId);
continue;
}
await adapter.sendPrompt(line);
}
} finally {
rl.close();
await adapter.stop();
}
}

export const daemonTuiCommand: CommandModule<unknown, DaemonTuiArgs> = {
command: 'daemon-tui',
describe:
'Experimental local harness for driving the TUI daemon adapter against qwen serve',
builder: (yargs) =>
yargs
.option('daemon-url', {
type: 'string',
default: process.env['QWEN_DAEMON_URL'] ?? 'http://127.0.0.1:4170',
description: 'Base URL of a running qwen serve daemon.',
})
.option('token', {
type: 'string',
description:
'Bearer token for the daemon. Defaults to QWEN_SERVER_TOKEN.',
})
.option('workspace', {
type: 'string',
description:
'Workspace cwd to pass to POST /session. Defaults to process.cwd().',
})
.option('model', {
type: 'string',
description: 'Optional model service id for the daemon session.',
})
.option('session-id', {
type: 'string',
description:
'Attach to an existing daemon session via POST /session/:id/load.',
})
.option('session-scope', {
type: 'string',
choices: ['single', 'thread'] as const,
description:
'Optional session scope override for new sessions. Omit to use the daemon default.',
})
.option('prompt', {
type: 'string',
description:
'Send one prompt and exit. Omit for an interactive validation loop.',
}),
handler: async (argv) => {
try {
const session = await createDaemonTuiSession(argv);
if (argv.prompt) {
await runPrompt(session, argv.prompt);
} else {
await runInteractive(session);
}
} catch (err) {
writeStderrLine(
`qwen daemon-tui: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
process.exit(0);
},
};
73 changes: 71 additions & 2 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { channelCommand } from '../commands/channel.js';
import { authCommand } from '../commands/auth.js';
import { reviewCommand } from '../commands/review.js';
import { serveCommand } from '../commands/serve.js';
import { daemonTuiCommand } from '../commands/daemon-tui.js';

// UUID v4 regex pattern for validation
const SESSION_ID_REGEX =
Expand Down Expand Up @@ -137,6 +138,12 @@ export interface CliArgs {
acp: boolean | undefined;
experimentalAcp: boolean | undefined;
experimentalLsp: boolean | undefined;
experimentalDaemonTui?: boolean | undefined;
daemonUrl?: string | undefined;
daemonToken?: string | undefined;
daemonSessionId?: string | undefined;
daemonSessionScope?: 'single' | 'thread' | undefined;
daemonModel?: string | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
openaiLogging: boolean | undefined;
Expand Down Expand Up @@ -672,6 +679,42 @@ export async function parseArguments(): Promise<CliArgs> {
'Enable experimental LSP (Language Server Protocol) feature for code intelligence',
default: false,
})
.option('experimental-daemon-tui', {
type: 'boolean',
description:
'Experimental: render the normal TUI against a running qwen serve daemon.',
hidden: true,
})
.option('daemon-url', {
type: 'string',
description:
'Experimental daemon TUI: base URL of a running qwen serve daemon.',
hidden: true,
})
.option('daemon-token', {
type: 'string',
description: 'Experimental daemon TUI: bearer token for the daemon.',
hidden: true,
})
.option('daemon-session-id', {
type: 'string',
description:
'Experimental daemon TUI: attach to an existing daemon session.',
hidden: true,
})
.option('daemon-session-scope', {
type: 'string',
choices: ['single', 'thread'] as const,
description:
'Experimental daemon TUI: session scope for new daemon sessions.',
hidden: true,
})
.option('daemon-model', {
type: 'string',
description:
'Experimental daemon TUI: model service id for new daemon sessions.',
hidden: true,
})
.option('channel', {
type: 'string',
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
Expand Down Expand Up @@ -1002,7 +1045,9 @@ export async function parseArguments(): Promise<CliArgs> {
// Register /review skill helpers (presubmit checks, cleanup)
.command(reviewCommand)
// Register `qwen serve` (Stage 1 daemon — see issue #3803)
.command(serveCommand);
.command(serveCommand)
// Register experimental daemon TUI wire-up harness.
.command(daemonTuiCommand);

yargsInstance
.version(await getCliVersion()) // This will enable the --version flag based on package.json
Expand All @@ -1026,7 +1071,8 @@ export async function parseArguments(): Promise<CliArgs> {
result._[0] === 'auth' ||
result._[0] === 'hooks' ||
result._[0] === 'channel' ||
result._[0] === 'review')
result._[0] === 'review' ||
result._[0] === 'daemon-tui')
) {
// Note: `serve` is intentionally NOT in this list. Its handler blocks
// forever (after the listener is up); SIGINT/SIGTERM in runQwenServe
Expand Down Expand Up @@ -1077,6 +1123,29 @@ export async function parseArguments(): Promise<CliArgs> {
(result as Record<string, unknown>)['channel'] = 'ACP';
}

if (result['experimentalDaemonTui']) {
process.env['QWEN_EXPERIMENTAL_DAEMON_TUI'] = '1';
if (typeof result['daemonUrl'] === 'string') {
process.env['QWEN_DAEMON_URL'] = result['daemonUrl'];
}
if (typeof result['daemonToken'] === 'string') {
process.env['QWEN_DAEMON_TOKEN'] = result['daemonToken'];

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.

[Suggestion] Bearer token 写入 process.env['QWEN_DAEMON_TOKEN'],所有子进程(MCP servers、shell exec)继承此环境变量,token 可能通过子进程日志或 crash dump 泄露。

Suggested change
process.env['QWEN_DAEMON_TOKEN'] = result['daemonToken'];
// 将 token 保留在内存中的 DaemonTuiRuntimeOptions 对象,通过函数参数传递

— DeepSeek/deepseek-v4-pro via Qwen Code /review

}
if (typeof result['daemonSessionId'] === 'string') {
process.env['QWEN_DAEMON_SESSION_ID'] = result['daemonSessionId'];
}
if (typeof result['daemonSessionScope'] === 'string') {
process.env['QWEN_DAEMON_SESSION_SCOPE'] = result['daemonSessionScope'];
}
if (typeof result['daemonModel'] === 'string') {
process.env['QWEN_DAEMON_MODEL'] = result['daemonModel'];
}
// Hidden draft path: bridge CLI flags into the daemon-native TUI adapter.
// For now the normal local TUI flag only exercises local-local daemon use;
// remote daemon smoke tests should pass QWEN_DAEMON_WORKSPACE explicitly.
process.env['QWEN_DAEMON_WORKSPACE'] = process.cwd();

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.

[Critical] process.env['QWEN_DAEMON_WORKSPACE'] = process.cwd() 无条件设置,导致 daemonTuiOptions.ts:39?? config.getTargetDir() 永远不会被触发。用户通过 --target-dir 指定的目录被静默覆盖。

Suggested change
process.env['QWEN_DAEMON_WORKSPACE'] = process.cwd();
// 仅当未设置时才写 env,否则让 daemonTuiOptions 回退到 getTargetDir()
if (!process.env['QWEN_DAEMON_WORKSPACE']) {
process.env['QWEN_DAEMON_WORKSPACE'] = process.cwd();
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

}

return result as unknown as CliArgs;
}

Expand Down
Loading
Loading