Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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);
},
};
70 changes: 68 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,26 @@ 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'];
}
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