qwen-code Documentation

An open-source AI coding agent that lives in your terminal.

28,650 stars TypeScript TypeScript 90 files · ~469,150 tokens #agentic#ai#ai-agent#ai-coding#cli#coding-agent#developer-tools#llm
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy qwen-code on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name qwen-code --restart always ${T_BENCH_TASK_DOCKER_CLIENT_IMAGE_NAME}
Configured Services & Ports
⚙️ client
image: ${T_BENCH_TASK_DOCKER_CLIENT_IMAGE_NAME}
docker-compose.yml
yaml (docker-compose.yml)
services:
  client:
    build:
      dockerfile: Dockerfile
    image: ${T_BENCH_TASK_DOCKER_CLIENT_IMAGE_NAME}
    container_name: ${T_BENCH_TASK_DOCKER_CLIENT_CONTAINER_NAME}
    command: ['sh', '-c', 'sleep infinity']
    environment:
      - TEST_DIR=${T_BENCH_TEST_DIR}
    volumes:
      - ${T_BENCH_TASK_LOGS_PATH}:${T_BENCH_CONTAINER_LOGS_PATH}
      - ${T_BENCH_TASK_AGENT_LOGS_PATH}:${T_BENCH_CONTAINER_AGENT_LOGS_PATH}

Technical Documentation: QwenLM/qwen-code

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (README.md)

Why Qwen Code?

  • Agentic out of the box — Auto-Memory, Auto-Skills, SubAgents, Agent Teams, and MCP. Dynamic workflows, zero setup.
  • Open-source, inside and out — The framework and the Qwen models are open-source. They evolve together. No vendor lock-in.
  • Multi-protocol — Supports OpenAI, Anthropic, Gemini, and Qwen APIs. Any third-party provider or local model (Ollama / vLLM). Switch at runtime.
  • Beyond the terminal — IDE plugins, Desktop app, Web UI, SDKs, and chat integrations (Telegram / DingTalk / WeChat / Feishu).

[!TIP]
Qwen Code is actively iterating on itself — using its own agent and models to file issues, submit PRs, review code, and run tests. Powered by the community, driven by AI.

Installation

Linux / macOS:

bash
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash

Windows:

powershell
irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex

Restart your terminal after installation to ensure environment variables take effect.

NPM / Homebrew

NPM (requires Node.js 22+):

bash
npm install @qwen-code/qwen-code@latest

Homebrew (macOS / Linux):

bash
brew install qwen-code

Quick Start

Open a terminal in your project and start Qwen Code:

bash
cd /path/to/your-project
qwen

Inside the session, run /auth to configure your provider and API key. Then try:

text
Explain this repository and show me where to start.

See the Authentication Guide and Settings Reference for detailed setup.

See the terminal interface

How to Use Qwen Code

Choose the interface that fits your workflow:

Interface Get started
Terminal Run qwen in your project — see Quick Start.
Desktop Download Qwen Code Desktop for macOS, Windows, and Linux.
Web Run qwen serve --open to open the Web UI in your browser. (experimental)
Editor Set up VS Code, Zed, or JetBrains.
Chat Connect Telegram, DingTalk, WeChat, or Feishu, then run qwen channel start.

For automation and custom integrations:

SDK example (Python)

python
import asyncio

from qwen_code_sdk import is_sdk_result_message, query


async def main() -> None:
    result = query(
        "Summarize the repository layout.",
        {
            "cwd": "/path/to/project",
            "path_to_qwen_executable": "qwen",
        },
    )

    async for message in result:
        if is_sdk_result_message(message):
            print(message["result"])


asyncio.run(main())

Capabilities

If you know Claude Code, you already know Qwen Code — and then some. We've put significant effort into bringing Qwen Code to feature parity with Claude Code, improving both breadth and reliability across the board.

Feature Qwen Code Claude Code
SubAgents, Agent Teams, Dynamic Workflows ✓ ✓
Auto-Memory, Auto-Skills, Hooks ✓ ✓
Built-in Skills (/review, /batch, /loop, /bugfix…) ✓ ✓
MCP, Plan Mode, LSP Integration ✓ ✓
Auto Mode, Sandbox, Git Worktrees ✓ ✓
Computer Use (desktop automation) ✓ ✓
IDE Plugins (VS Code / JetBrains / Zed) ✓ ✓
SDK ✓ ✓
Headless Mode, Session Management ✓ ✓
Open-source — model and framework ✓ —
Multi-protocol (OpenAI / Anthropic / Gemini / Qwen + any provider) ✓ —
Agent Arena (multi-model head-to-head on same task) ✓ —
Daemon Mode — qwen serve (multi-client shared agent) ✓ —
IM Channels (Telegram / DingTalk / WeChat / Feishu) ✓ —

Qwen Code Evaluation

On SWE-bench Verified, seven Qwen Code versions scored 76.40%–77.80% on average using the same Qwen 3.7 Max model (500 tasks, three trials per version).

Full results and evaluation configuration →

Ecosystem

  • Qwen Code Desktop — Official desktop app for macOS, Windows, and Linux

  • AionUi — A modern GUI for command-line AI tools including Qwen Code

  • Gemini CLI Desktop — A cross-platform desktop/web/mobile UI for Qwen Code

  • qwen-audio-agent — Realtime full-duplex voice frontend for Qwen Code over ACP — talk hands-free with barge-in and a local wake word

  • 🦞 Qwen Code Claw — Let other agents (Claude, Codex, etc.) delegate coding tasks to Qwen Code via ACP. Paste this prompt into your agent:

text
Use Qwen Code as a coding agent. First, fetch and read this skill reference:
https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/.qwen/skills/qwen-code-claw/SKILL.md
Then follow the instructions inside to install, authenticate, and use Qwen Code via acpx for all coding tasks.
  • Aliyun Model Studio CLI — Official CLI for Aliyun's AI platform (bailian-cli). Extends Qwen Code with image/video generation, knowledge retrieval, app orchestration, and model deployment

Privacy

See the Terms of Service and Privacy Notice, including Chrome extension and Browser Use data handling.

Contributing

Contributions are welcome! See CONTRIBUTING.md for guidelines.

Acknowledgments

This project was originally based on Google Gemini CLI v0.8.2. We gratefully acknowledge the Gemini CLI team's excellent work. Starting from Qwen Code v0.1, we stopped syncing with upstream and began independent development as a multi-protocol, multi-platform agent framework with deep integrations for Qwen models and beyond.


Index (`docs/index.md`)

Qwen Code Documentation

Welcome to the Qwen Code documentation. Qwen Code is an agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.

Documentation Sections

[User Guide](./users/overview)

Learn how to use Qwen Code as an end user. This section covers:

  • Basic installation and setup
  • Common usage patterns
  • Features and capabilities
  • Configuration options
  • Troubleshooting

[Developer Guide](./developers/architecture)

Learn how to contribute to and develop Qwen Code. This section covers:

  • Architecture overview
  • Contributing guidelines
  • Core concepts and implementation details
  • Tools and development workflow
  • Extension and plugin development

Users Overview (`docs/users/overview.md`)

Qwen Code overview


Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.

Get started in 30 seconds

Install Qwen Code:

The recommended installer uses a standalone archive when one is available for
your platform. If it falls back to npm, Node.js 22 or later with npm must be
available on PATH.

Linux / macOS

sh
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash

Windows

powershell
irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex

[!note]

It's recommended to restart your terminal after installation if qwen is not
immediately available on PATH. If the installation fails, please refer to
Manual Installation in the Quickstart
guide. For offline installation, download a release archive and run the
installer with --archive PATH; keep SHA256SUMS next to the archive.

Start using Qwen Code:

bash
cd your-project
qwen

On first launch you'll be prompted to connect a model provider. The menu offers QwenCloud (the official recommended setup), Alibaba ModelStudio, Third-party Providers (built-in providers such as DeepSeek, MiniMax, Z.AI, Kimi, and OpenRouter, connected with an API key), and Custom Provider (a local server, proxy, or unsupported provider). For a QwenCloud key, choose QwenCloud → Token Plan or QwenCloud → Standard API Key and then pick the platform (China or International) — see Authentication. For the Alibaba Cloud Token Plan (intl), choose Alibaba ModelStudio → Token Plan; to use a ModelStudio API key, choose Alibaba ModelStudio → Standard API Key and follow the API setup guide (Beijing / intl). Then let's start with understanding your codebase. Try one of these commands:

text
what does this project do?

You'll be prompted to log in on first use. That's it! Continue with Quickstart (5 mins) →

[!tip]

See troubleshooting if you hit issues.

[!note]

New VS Code Extension (Beta): Prefer a graphical interface? Our new VS Code extension provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. Download and install the Qwen Code Companion now.

What Qwen Code does for you

  • Build features from descriptions: Tell Qwen Code what you want to build in plain language. It will make a plan, write the code, and ensure it works.
  • Debug and fix issues: Describe a bug or paste an error message. Qwen Code will analyze your codebase, identify the problem, and implement a fix.
  • Navigate any codebase: Ask anything about your team's codebase, and get a thoughtful answer back. Qwen Code maintains awareness of your entire project structure, can find up-to-date information from the web, and with MCP can pull from external datasources like Google Drive, Figma, and Slack.
  • Automate tedious tasks: Fix fiddly lint issues, resolve merge conflicts, and write release notes. Do all this in a single command from your developer machines, or automatically in CI.
  • Followup suggestions: Qwen Code predicts what you want to type next and shows it as ghost text. Press Tab to accept, or just keep typing to dismiss.

Why developers love Qwen Code

  • Works in your terminal: Not another chat window. Not another IDE. Qwen Code meets you where you already work, with the tools you already love.
  • Takes action: Qwen Code can directly edit files, run commands, and create commits. Need more? MCP lets Qwen Code read your design docs in Google Drive, update your tickets in Jira, or use your custom developer tooling.
  • Unix philosophy: Qwen Code is composable and scriptable. tail -f app.log | qwen -p "Slack me if you see any anomalies appear in this log stream" works. Your CI can run qwen -p "If there are new text strings, translate them into French and raise a PR for @lang-fr-team to review".

Users Quickstart (`docs/users/quickstart.md`)

Quickstart

👏 Welcome to Qwen Code!

This quickstart guide will have you using AI-powered coding assistance in just a few minutes. By the end, you'll understand how to use Qwen Code for common development tasks.

Before you begin

Make sure you have:

  • A terminal or command prompt open
  • A code project to work with
  • An API key or Token Plan subscription from 千问AI平台 / QwenCloud (China / International) — the recommended setup — or an API key from Alibaba Cloud ModelStudio (Beijing / intl), or an Alibaba Cloud Token Plan (Beijing / intl) subscription

Step 1: Install Qwen Code

To install Qwen Code, use one of the following methods:

Linux / macOS

sh
curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash

Windows

powershell
irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex

[!note]

It's recommended to restart your terminal after installation to ensure environment variables take effect.

Manual Installation

Prerequisites

Make sure you have Node.js 22 or later installed. Download it from nodejs.org.

NPM

bash
npm install @qwen-code/qwen-code@latest

Homebrew (macOS, Linux)

bash
brew install qwen-code

Step 2: Set up authentication

When you start an interactive session with the qwen command, you'll be prompted to configure authentication:

bash
# You'll be prompted to set up authentication on first use
qwen
bash
# Or run /auth anytime to change authentication method
/auth

The first-run menu lets you connect a model provider. Choose one of:

  • QwenCloud — the recommended setup. Opens a sub-menu:

    • Token Plan: subscription with a monthly Credit quota on a dedicated endpoint (Personal and Team editions).
    • Standard API Key: pay-as-you-go, connect with an existing platform API key.

    Either way you then pick the platform explicitly (China or International), so the UI language never decides your endpoint. See Authentication → Option 1: QwenCloud for details.

  • Alibaba ModelStudio — opens a sub-menu:

    • Token Plan: usage-based billing with a dedicated endpoint, aimed at teams and companies. See the Token Plan guide (intl) for setup instructions.
    • Standard API Key: connect with an existing API key from Alibaba Cloud ModelStudio (Beijing / intl). See the API setup guide (Beijing / intl) for details.
  • Third-party Providers — choose a built-in provider (DeepSeek, Grok, MiniMax, Z.AI, Kimi, Idealab, ModelScope, OpenRouter, Requesty, and more) and connect with an API key.

  • Custom Provider — manually connect a local server, proxy, or unsupported provider.

⚠️ Note: Qwen OAuth was discontinued on April 15, 2026. If you were previously using Qwen OAuth, please switch to one of the methods above.

[!note]

Qwen Code stores its configuration and customizations in .qwen directories: ~/.qwen/ for user-level settings (e.g. settings.json) and global custom commands, and /.qwen/ for project-specific files such as custom commands and summaries.

[!tip]

To configure authentication, start Qwen Code and run /auth. Use /doctor to check your current configuration at any time. See the Authentication page for details.

Step 3: Start your first session

Open your terminal in any project directory and start Qwen Code:

bash
# optional
cd /path/to/your/project
# start qwen
qwen

You'll see the Qwen Code welcome screen with your session information, recent conversations, and latest updates. Type /help for available commands.

Chat with Qwen Code

Ask your first question

Qwen Code will analyze your files and provide a summary. You can also ask more specific questions:

text
explain the folder structure

You can also ask Qwen Code about its own capabilities:

text
what can Qwen Code do?

[!note]

Qwen Code reads your files as needed - you don't have to manually add context. Qwen Code also has access to its own documentation and can answer questions about its features and capabilities.

Make your first code change

Now let's make Qwen Code do some actual coding. Try a simple task:

text
add a hello world function to the main file

Qwen Code will:

  1. Find the appropriate file
  2. Show you the proposed changes
  3. Ask for your approval
  4. Make the edit

[!note]

Qwen Code always asks for permission before modifying files. You can approve individual changes or enable "Accept all" mode for a session.

Use Git with Qwen Code

Qwen Code makes Git operations conversational:

text
what files have I changed?
text
commit my changes with a descriptive message

You can also prompt for more complex Git operations:

text
create a new branch called feature/quickstart
text
show me the last 5 commits
text
help me resolve merge conflicts

Fix a bug or add a feature

Qwen Code is proficient at debugging and feature implementation.

Describe what you want in natural language:

text
add input validation to the user registration form

Or fix existing issues:

text
there's a bug where users can submit empty forms - fix it

Qwen Code will:

  • Locate the relevant code
  • Understand the context
  • Implement a solution
  • Run tests if available

Test out other common workflows

There are a number of ways to work with Qwen Code:

Refactor code

text
refactor the authentication module to use async/await instead of callbacks

Write tests

text
write unit tests for the calculator functions

Update documentation

text
update the README with installation instructions

Code review

text
review my changes and suggest improvements

[!tip]

Remember: Qwen Code is your AI pair programmer. Talk to it like you would a helpful colleague - describe what you want to achieve, and it will help you get there.

Essential commands

Here are the most important commands for daily use:

Command What it does Example
qwen start Qwen Code qwen
/auth Change authentication method (in session) /auth
/doctor Check current authentication and environment /doctor
/help Display help information for available commands /help or /?
/compress Replace chat history with summary to save Tokens /compress
/clear Clear conversation history and free up context /clear (aliases: reset, new)
/theme Change Qwen Code visual theme /theme
/language View or change language settings /language
→ ui [language] Set UI interface language /language ui zh-CN
→ output [language] Set LLM output language /language output Chinese
/quit Exit Qwen Code immediately /quit or /exit

See the CLI reference for a complete list of commands.

Pro tips for beginners

Be specific with your requests

  • Instead of: "fix the bug"
  • Try: "fix the login bug where users see a blank screen after entering wrong credentials"

Use step-by-step instructions

  • Break complex tasks into steps:
text
1. create a new database table for user profiles
2. create an API endpoint to get and update user profiles
3. build a webpage that allows users to see and edit their information

Let Qwen Code explore first

  • Before making changes, let Qwen Code understand your code:
text
analyze the database schema
text
build a dashboard showing products that are most frequently returned by our UK customers

Save time with shortcuts

  • Press ? to see all available keyboard shortcuts
  • Use Tab for command completion
  • Press ↑ for command history
  • Type / to see all slash commands

Getting help

  • In Qwen Code: Type /help or ask "how do I..."
  • Documentation: You're here! Browse other guides
  • Community: Join our GitHub Discussion for tips and support

Users Features Channels Overview (`docs/users/features/channels/overview.md`)

Channels

Channels let you interact with a Qwen Code agent from messaging platforms like Telegram, WeChat, QQ, DingTalk, WeCom, or Feishu, instead of the terminal. You send messages from your phone or desktop chat app, and the agent responds just like it would in the CLI.

Code-hosting platforms (starting with GitHub) and authenticated workspace accounts (starting with DingTalk Workspace) are also supported through channels. Email connects a dedicated mailbox through IMAP and SMTP.

How It Works

When you run qwen channel start, Qwen Code:

  1. Reads channel configurations from your settings.json
  2. Spawns a single agent process using the Agent Client Protocol (ACP)
  3. Connects to each messaging platform and starts listening for messages
  4. Routes incoming messages to the agent and sends responses back to the correct chat

All channels share one agent process with isolated sessions per user. Each channel can have its own working directory, model, and instructions.

Quick Start

  1. Set up a bot or authenticated workspace account (see channel-specific guides: Telegram, WeChat, QQ Bot, DingTalk, DingTalk Workspace, WeCom, WeCom Callback, Feishu, GitHub, Email)
  2. Add the channel configuration to ~/.qwen/settings.json
  3. Run qwen channel start to start all channels, or qwen channel start <name> for a single channel

Want to connect a platform that isn't built in? See Plugins to add a custom adapter as an extension.

Configuration

Channels are configured under the channels key in settings.json. Each channel has a name and a set of options:

json
{
  "channels": {
    "my-channel": {
      "type": "telegram",
      "token": "$MY_BOT_TOKEN",
      "privatePolicy": "allowlist",
      "allowedUsers": ["123456789"],
      "sessionScope": "user",
      "cwd": "/path/to/working/directory",
      "instructions": "Optional system instructions for the agent.",
      "groupPolicy": "disabled",
      "groups": {
        "*": { "requireMention": true }
      }
    }
  }
}

Options

Option Required Description
type Yes Channel type: telegram, weixin, qq, dingtalk, dws, wecom, wecom-callback, feishu, github, gitlab, email, or a custom type from an extension (see Plugins)
token See description Required for Telegram (bot token) and WeCom Callback (verification token). Supports $ENV_VAR syntax. See WeCom Callback for application credentials
clientId DingTalk, Feishu DingTalk AppKey or Feishu App ID. Supports $ENV_VAR syntax
clientSecret DingTalk, Feishu DingTalk AppSecret or Feishu App Secret. Supports $ENV_VAR syntax
botId WeCom WeCom intelligent robot Bot ID. Supports $ENV_VAR syntax. See WeCom
secret WeCom WeCom intelligent robot Secret. Supports $ENV_VAR syntax. See WeCom
model No Model to use for this channel (e.g., qwen3.5-plus). Overrides the default model. Useful for multimodal models that support image input
privatePolicy No Private access: disabled, allowlist, pairing, or open; new managed channels default to pairing
allowedUsers No Private user IDs allowed without pairing (used by private allowlist and pairing policies)
sessionScope No How sessions are scoped: user (default), chat_thread, or single. Legacy thread remains compatible when already configured but is not offered for new Web Shell configurations
sessionRotation No Start a fresh session after maxTurns routed messages or maxAgeHours elapsed, whichever comes first. Requires a positive bound and cannot be combined with multiSession.
multiSession No Retain up to eight owner-scoped named tasks in one chat. Requires daemon-managed mode, sessionScope: "user", no webhooks or group-history backfill, and no enabled Channel loops
cwd No Working directory for the agent. Defaults to the current directory
approvalMode No Tool approval mode for channel sessions. Unattended webhook tasks require yolo; the setting applies to every session on the channel
instructions No Custom instructions prepended to the first message of each session
webhooks No Webhook sources and delivery targets for daemon-managed channels. See Webhook-triggered tasks
groupPolicy No Group chat access: disabled (default), allowlist, pairing, or open. See Group Chats
senderPolicy No Deprecated: allowlist, pairing, or open; used only when privatePolicy is absent and DM access is enabled
dmPolicy No Deprecated: open or disabled; used only when privatePolicy is absent
operators No Who may operate a shared session (/approve, /clear, /loop, ...). Unset or empty grants no shared-session operator permissions. See Shared-Session Operators
groupHistoryLimit No Opt-in group history backfill. 0 or omitted disables it. A positive number persists that many unmentioned group messages from senders admitted by the group member policy for the next bot mention/reply.
groups No Per-group settings. Keys are group chat IDs or "*" for defaults. See Group Chats
dispatchMode No What happens when you send a message while the bot is busy: steer (default), collect, or followup. See Dispatch Modes

Message Routes

Use messageRoutes to run multiple workflows through one Channel connection.
Each key is a case-sensitive message prefix and each value contains instructions
for that route:

json
{
  "messageRoutes": {
    "/review": "Review the requested pull request.",
    "/QA": "Answer questions about this repository."
  },
  "defaultMessageRoute": "/QA"
}

A prefix must be followed by whitespace and a non-empty message. The longest
matching prefix wins; leading mentions are skipped when matching. The matched prefix is removed before the
message reaches the agent. Route instructions are combined with the Channel's
common instructions on the first turn of each session. Routes use separate
sessions within the configured sessionScope. Existing Channel memory remains
shared at its configured chat/thread scope; routes are not a permissions sandbox.

Without defaultMessageRoute, unmatched chat messages are ignored. To accept
ordinary messages, set it to a key in messageRoutes; those messages keep their
original text and use that route's instructions and session. Empty prefixes and
empty route maps are invalid. Route instructions may be empty. Prefixes and
instructions are trimmed when loading configuration. messageRoutes cannot be
combined with multiSession.

Routing does not grant access: private and group policies still apply. Shared
and agent commands also need a route prefix, for example /QA /help. Messages
without user-authored text and provider-generated system events remain outside
prefix routing.

Private Policy

privatePolicy controls private conversations independently of groups:

  • disabled — Ignore private messages without creating pairing requests.
  • allowlist — Only users listed in the top-level allowedUsers may send private messages.
  • pairing — Users in allowedUsers are admitted directly. Other users need a locally approved user pairing. See DM Pairing.
  • open — Anyone can send private messages.

New managed channels default to pairing. The deprecated senderPolicy and dmPolicy keys remain supported: an explicit privatePolicy takes precedence; otherwise dmPolicy: "disabled" disables private access, and otherwise senderPolicy supplies the policy (default allowlist). These old keys do not restrict group members. Saving from the editor uses privatePolicy and preserves existing deprecated keys.

Shared-Session Operators

In a shared session (sessionScope: "chat_thread" or "single", and group chats under "thread"), some commands affect everyone in the conversation: /approve, /deny, /cancel, /clear, /who, /status, /loop, /btw, the loop tool, and steering an in-flight turn. Only the session's operators may use them; other members' messages queue instead of steering. Sessions that are not shared belong to their own sender, who may always use them.

Set operators to the user IDs allowed to manage shared sessions. Omitted or empty operators grants nobody this role. Private allowlists, user pairing, group pairing, and group member lists never imply operator permissions. Operators must still pass normal conversation admission.

For example, groups: { "*": { "senders": "open" } } with operators: ["alice"] lets every admitted group member start a turn, but only Alice can manage its shared session.

A saved loop is checked against the same rule when it fires, using its creator as the sender, and is disabled if the creator is no longer an operator.

Session Scope

Controls how conversation sessions are managed:

  • user (default) — One session per user. All messages from the same user share a conversation.
  • chat_thread — One session per chat thread/topic, shared by participants in that thread.
  • thread — Legacy thread/topic routing retained for existing configurations.
  • single — One shared session for all users. Everyone shares the same conversation.

Named Tasks

Daemon-managed Channels can retain several named conversations for the same user in one chat:

json
{
  "channels": {
    "my-channel": {
      "type": "telegram",
      "sessionScope": "user",
      "multiSession": true
    }
  }
}

The catalog is private to the exact channel, chat, and sender. Task names use 1–32 ASCII letters, numbers, underscores, or hyphens, and are unique case-insensitively. Up to eight tasks may be open; closing a task detaches it without deleting its transcript, so selecting it later reopens the exact conversation. Session IDs are never accepted by or shown in chat commands.

Named results identify their originating task: direct chats use [task], while group chats use [sender · task]. Named text permission prompts also show the exact request ID and the corresponding /approve , /approve-always , and /deny commands. The label is presentation-only and is not stored in the model transcript.

One task remains selected to receive the next normal message, but other named tasks may keep running concurrently. /session new <name> shares the configured workspace, while /session new <name> --worktree creates an isolated checkout for that task under the daemon workspace's .qwen/worktrees/ directory. The daemon verifies the persisted worktree owner before reopening the task after a restart; a missing, changed, or foreign ownership record fails closed instead of silently moving the task into the shared workspace. Creating or selecting another task does not cancel or retarget earlier work, and late results retain their originating task label. A busy task cannot be closed, but its active prompt can be cancelled with /session cancel [<name>] through the existing Channel cancellation behavior. Independently queued turns are not cancelled, but in collect dispatch mode any follow-ups buffered behind the cancelled prompt are discarded by that existing behavior. Media preparation is not targeted. Bare permission commands apply only to the selected task, while an explicit request ID can answer an owned inactive task. /clear, /new, and /reset also work on a selected worktree task: the task gets a fresh conversation while its worktree and files are kept. A busy worktree task refuses the reset until its prompt finishes, and a task whose worktree record was damaged reports the failure without touching files. Channel memory remains scoped to the chat rather than to a named task.

This mode is unavailable in standalone qwen channel start, with webhooks, with non-zero channel or group groupHistoryLimit, or with Channel loops. If an enabled loop already exists for that channel, the daemon worker refuses to start until the loop is disabled.

Session Rotation

Long-lived routes can start a fresh conversation after a configured limit:

json
{
  "channels": {
    "my-bot": {
      "type": "dingtalk",
      "sessionScope": "chat_thread",
      "sessionRotation": { "maxTurns": 200, "maxAgeHours": 24 }
    }
  }
}

Either positive bound may be used alone; the first one reached triggers rotation before the next routed message. maxTurns counts messages routed to the session, including messages handled without a model turn. The count and age start persist across restarts. A route with a running or queued message waits for a later idle message to rotate, so steady traffic may pass the configured bound. Rotation clears that route's conversation context, retires its old session, and posts a notice in the triggering chat or thread. With sessionScope: "single", other chats sharing the session do not receive the notice. Omitting sessionRotation preserves ongoing session reuse. Named tasks (multiSession) cannot use this option.

Channel Memory

Channel memory stores durable context for one chat or thread. Entries have stable
IDs, so a list response can be used for deterministic follow-up operations.

  • 记住:默认使用 staging 环境 is the deterministic form and saves exactly one
    scalar entry for the current chat or thread.
  • To save several separate facts in one request, use a natural phrase routed
    through the classifier. For example:
    请记住这三条约定:使用 staging;发布前测试;优先中文回复 creates entries
    that you can manage independently. Exact duplicate facts are skipped and
    reported without creating another entry. Requests containing credential-like
    text are rejected; remove secrets and save the non-sensitive facts separately.
  • 查看记忆 lists entries and their stable IDs. Use 查看第 2 页记忆 to view
    a later page, 查看记忆 to view one entry, or a natural filtered
    request such as 只看中文偏好 to list the matching entries.
  • 查看刚才那条记忆, 把关于 staging 的记忆改成默认使用 production, and
    忘掉刚才那条 work when the natural reference resolves to exactly one entry.
    Natural updates and removals first show the proposed change. Confirm an
    update with 确认更新记忆 or confirm memory update, or a removal with
    确认删除记忆 or confirm memory removal, within 60 seconds. Exact-ID
    updates and removals remain immediate and do not need confirmation.
  • 清空记忆 starts the clear-all confirmation flow; 确认清空记忆 completes
    it.

When a natural inspect, update, or removal request matches multiple entries,
the bot returns the candidate IDs and previews without changing memory. There
is no pending selection for an ambiguous result: retry the request with one
exact ID, such as 忘掉 m-a31f0d82c7e4. Exact-ID operations remain the
deterministic fast path. A natural request with no match reports that no entry
matched.

Pending update, removal, and clear confirmations apply only to the sender and
chat or thread that created them. A newer clear, natural update, or natural
removal proposal replaces an older pending one for that sender and target.
Pending confirmations are discarded when the channel process restarts.

The legacy slash aliases /remember-channel, /channel-memory, and
/forget-channel have been removed. They are no longer channel-memory
commands.

Channel memory follows the channel access gates. Any message accepted by
private access, group admission, group member settings,
pairing, and mention requirements can read, write, update, or clear memory for
that chat or thread. Accepted members of the same group share that group's
target store. Use a group's senders: "allowlist" with its allowedUsers when group memory should be
limited to trusted senders — under senders: "open" every member of an
admitted group shares that group's memory store.

Existing legacy CHANNEL.md memory is migrated automatically to structured
CHANNEL.json storage on the first mutation. Structured memory persists across
standalone channel and daemon-managed channel restarts, and is injected when a
fresh target-scoped session starts, including after /clear.

After that initial injection, each accepted message also recalls up to three
relevant entries for that message. This keeps durable facts available during a
long-running session without adding every stored entry to every turn. Recall is
based on the current message and does not modify the stored memory.

Memory remains keyed to the current chat or thread. It is not injected or
recalled in a sessionScope: single session, because that session is shared
across the whole channel rather than scoped to one target.

Channel memory does not automatically learn facts from normal conversation or
accept 第一个 as confirmation for an ambiguous natural reference. Use a clear
remember request and an exact entry ID when a natural reference is ambiguous.

Token Security

Bot tokens should not be stored directly in settings.json. Instead, use environment variable references:

json
{
  "token": "$TELEGRAM_BOT_TOKEN"
}

Set the actual token in your shell environment or in a .env file that gets loaded before running the channel.

DM Pairing

When privatePolicy is set to "pairing", unknown senders go through an approval flow:

  1. An unknown user sends a message to the bot
  2. The bot replies with an 8-character pairing code (e.g., VEQDDWXJ)
  3. The user shares the code with you (the bot operator)
  4. You approve them via CLI:
bash
qwen channel pairing approve my-channel VEQDDWXJ

Once approved, the user's ID is saved to the channel's workspace-scoped allowlist (~/.qwen/channels/<workspace-scope>/<name>-allowlist.json) and all future messages go through normally. Pairing state is scoped per workspace, so two workspaces using the same channel name keep separate approvals.

Pairing CLI Commands

bash
# List pending pairing requests
qwen channel pairing list my-channel

# Approve a request by code
qwen channel pairing approve my-channel <CODE>

Run these from the channel's workspace directory (or pass --cwd <dir>) — pairing state is stored per workspace.

Pairing Rules

  • Codes are 8 characters, uppercase, using an unambiguous alphabet (no 0/O/1/I)
  • Codes expire after 1 hour
  • Maximum 3 pending requests per channel at a time, and at most one per sender — additional requests are declined until one expires or is approved
  • Users listed in allowedUsers in settings.json skip user pairing; under groupPolicy: "pairing", the group itself must still be approved
  • Approved users are stored per workspace in ~/.qwen/channels/<workspace-scope>/<name>-allowlist.json — treat this file as sensitive

Group Chats

By default, the bot only works in direct messages. To enable group chat support, set groupPolicy to "allowlist", "pairing", or "open".

Group Policy

Controls whether the bot participates in group chats at all:

  • disabled (default) — The bot ignores all group messages. Safest option.
  • allowlist — The bot only responds in groups explicitly listed in groups by chat ID. The "*" key provides default settings but does not act as a wildcard allow.
  • pairing — A deliberate mention or reply from an unknown group creates one pairing request for the group. Once approved, members can use the bot subject to the group's senders setting (default open); privatePolicy continues to control direct messages.
  • open — The bot responds in all groups it's added to. Use with caution.

Approve a group with the same CLI command used for user pairing. The pending
request identifies the group and the member who initiated it:

bash
qwen channel pairing approve my-channel <CODE>

Group approvals are stored by the group's chat ID in the channel's workspace scope. On GitHub and GitLab the chat ID is the repository/project path, so a rename or transfer detaches the stored approval — re-approve the group after renaming. A repo or project re-created under the same path inherits any stale approval — revoke group approvals after any rename, transfer, or deletion.
An unmentioned message never creates a group pairing request, even when a group
sets requireMention to false; after approval, the configured mention policy
applies normally.

Group pairing requests share the same pending queue as DM pairing requests:
a channel holds at most 3 pending requests overall, and a sender holds at most
one pending request across user and group requests (see
Pairing Rules).

Mention Gating

In groups, the bot requires an @mention or a reply to one of its messages by default. This prevents the bot from responding to every message in a group chat.

Configure per-group with the groups setting:

json
{
  "groups": {
    "*": { "requireMention": true },
    "-100123456": { "requireMention": false }
  }
}
  • "*" — Default settings for all groups. Only sets config defaults, not an allowlist entry.
  • Group chat ID — Override settings for a specific group. Overrides "*" defaults.
  • requireMention (default: true) — When true, the bot only responds to messages that @mention it or reply to one of its messages. When false, the bot responds to all messages (useful for dedicated task groups).

Group Senders

Admitted groups allow all members by default. Set senders in groups to restrict members independently of private access:

json
{
  "privatePolicy": "pairing",
  "groupPolicy": "open",
  "groups": {
    "*": { "senders": "open" },
    "-100123456": { "senders": "allowlist", "allowedUsers": ["alice", "bob"] }
  }
}
  • open (default) — Any member of an admitted group may use the bot. Private policy and user pairing have no effect on group members.
  • allowlist — only the group's allowedUsers may speak. This list is separate from the channel-level allowedUsers.

All group settings use field-by-field inheritance: the specific group, then "*", then the channel default where applicable, then the built-in default. Empty objects inherit defaults; arrays replace rather than merge, including allowedUsers: []. For example, a group that sets only senders: "allowlist" uses the allowedUsers from "*". Under groupPolicy: "allowlist" a group ID key also admits the group, so a per-group entry there both admits the group and sets its senders.

pairing is deliberately not a senders value: pairing approvals are stored per user, so approving someone through a group message would also unlock their direct messages. Use groupPolicy: "pairing" to admit an entire group instead.

On the github and gitlab channels all inbound traffic is group traffic, so only groupPolicy and group member settings govern admission — see those channels' Security notes before using it on a public repository or project. Under sessionScope: "single" direct messages and groups share one conversation, so opening a group also exposes direct-message history to its members.

In the Web Shell channel editor, Who can talk in groups and Allowed group member IDs edit groups["*"], and Session operators edits operators. When senders is unset, the editor shows its effective default and writes nothing until you pick a value. Per-group overrides and an explicitly empty "operators": [] are set in settings.json; saving from the editor keeps them.

Group History Backfill

By default, Qwen ignores unmentioned group messages and does not store them as session turns. To let the next @mention include recent group context, set groupHistoryLimit to a positive number.

json
{
  "channels": {
    "my-dingtalk": {
      "type": "dingtalk",
      "clientId": "$DINGTALK_CLIENT_ID",
      "clientSecret": "$DINGTALK_CLIENT_SECRET",
      "groupPolicy": "open",
      "groupHistoryLimit": 50,
      "groups": {
        "*": { "requireMention": true },
        "sensitive-group-id": {
          "requireMention": true,
          "groupHistoryLimit": 0
        }
      }
    }
  }
}
  • Omitted or 0 disables backfill.
  • Group-level groupHistoryLimit overrides the channel-level value.
  • Only messages from senders the group's senders setting admits are persisted (all admitted group members by default) — so senders: "open" also widens whose messages groupHistoryLimit records.
  • Messages rejected by groupPolicy or group allowlist are not persisted.
  • Pending group history is stored as local JSONL under ~/.qwen/channels/<channel-name>-group-history.jsonl or $QWEN_HOME/channels/<channel-name>-group-history.jsonl.
  • Cached messages are injected as untrusted context on the next real trigger and are not written as standalone session turns.

How group messages are evaluated

text
1. groupPolicy — is this group disabled, listed, paired, or open? (no → ignore/pairing flow)
2. requireMention — was the bot mentioned/replied to? (no → no task; eligible history may be cached)
3. senders — is this member allowed?                  (open, or group member allowlist)
4. Route to session

Telegram Setup for Groups

  1. Add the bot to a group
  2. Disable privacy mode in BotFather (/mybots → Bot Settings → Group Privacy → Turn Off) — otherwise the bot won't see non-command messages
  3. Remove and re-add the bot to the group after changing privacy mode (Telegram caches this setting)

Finding a Group Chat ID

To find a group's chat ID for the groups allowlist:

  1. Stop the bot if it's running
  2. Send a message mentioning the bot in the group
  3. Use the Telegram Bot API to check queued updates:
bash
curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates" | python3 -m json.tool

Look for message.chat.id in the response — group IDs are negative numbers (e.g., -5170296765).

Media Support

Channels support sending images and files to the agent, not just text.

Images

Send a photo to the bot and the agent will see it — useful for sharing screenshots, error messages, or diagrams. The image is sent directly to the model as a vision input.

To use image support, configure a multimodal model for the channel:

json
{
  "channels": {
    "my-channel": {
      "type": "telegram",
      "model": "qwen3.5-plus",
      ...
    }
  }
}

Files

Send a document (PDF, code file, text file, etc.) to the bot. The file is downloaded and saved to a temporary directory, and the agent is told the file path so it can read the contents using its file-reading tools.

Files work with any model — no multimodal support required.

Platform differences

Feature Telegram WeChat DingTalk Feishu QQ Bot
Images Direct download via Bot API CDN download with AES decryption downloadCode API (two-step) Open API resources endpoint (authenticated GET, 50MB limit) Vision input, also saved to a local path (8MB cap)
Files Direct download via Bot API (20MB limit) CDN download with AES decryption downloadCode API (two-step) Open API resources endpoint (50MB limit) Direct download, saved to a local path (20MB limit)
Captions Photo/file captions included as message text Not applicable Rich text: mixed text + images in one message Rich text (post): text extracted; embedded images ignored Message text is kept as the caption

QQ Bot accepts images, videos, files, and voice messages. An image is passed to the agent as vision input and is also saved to a temporary local path; a video or a file is saved to a temporary local path and the agent is told that path, with a file keeping the sender's extension so the read tool can classify it. A voice message is saved as audio — the platform's SILK-to-WAV conversion is preferred when available — and needs no text to start a turn. Image input requires a model that accepts images — with a text-only model the agent still gets the path. See QQ Bot for details.

WeCom accepts text, images, mixed text plus images, files, videos, and voice messages (transcribed). Images are passed to the agent as attachments; files and videos are downloaded to temporary local paths. See WeCom for details.

Dispatch Modes

Controls what happens when you send a new message while the bot is still processing a previous one.

  • steer (default) — The bot cancels the current request and starts working on your new message. Best for normal chat, where a follow-up usually means you want to correct or redirect the bot.
  • collect — Your new messages are buffered. When the current request finishes, all buffered messages are combined into a single follow-up prompt. Good for async workflows where you want to queue up thoughts.
  • followup — Each message is queued and processed as its own separate turn, in order. Useful for batch workflows where each message is independent.
json
{
  "channels": {
    "my-channel": {
      "type": "telegram",
      "dispatchMode": "steer",
      ...
    }
  }
}

You can also set dispatch mode per group, overriding the channel default:

json
{
  "groups": {
    "*": { "requireMention": true, "dispatchMode": "steer" },
    "-100123456": { "dispatchMode": "collect" }
  }
}

Response delivery

Channels use their normal response delivery path. The shared delivery layer sends completed responses, and adapters may provide native progressive display, such as updating an interactive card in place. Platform message-length limits may still split long responses.

The obsolete blockStreaming, blockStreamingChunk, and blockStreamingCoalesce settings are no longer supported and can be removed from channel configuration. They do not affect delivery. Channel settings management rejects newly added or changed values for these fields. An unchanged stored value is retained, or removed, when the edit keeps the channel's type; changing a channel's type requires removing these fields first.

Turn output mode

outputMode is a shared channel setting with adapter opt-in. Currently only DingTalk supports it and defaults to per_turn when the setting is omitted. Other adapters retain their existing behavior and receive no output-mode default: the channel editor does not offer this field, and configuration parsing or management saves reject an explicit value on unsupported adapters.

  • per_task waits for the main task and its associated background tasks and notifications, then delivers one final result containing the last non-empty assistant reply for that task.
  • per_response delivers each complete assistant response, not each token chunk.
  • per_turn delivers the last non-empty assistant reply within each turn. The main turn finishes immediately when its prompt ends; later background notification turns deliver separate results.

In the default per_turn mode, a later background callback cannot reopen or replace the completed main result. A main result followed by eleven independent callback turns can therefore produce twelve result messages or cards. Choose per_task when the final result should wait for the associated background work. These modes select assistant output; they do not generate an extra summary or concatenate every intermediate reply.

The selected policy applies whether interactive cards are enabled or replies use ordinary messages. The shared layer owns output selection and task/turn coordination; native rendering, media and fallback delivery remain adapter-specific. See DingTalk turn output mode for presentation details and the conversation scope. Channel loops and webhook runs are unchanged.

Scheduled Channel Loops

Channels have a persistent scheduler for prompts that should run later and push
their result back to the same chat. You can ask the agent naturally, for
example, Every 15 minutes, check the deployment and report any change, or use
the local commands directly:

text
/loop add "*/15 * * * *" check the deployment and report any change
/loop list
/loop inspect <id>
/loop cancel <id>

The agent uses the channel_loop_create, channel_loop_list, and
channel_loop_cancel tools when it manages these jobs for you. Schedules use
standard five-field cron expressions in the machine's local time. The job runs
unattended and its final response is delivered automatically to the chat that
created it.

Channel loops differ from the session-scoped tasks described in
Run Prompts on a Schedule:

  • They are stored under $QWEN_HOME/channels/ — standalone channels use
    cron.json directly, while daemon-managed channels use a per-workspace file
    under daemon/. Both survive channel restarts.
  • They are scoped to the current channel chat or thread. Each target can have up
    to 10 enabled loops, and each prompt is limited to 4,000 characters.
  • They require an adapter and target that support proactive delivery. Telegram,
    DingTalk, Feishu, and WeCom opt in, subject to platform-specific target
    restrictions.
  • They are unavailable with sessionScope: "single" because that scope is not
    tied to one chat target.
  • A saved loop is disabled if its target is no longer authorized when it is due.

Background Agent Results

When the agent delegates work to a background subagent or fork, the completion
result is delivered back to the channel chat that owns the session. Delivery
can happen after the original turn has ended, so keep the channel service or
daemon running while background work is active.

Slash Commands

Channels support slash commands. These are handled locally (no agent round-trip):

  • /help — List available commands
  • /clear — Clear your session and start fresh (aliases: /reset, /new)
  • /status — Show session info and access policy
  • /btw <question> — Ask a side question without interrupting the current task; text-only questions up to 4096 characters, requires an agent connection with side-question support
  • /sessions [all] — List open named tasks, or include closed tasks; available only with multiSession: true
  • /session current — Show the selected named task
  • /session new <name> — Create and select a shared-workspace task
  • /session new <name> --worktree — Create and select a task in its own Git worktree; daemon-managed named-task mode only
  • /session use <name> — Select an open task or reopen a closed task
  • /session cancel [<name>] — Cancel the selected task's active prompt, or name another owned task; independently queued turns are not cancelled, but collect-mode follow-ups buffered behind the cancelled prompt are discarded by the existing cancellation behavior; media preparation is not targeted
  • /session close <name> — Close a task without deleting its transcript
  • /loop add "<cron>" — Create a persistent scheduled channel loop
  • /loop list — List loops for the current chat
  • /loop inspect — Show loop status and run details
  • /loop cancel — Disable a loop

All other slash commands (e.g., /compress, /summary) are forwarded to the agent. Named-task commands are registered only when the mode is enabled, so /sessions remains agent-visible for existing configurations.

Named-task commands work on all channel types (Telegram, WeChat, QQ, DingTalk, WeCom, Feishu, GitHub). /cancel is currently registered only by Telegram, and loop creation requires proactive delivery support for the current adapter and target.

Running

bash
# Start all configured channels (shared agent process)
qwen channel start

# Start a single channel
qwen channel start my-channel

# Check if the service is running
qwen channel status

# Stop the running service
qwen channel stop

The bot runs in the foreground. Press Ctrl+C to stop, or use qwen channel stop from another terminal.

Experimental Daemon-Managed Mode

You can also run configured channels under qwen serve:

bash
# Start one channel under the daemon lifecycle
qwen serve --channel my-channel

# Start all configured channels
qwen serve --channel all

# Or enable channels later on a token-protected daemon
QWEN_SERVER_TOKEN=secret qwen serve
qwen channel set my-channel --token secret

# Query or stop the daemon-managed selection
qwen channel status --daemon-url http://127.0.0.1:4170 --token secret
qwen channel stop --daemon-url http://127.0.0.1:4170 --token secret

This mode starts workspace-grouped channel worker processes owned by qwen serve. Workers connect back to the daemon through the SDK and use the same channel adapters. They are separate from the daemon process, so a channel adapter crash does not crash the daemon. An explicit --channel selection takes precedence and fails daemon startup if it cannot become ready. On a flagless boot, every trusted registered workspace's own serve.channels setting is restored, and a name that cannot be hosted is skipped with a log instead of stopping the others. all remains primary-workspace only. Registering a workspace later restores its channels too — without holding the registration open — once per daemon run, and not at all when the daemon was started with an explicit --channel or after qwen channel stop. Unlike the boot restore, one name it cannot host costs that workspace its whole list. Without any of these sources, the daemon does not load channel adapters or reserve the lease until the first qwen channel set, or until a workspace that configures its own serve.channels registers.

Automatic restore skips invalid startup settings and validation or lease failures that occur before workers start, while preserving unrelated settings. After a worker startup fails, the daemon continues only once cleanup succeeds. A global runtime startup timeout or an unconfirmed worker stop still follows the normal startup-failure path; the service lease remains held while worker termination is unconfirmed. Check the daemon log for messages identifying serve.channels when a channel does not restore.

Stored startup names must be non-empty, have no leading or trailing whitespace, and contain no unsafe control or invisible characters. Invalid entries are skipped individually and logged by their array index. Startup does not rename instances or rewrite the configuration. The channel startup toggle reflects the saved setting; runtime state shows whether the channel is running.

qwen serve --channel is not the same service as qwen channel start. Standalone qwen channel start still uses the ACP-backed channel service and can run channel configs with different cwd values. Daemon-managed channels require every selected channel's cwd to resolve to a workspace registered by the daemon. In multi-workspace mode, a selection replacement keeps workers for workspaces whose ordered channel list did not change; all remains primary-workspace-only.

Without --daemon-url, qwen channel status and qwen channel stop retain standalone pidfile behavior. Their --daemon-url variants query or stop the daemon manager. Runtime selections are not written to settings and do not survive daemon restart. If a ready worker exits unexpectedly, the daemon continues running and reports a channel-worker warning in /daemon/status.

Webhook-triggered tasks

Daemon-managed channels can also accept authenticated webhook events. Qwen receives the event as context, summarizes and decides what matters, and then delivers the final response to the configured chat target. This is not a raw notification relay.
Webhook tasks require approvalMode: "yolo" because they run without interactive approval. That setting applies to the whole channel, not only webhook turns, so use a dedicated webhook channel or tightly restrict normal chat senders for that channel.

Example channel config:

json
{
  "channels": {
    "dingtalk-main": {
      "type": "dingtalk",
      "clientId": "$DINGTALK_CLIENT_ID",
      "clientSecret": "$DINGTALK_CLIENT_SECRET",
      "cwd": "/repo",
      "privatePolicy": "allowlist",
      "allowedUsers": ["12345"],
      "approvalMode": "yolo",
      "sessionScope": "user",
      "webhooks": {
        "sources": {
          "github-ci": {
            "secretEnv": "QWEN_CHANNEL_GITHUB_CI_SECRET",
            "targets": {
              "operator": {
                "chatId": "DINGTALK_USER_ID",
                "senderId": "webhook:github-ci",
                "isGroup": false
              },
              "team": {
                "chatId": "OPEN_CONVERSATION_ID",
                "senderId": "webhook:github-ci",
                "isGroup": true
              }
            }
          }
        }
      }
    }
  }
}

For DingTalk, set isGroup explicitly on every target. A direct-message target uses the DingTalk user ID as chatId with isGroup: false; a group target uses the group openConversationId with isGroup: true. Other adapters may require their own proactive target shape.

Daemon-managed DingTalk, Feishu, Telegram, and WeCom channels dynamically observe contacts from authorized inbound messages. List contacts observed in the primary workspace during the default seven-day freshness window:

bash
curl -H "Authorization: Bearer $QWEN_SERVER_TOKEN" \
  http://127.0.0.1:4170/workspace/channel/observed-contacts

Use GET /workspaces/:workspace/channel/observed-contacts to select another registered, trusted workspace. Add ?freshWithinSeconds=N to choose a window from one second through 365 days. The daemon advertises this API with the workspace_channel_observed_contacts capability.

The response returns complete platform IDs and labels. Group labels use names already present in accepted inbound messages when available: DingTalk supplies conversationTitle, and Telegram supplies chat.title. Feishu and WeCom group labels currently fall back to their complete IDs; no platform directory or group-detail API is queried. Topic labels also fall back to complete IDs. Each lastObservedAt is a canonical ISO 8601 UTC timestamp with millisecond precision; clients can convert it to the user's local time zone for display. Top-level users contains users observed in direct messages. groups contains observed group conversations, groups[].users contains users observed in each group, and groups[].topics[].users contains users observed in Feishu or Telegram topics:

json
{
  "users": [
    {
      "channelName": "feishu-main",
      "label": "Example User",
      "id": "ou_complete_user_id",
      "lastObservedAt": "2026-07-17T08:00:00.000Z"
    }
  ],
  "groups": [
    {
      "channelName": "feishu-main",
      "label": "oc_complete_chat_id",
      "id": "oc_complete_chat_id",
      "lastObservedAt": "2026-07-17T08:05:00.000Z",
      "users": [
        {
          "label": "Example User",
          "id": "ou_complete_user_id",
          "lastObservedAt": "2026-07-17T08:05:00.000Z"
        }
      ],
      "topics": []
    }
  ]
}

These nested users are observed participants, not authoritative group membership. Only messages that pass direct/group, mention, sender, and pairing gates are recorded. Repeated observations refresh labels and timestamps; passive observation cannot detect a leave or deletion until the relationship becomes stale. Message content is never stored. The bounded registry lives under $QWEN_HOME/channels/daemon/<workspaceHash>/observed-contacts.json, outside the workspace checkout and partitioned per workspace. Its 500-observation limit is shared by all channels and conversations in that workspace, and observations older than 365 days are removed on the next accepted write. If the registry becomes malformed or uses an unsupported version, delete that file to reset it; accepted traffic recreates it. Webhook configuration and delivery are unchanged.

Start qwen serve with the channel worker enabled:

bash
QWEN_SERVER_TOKEN="$QWEN_SERVER_TOKEN" qwen serve --require-auth --channel dingtalk-main

Example request:

bash
curl -X POST "http://127.0.0.1:4170/channels/dingtalk-main/webhooks/github-ci" \
  -H "x-qwen-webhook-secret: $QWEN_CHANNEL_GITHUB_CI_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "eventType": "push",
    "targetRef": "operator",
    "title": "CI pipeline finished",
    "payload": {
      "targetRef": "refs/heads/main",
      "repository": "qwen-code",
      "status": "success"
    }
  }'

Webhook routes authenticate with the webhook secret header, even when qwen serve is running with bearer auth enabled. Do not share the daemon bearer token with webhook providers. Webhook config and secretEnv values are loaded when the daemon starts; restart qwen serve after changing webhook sources or rotating secrets. A 202 {"accepted": true} response means the channel worker accepted ownership of the task, not that the final response has already been delivered to chat. Check daemon and channel worker logs, plus /daemon/status, when troubleshooting delivery failures.

Multi-Channel Mode

When you run qwen channel start without a name, all channels defined in settings.json start together sharing a single agent process. Each channel maintains its own sessions — a Telegram user and a WeChat user get separate conversations, even though they share the same agent.

Each channel uses its own cwd from its config, so different channels can work on different projects simultaneously.

Service Management

The channel service uses a PID file (~/.qwen/channels/service.pid) to track the running instance:

  • Duplicate prevention: Running qwen channel start while a service is already running will show an error instead of starting a second instance
  • qwen channel stop: Gracefully stops the running service from another terminal
  • qwen channel status: Shows whether the service is running, its uptime, and session counts per channel

Crash Recovery

If the agent process crashes unexpectedly, the channel service automatically restarts it and attempts to restore all active sessions. Users can continue their conversations without starting over.

  • Sessions are persisted to ~/.qwen/channels/sessions.json while the service is running
  • On crash: the agent restarts within 3 seconds and reloads saved sessions
  • After 3 consecutive crashes, the service exits with an error
  • On clean shutdown (Ctrl+C or qwen channel stop): session data is cleared — the next start is always fresh

Design Openrouter Auth And Models (`docs/design/openrouter-auth-and-models.md`)

OpenRouter Auth and Model Management Design

This document captures the design intent behind the OpenRouter auth flow and the
model management changes introduced with it. It intentionally focuses on the
product and architectural choices, not implementation history.

Goals

  • Let users authenticate with OpenRouter from both CLI and /auth.
  • Reuse the existing OpenAI-compatible provider path instead of adding a new auth
    type for OpenRouter.
  • Make the first-run experience usable without asking users to manage hundreds of
    models immediately.
  • Keep a clear path toward richer model management via /manage-models.

OpenRouter Auth

OpenRouter is integrated as an OpenAI-compatible provider:

  • auth type: AuthType.USE_OPENAI
  • provider settings: modelProviders.openai
  • API key env var: OPENROUTER_API_KEY
  • base URL: https://openrouter.ai/api/v1

This avoids introducing an OpenRouter-specific AuthType when the runtime model
provider path is already OpenAI-compatible. It keeps auth status, model
resolution, provider selection, and settings schema aligned with the existing
provider abstraction.

The user-facing flows are:

  • /auth → OpenRouter for the interactive TUI flow.
  • Environment variables for automation or direct API-key setup:
    OPENROUTER_API_KEY plus OPENAI_BASE_URL=https://openrouter.ai/api/v1.
  • ~/.qwen/settings.json for scripted setup that needs explicit model provider
    entries.

Browser OAuth uses OpenRouter's PKCE flow and writes the exchanged API key into
settings before refreshing auth as AuthType.USE_OPENAI.

Model Management

OpenRouter exposes a large dynamic model catalog. Writing every discovered model
into modelProviders.openai would make /model noisy and would turn a long-term
settings field into a cache of a remote catalog.

The key design split is:

  • Catalog: the full set of models discovered from a source such as
    OpenRouter.
  • Enabled set: the smaller set of models that should appear in /model and
    be persisted in user settings.

For the initial OpenRouter flow, auth should finish with a useful default enabled
set instead of interrupting the user with a large picker. The recommended set
should be small, stable, and biased toward models that let users try the product
successfully, including free models when available.

/model remains a fast model switcher. It should not become the place where
users browse and curate a full provider catalog.

`/manage-models`

Richer model management belongs in a separate /manage-models entry point. That
flow should let users:

  • browse discovered models;
  • search by id, display name, provider prefix, and derived tags such as free or
    vision;
  • see which models are currently enabled;
  • enable or disable models in batches.

The source dimension must remain part of this design. OpenRouter is only the
first dynamic catalog source; future sources such as ModelScope and ModelStudio
should fit the same shape. UI complexity can be reduced, but the underlying
source abstraction should stay available as the extension point.

Current Boundary

This change should do the minimum needed to make OpenRouter auth and model setup
pleasant:

  • OAuth or key-based auth configures OpenRouter through the existing
    OpenAI-compatible provider path.
  • The initial enabled model set is curated instead of dumping the full catalog
    into settings.
  • Full catalog storage, browsing, filtering, and batch management are deferred to
    /manage-models.

The design principle is simple: authentication should get users to a working
state quickly, while model curation should live in a dedicated management flow.


Developers Architecture (`docs/developers/architecture.md`)

Qwen Code Architecture Overview

Qwen Code is a monorepo that supports an interactive terminal, headless and
programmatic execution, the Agent Client Protocol (ACP), a long-running HTTP
daemon, web and IDE clients, and messaging-channel adapters. This document maps
those surfaces to the packages that implement them and explains the main
runtime boundaries.

For detailed daemon internals, start with the
daemon documentation. For HTTP request and event
shapes, see the qwen serve protocol reference.

System at a glance

Qwen Code has two agent execution models:

  • Direct execution: the interactive TUI and headless CLI construct and run
    the agent runtime directly.
  • ACP execution: qwen --acp hosts the agent behind an ACP transport. It
    can be driven by an ACP client directly or by qwen serve through the shared
    ACP bridge.

qwen serve adds an HTTP + Server-Sent Events (SSE) control plane around ACP
execution so multiple clients can use long-lived, workspace-scoped runtimes.

mermaid
flowchart TB
    subgraph surfaces["User and integration surfaces"]
        TUI["Interactive TUI / headless CLI"]
        PQ["TypeScript SDK process client"]
        WEB["Web Shell / shared Web UI"]
        IDE["IDE integrations"]
        CHANNEL["Messaging channels"]
        CUSTOM["Custom daemon clients"]
    end

    subgraph hosts["Process and transport hosts"]
        CLI["CLI host<br/>packages/cli"]
        SDK["Daemon client<br/>packages/sdk-typescript"]
        SERVE["qwen serve<br/>packages/cli/src/serve"]
        BRIDGE["ACP bridge<br/>packages/acp-bridge"]
        ACP["qwen --acp child"]
    end

    subgraph runtime["Agent runtime"]
        CORE["Agent orchestration and tools<br/>packages/core"]
    end

    subgraph external["External systems"]
        MODEL["Model providers"]
        MCP["MCP servers"]
        HOST["Workspace filesystem and processes"]
    end

    TUI --> CLI
    PQ --> CLI
    CLI --> CORE

    WEB --> SDK
    IDE --> SDK
    CHANNEL --> SDK
    CUSTOM --> SDK
    SDK --> SERVE
    SERVE --> BRIDGE
    BRIDGE --> ACP
    ACP --> CORE

    CORE --> MODEL
    CORE --> MCP
    CORE --> HOST

The diagram shows the main production paths. Some adapters also have standalone
modes: for example, qwen channel start uses the ACP bridge without requiring
an HTTP daemon. See the
channel plugin guide for those variants.

Repository layout

Path Responsibility
packages/cli The qwen executable, argument parsing, configuration assembly, Ink TUI, headless output, ACP entry point, qwen serve, and command-specific adapters.
packages/core UI-independent agent orchestration, model-provider integration, prompt and context construction, tool registration and execution, permissions, sessions, memory, telemetry, and shared services.
packages/acp-bridge ACP channel lifecycle, session multiplexing, event delivery, permission mediation, process spawning, and the filesystem seam shared by daemon and adapter hosts.
packages/sdk-typescript Programmatic process execution through query() plus HTTP/SSE clients and transcript projection for qwen serve.
packages/web-shell The browser UI and daemon React adapter built on the TypeScript SDK.
packages/web-templates Web templates packaged as embeddable JavaScript and CSS strings.
packages/audio-capture Native microphone capture for voice input.
packages/channels The shared channel runtime and platform adapters for messaging services.
packages/desktop, packages/vscode-ide-companion, packages/chrome-extension, packages/zed-extension Product and editor surfaces that adapt Qwen Code to their host environments.
packages/sdk-java, packages/sdk-python Language-specific programmatic clients.
packages/cua-driver, packages/mobile-mcp Computer-use and mobile-device integrations exposed through MCP-compatible boundaries.
integration-tests End-to-end coverage for CLI, interactive, SDK, sandbox, hook, and terminal behavior.
docs and docs-site User, developer, protocol, and design documentation plus the documentation site.
scripts Build, packaging, release, validation, and repository-maintenance automation.

Most code lives in npm workspaces under packages/. A package should depend on
another package through its declared public exports rather than through a
relative path into that package's source tree.

Package boundaries

CLI and presentation surfaces

packages/cli owns the executable and chooses the runtime mode from command-line
arguments. It loads user and workspace settings, constructs the core
configuration, enters the requested sandbox when necessary, and then starts one
of the interactive, headless, ACP, daemon, channel, or maintenance flows.

Presentation remains outside the core runtime:

  • the Ink TUI renders local interactive sessions;
  • packages/web-shell adapts daemon state to React providers and hooks and
    provides the browser experience;
  • IDE and channel packages translate host-specific events into shared client or
    bridge contracts.

Core runtime

packages/core owns the agent loop. It constructs model requests, maintains
conversation context, dispatches tool calls, applies permission policy, and
returns structured events and results to the active host. Built-in tools cover
file operations, shell execution, search, planning, web access, memory, skills,
and subagents. MCP extends the tool and resource surface without coupling the
runtime to a specific integration.

The core package does not decide how results are displayed or how a remote
client transports them. Those decisions belong to the CLI, bridge, SDK, and UI
layers.

ACP bridge

packages/acp-bridge connects a host process to an ACP agent runtime. Its main
responsibilities are:

  • spawning or attaching to an ACP channel;
  • multiplexing sessions and clients;
  • forwarding prompts, cancellations, and ACP notifications;
  • mediating permission requests;
  • publishing bounded session event streams;
  • providing a workspace filesystem interface to the host.

The bridge can use a real qwen --acp child process in production or an
in-memory channel in tests. See the
@qwen-code/acp-bridge README for its
public entry points.

SDK and UI adapters

The TypeScript SDK exposes two client styles:

  • query() starts and controls a Qwen Code process for programmatic local use;
  • daemon clients communicate with qwen serve over HTTP and SSE.

packages/web-shell builds a React state layer and browser UI on the daemon
client. Other clients,
including IDE integrations and daemon-managed channels, reuse the same SDK and
event contracts instead of importing server implementation code.

Runtime flows

Direct CLI flow

  1. The CLI parses arguments and resolves user, workspace, environment, and
    command-line configuration.
  2. It prepares sandboxing and constructs the core runtime configuration.
  3. The core runtime builds the model request and enters the agent/tool loop.
  4. Tool calls are checked against permission policy and executed in the active
    workspace environment.
  5. The CLI renders incremental events in the TUI or serializes them for
    headless output.

Daemon flow

  1. A client uses the TypeScript SDK or the documented HTTP API to connect to
    qwen serve.
  2. The daemon authenticates the request and resolves the workspace that owns
    the requested operation.
  3. The workspace runtime forwards agent operations through its ACP bridge to a
    qwen --acp child.
  4. The child runs the same core agent and tool logic used by direct execution.
  5. Responses and notifications return through the bridge; session events are
    delivered to clients over SSE.

With multi-workspace sessions enabled, each live workspace runtime owns its own
bridge and ACP child. Filesystem access, environment overlays, MCP transports,
sessions, and failure handling remain scoped to that resolved runtime. The
daemon architecture documents the process
topology, trust boundaries, event replay, and lifecycle in detail.

Extension points

Qwen Code can be extended at several layers:

  • MCP servers add tools, prompts, and resources to the core runtime.
  • Extensions and skills package reusable commands, configuration, and agent
    behavior.
  • Channel plugins adapt messaging platforms to the shared channel runtime.
  • SDK clients build custom local or daemon-backed applications.
  • UI adapters project shared daemon events into host-specific state and
    presentation.

Keep platform-specific concerns in adapters. Shared agent behavior belongs in
the core runtime, while transport and wire behavior belongs in the ACP bridge,
SDK, or daemon host.

Configuration and state

The CLI assembles effective configuration from command-line arguments,
environment variables, user settings, workspace settings, and defaults before
constructing the runtime. The core receives the resolved configuration rather
than reading presentation-specific input. See
Settings for the supported settings and
their scopes.

Direct sessions persist their history and metadata through shared core
services. In daemon mode, the daemon resolves the owning workspace and exposes
workspace- and session-scoped operations to clients; the ACP child remains the
owner of live agent execution.

Where to go next


Developers Channel Plugins (`docs/developers/channel-plugins.md`)

Channel Plugin Developer Guide

A channel plugin connects Qwen Code to a messaging platform. It's packaged as an extension and loaded at startup. For user-facing docs on installing and configuring plugins, see Plugins.

How It Fits Together

Your plugin sits in the Platform Adapter layer. You handle platform-specific concerns (connecting, receiving messages, sending responses). ChannelBase handles everything else (access control, session routing, prompt queuing, slash commands, crash recovery).

text
Your Plugin  →  builds Envelope  →  handleInbound()
ChannelBase  →  gates → commands → routing → ChannelAgentBridge.prompt()
ChannelBase  →  calls your sendMessage() with the agent's response

ChannelAgentBridge is the adapter-facing bridge contract. The current standalone qwen channel start path provides an AcpBridge, but plugin code should type constructor parameters as ChannelAgentBridge so the same adapter can run behind other bridge implementations later.

Migration note for existing TypeScript plugins: if your adapter constructor or factory explicitly types bridge as AcpBridge, change that annotation to ChannelAgentBridge and keep using only the methods exposed by that contract. JavaScript plugins are unaffected at runtime, and standalone qwen channel start still passes the current AcpBridge implementation.

Runtime Modes

The same plugin adapter can be hosted by either channel runtime:

  • qwen channel start [name] is the standalone ACP-backed service. It still uses AcpBridge and remains the stable command for running channels outside a daemon.
  • qwen serve --channel <name> and repeatable --channel flags start experimental daemon-managed channel workers. Named channels are grouped by owning workspace, with one worker per owning runtime. --channel all intentionally starts only the primary workspace's configured channels. Workers are owned by qwen serve, connect to that daemon through the SDK, and pass adapters a ChannelAgentBridge facade backed by DaemonChannelBridge.

Daemon-managed channels inherit the daemon's lifecycle and status reporting. They are intentionally out-of-process so adapter or platform SDK failures do not crash the daemon. Every named channel must resolve to exactly one registered, trusted workspace; its worker receives that runtime's canonical cwd and environment overlay. A user/system channel with no cwd is ambiguous when several workspaces are registered, while a channel in a workspace-local settings file belongs to that workspace by default. --channel all remains primary-only and cannot be combined with named selections.

The Plugin Object

Your extension entry point exports a plugin conforming to ChannelPlugin:

typescript
import type { ChannelPlugin } from '@qwen-code/channel-base';
import { MyChannel } from './MyChannel.js';

export const plugin: ChannelPlugin = {
  channelType: 'my-platform', // Unique ID, used in settings.json "type" field
  displayName: 'My Platform', // Shown in CLI output
  requiredConfigFields: ['apiKey'], // Validated at startup (beyond standard ChannelConfig)
  createChannel: (name, config, bridge, options) =>
    new MyChannel(name, config, bridge, options),
};

The Channel Adapter

Extend ChannelBase and implement three methods:

typescript
import { ChannelBase } from '@qwen-code/channel-base';
import type {
  ChannelBaseOptions,
  ChannelAgentBridge,
  ChannelConfig,
  Envelope,
  SessionTarget,
} from '@qwen-code/channel-base';

export class MyChannel extends ChannelBase {
  constructor(
    name: string,
    config: ChannelConfig,
    bridge: ChannelAgentBridge,
    options?: ChannelBaseOptions,
  ) {
    super(name, config, bridge, options);
  }

  async connect(): Promise<void> {
    // Connect to your platform, register message handlers
    // When a message arrives:
    const envelope: Envelope = {
      channelName: this.name,
      senderId: '...', // Stable, unique platform user ID
      senderName: '...', // Display name
      chatId: '...', // Chat/conversation ID (distinct for DMs vs groups)
      text: '...', // Message text (strip @mentions)
      isGroup: false, // Accurate — used by GroupGate
      isMentioned: false, // Accurate — used by GroupGate
      isReplyToBot: false, // Accurate — used by GroupGate
    };
    this.handleInbound(envelope);
  }

  async sendMessage(chatId: string, text: string): Promise<void> {
    // Format markdown → platform format, chunk if needed, deliver
  }

  disconnect(): void {
    // Clean up connections
  }
}

Most adapters should pass options through unchanged. If an adapter creates its own SessionRouter and passes that router to super(), set registerBridgeEvents: true in ChannelBaseOptions so ChannelBase still receives toolCall and sessionDied events directly. Leave it unset for routers supplied by the channel gateway.

If your adapter exposes shell-command or BTW side-question behavior, check that the corresponding bridge.shellCommand / bridge.btw method exists before enabling it. Daemon-managed workers omit those optional methods unless the daemon advertises the matching session_shell_command / session_btw capability.

The Envelope

The normalized message object you build from platform data. The boolean flags drive gate logic, so they must be accurate.

Field Type Required Notes
channelName string Yes Use this.name
senderId string Yes Must be stable across messages (used for session routing + access control)
senderName string Yes Display name
chatId string Yes Must distinguish DMs from groups
chatName string No Group/conversation name when supplied by the platform
text string Yes Strip bot @mentions
threadId string No For sessionScope: "thread"
messageId string No Platform message ID — useful for response correlation
isGroup boolean Yes GroupGate relies on this
isMentioned boolean Yes GroupGate relies on this
isReplyToBot boolean Yes GroupGate relies on this
referencedText string No Quoted message — prepended as context
imageBase64 string No Base64-encoded image (legacy — prefer attachments)
imageMimeType string No e.g., image/jpeg (legacy — prefer attachments)
attachments Attachment[] No Structured media attachments (see below)

Attachments

Use the attachments array for images, files, audio, and video. handleInbound() resolves them automatically: images with base64 data are sent to the model as vision input, files with a filePath get their path appended to the prompt so the agent can read them.

typescript
interface Attachment {
  type: 'image' | 'file' | 'audio' | 'video';
  data?: string; // base64-encoded data (images, small files)
  filePath?: string; // absolute path to local file (large files saved to disk)
  mimeType: string; // e.g. 'application/pdf', 'image/jpeg'
  fileName?: string; // original file name from the platform
}

Example — handling a file upload in your adapter:

typescript
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

const buf = await downloadFromPlatform(fileId);
const dir = join(tmpdir(), 'channel-files');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const filePath = join(dir, fileName);
writeFileSync(filePath, buf);

envelope.attachments = [
  {
    type: 'file',
    filePath,
    mimeType: 'application/pdf',
    fileName,
  },
];

The legacy imageBase64/imageMimeType fields still work for backwards compatibility but attachments is preferred for new code.

Extension Manifest

Your qwen-extension.json declares the channel type. The key must match channelType in your plugin object:

json
{
  "name": "my-channel-extension",
  "version": "1.0.0",
  "channels": {
    "my-platform": {
      "entry": "dist/index.js",
      "displayName": "My Platform Channel"
    }
  }
}

Optional Extension Points

Custom slash commands — register in your constructor:

typescript
this.registerCommand('mycommand', async (envelope, args) => {
  await this.sendMessage(envelope.chatId, 'Response');
  return true; // handled, don't forward to agent
});

Working indicators — override onPromptStart() and onPromptEnd() to show platform-specific typing indicators. These hooks fire only when a prompt actually begins processing — not for buffered messages (collect mode) or gated/blocked messages:

typescript
protected override onPromptStart(chatId: string, sessionId: string, messageId?: string): void {
  this.platformClient.sendTyping(chatId); // your platform API
}

protected override onPromptEnd(chatId: string, sessionId: string, messageId?: string): void {
  this.platformClient.stopTyping(chatId);
}

Tool call hooks — override onToolCall() to display agent activity (e.g., "Running shell command...").

Streaming hooks — override onResponseChunk(chatId, chunk, sessionId, segment) for per-chunk progressive display (e.g., editing a message in-place). Override onResponseComplete(chatId, fullText, sessionId, segment) to customize final delivery. In daemon-managed named-task mode, segment.sourceLabel is immutable delivery metadata for that segment. Render it once on each independently visible message or card, including a separately visible final response, but do not add it to raw buffers or model text. Clear adapter-owned segment state from onOutputSegmentEnd().

Named-task attribution — sendThreadMessage(chatId, threadId, text, sourceLabel) receives the same optional plain-text label for one-shot and proactive delivery boundaries. The default implementation handles plain messages. Adapters that override delivery, split messages, emit cards, or provide fallback sends must repeat the label at every independently visible boundary, escape only the label for the target markup dialect, and include its rendered size in platform limits. Run no-reply checks, media-marker projection, audit hashing, transcript persistence, and retry-body capture against the raw response before presentation; if delivery is persisted for restart-safe retry, persist the captured label separately.

Interactive ChannelUserInputRequestContext also carries sourceLabel. Cards, terminal replacements, and plain fallbacks must retain it without weakening the existing request, session, run, owner, and target checks.

Proactive delivery — override supportsProactiveSend() to return true when the adapter can send without an active inbound request. ChannelBase uses this capability for persistent channel loops, webhook tasks, background-agent results, and daemon delivery. The default target policy rejects threaded targets; override the protected target checks only for target shapes your platform can deliver safely:

typescript
override supportsProactiveSend(): boolean {
  return true;
}

protected override supportsProactiveTarget(target: SessionTarget): boolean {
  return target.threadId === undefined;
}

protected override async pushProactive(
  target: SessionTarget,
  text: string,
  sourceLabel?: string,
): Promise<void> {
  await this.sendThreadMessage(
    target.chatId,
    target.threadId,
    text,
    sourceLabel,
  );
}

Use supportsProactiveDeliveryTarget() when generic daemon delivery accepts a different target shape, and supportsProactiveWebhookTarget() when webhook delivery differs from loops and background results. Keep unsupported targets rejected rather than falling back to another conversation.

Media — populate envelope.attachments with images/files. See Attachments above.

Reference Implementations

  • Plugin example (packages/channels/plugin-example/) — minimal WebSocket-based adapter, good starting point
  • Telegram (packages/channels/telegram/) — full-featured: images, files, formatting, typing indicators
  • DingTalk (packages/channels/dingtalk/) — stream-based with rich text handling

Developers Contributing (`docs/developers/contributing.md`)

How to Contribute

We would love to accept your patches and contributions to this project.

Contribution Process

Code Reviews

All submissions, including submissions by project members, require review. We
use GitHub pull requests
for this purpose.

Pull Request Guidelines

To help us review and merge your PRs quickly, please follow these guidelines. PRs that do not meet these standards may be closed.

All PRs should be linked to an existing issue in our tracker. This ensures that every change has been discussed and is aligned with the project's goals before any code is written.

  • For bug fixes: The PR should be linked to the bug report issue.
  • For features: The PR should be linked to the feature request or proposal issue that has been approved by a maintainer.

If an issue for your change doesn't exist, please open one first and wait for feedback before you start coding.

2. Keep It Small and Focused

We favor small, atomic PRs that address a single issue or add a single, self-contained feature.

  • Do: Create a PR that fixes one specific bug or adds one specific feature.
  • Don't: Bundle multiple unrelated changes (e.g., a bug fix, a new feature, and a refactor) into a single PR.

As a rule of thumb, start splitting a PR once it exceeds about 1,200 changed
lines. PRs above about 2,000 changed lines should either be split into a series
of smaller, logical PRs that can be reviewed and merged independently, or
explain in the PR description why the change needs to land together.

3. Use Draft PRs for Work in Progress

If you'd like to get early feedback on your work, please use GitHub's Draft Pull Request feature. This signals to the maintainers that the PR is not yet ready for a formal review but is open for discussion and initial feedback.

4. Ensure All Checks Pass

Before submitting your PR, ensure that all automated checks are passing by running npm run preflight. This command runs all tests, linting, and other style checks.

5. Update Documentation

If your PR introduces a user-facing change (e.g., a new command, a modified flag, or a change in behavior), you must also update the relevant documentation in the /docs directory.

New or updated design documents must include complete English (<name>.md) and
Simplified Chinese (<name>.zh-CN.md) versions in the same directory and PR.
Keep their structure, decisions, constraints, and acceptance criteria aligned,
and add reciprocal language links. See the design documentation requirements
in English or 简体中文.

6. Write Clear Commit Messages and a Good PR Description

Your PR should have a clear, descriptive title and a detailed description of the changes. Follow the Conventional Commits standard for your commit messages.

  • Good PR Title: feat(cli): Add --json flag to 'config get' command
  • Bad PR Title: Made some changes

In the PR description, explain the "why" behind your changes and link to the relevant issue (e.g., Fixes #123).

Development Setup and Workflow

This section guides contributors on how to build, modify, and understand the development setup of this project.

Setting Up the Development Environment

Prerequisites:

  1. Node.js:
    • Development: Please use Node.js >=22. Ink 7 (used by the TUI) requires Node 22, and react@^19.2.0 is the matching peer. You can use a tool like nvm to manage Node.js versions.
    • Production: For running the CLI in a production environment, any version of Node.js >=22 is acceptable.
  2. Git

Build Process

To clone the repository:

bash
git clone https://github.com/QwenLM/qwen-code.git # Or your fork's URL
cd qwen-code

To install dependencies defined in package.json as well as root dependencies:

bash
npm install

To build the entire project (all packages):

bash
npm run build

This command typically compiles TypeScript to JavaScript, bundles assets, and prepares the packages for execution. Refer to scripts/build.js and package.json scripts for more details on what happens during the build.

Enabling Sandboxing

Sandboxing is highly recommended and requires, at a minimum, setting QWEN_SANDBOX=true in your ~/.env and ensuring a sandboxing provider (e.g. macOS Seatbelt, docker, or podman) is available. See Sandboxing for details.

To build both the qwen CLI utility and the sandbox container, run build:all from the root directory:

bash
npm run build:all

To skip building the sandbox container, you can use npm run build instead.

Running

To start the Qwen Code application from the source code (after building), run the following command from the root directory:

bash
npm start

If you'd like to run the source build outside of the qwen-code folder, you can utilize npm link path/to/qwen-code/packages/cli (see: docs) to run with qwen

Running Tests

This project contains two types of tests: unit tests and integration tests.

Unit Tests

To execute the unit test suite for the project:

bash
npm run test

This will run tests located in the packages/core and packages/cli directories. Ensure tests pass before submitting any changes. For a more comprehensive check, it is recommended to run npm run preflight.

Integration Tests

The integration tests are designed to validate the end-to-end functionality of Qwen Code. They are not run as part of the default npm run test command.

To run the integration tests, use the following command:

bash
npm run test:e2e

For more detailed information on the integration testing framework, please see the Integration Tests documentation.

Linting and Preflight Checks

To ensure code quality and formatting consistency, run the preflight check:

bash
npm run preflight

This command will run ESLint, Prettier, all tests, and other checks as defined in the project's package.json.

ProTip

after cloning create a git precommit hook file to ensure your commits are always clean.

bash
echo "
# Run npm build and check for errors
if ! npm run preflight; then
  echo "npm build failed. Commit aborted."
  exit 1
fi
" > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit

Formatting

To separately format the code in this project by running the following command from the root directory:

bash
npm run format

This command uses Prettier to format the code according to the project's style guidelines.

Linting

To separately lint the code in this project, run the following command from the root directory:

bash
npm run lint

Coding Conventions

  • Please adhere to the coding style, patterns, and conventions used throughout the existing codebase.
  • Imports: Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages.

Project Structure

  • packages/: Contains the individual sub-packages of the project.
    • cli/: The command-line interface.
    • core/: The core backend logic for Qwen Code.
  • docs/: Contains all project documentation.
  • scripts/: Utility scripts for building, testing, and development tasks.

For more detailed architecture, see docs/architecture.md.

Documentation Development

This section describes how to develop and preview the documentation locally.

Prerequisites

  1. Ensure you have Node.js (version 22+) installed
  2. Have npm or yarn available

Setup Documentation Site Locally

To work on the documentation and preview changes locally:

  1. Navigate to the docs-site directory:

    bash
    cd docs-site
  2. Install dependencies:

    bash
    npm install
  3. Link the documentation content from the main docs directory:

    bash
    npm run link

    This creates a symbolic link from ../docs to content in the docs-site project, allowing the documentation content to be served by the Next.js site.

  4. Start the development server:

    bash
    npm run dev
  5. Open http://localhost:3000 in your browser to see the documentation site with live updates as you make changes.

Any changes made to the documentation files in the main docs directory will be reflected immediately in the documentation site.

Debugging

VS Code:

  1. Run the CLI to interactively debug in VS Code with F5
  2. Start the CLI in debug mode from the root directory:
    bash
    npm run debug
    This command runs node --inspect-brk dist/index.js within the packages/cli directory, pausing execution until a debugger attaches. You can then open chrome://inspect in your Chrome browser to connect to the debugger.
  3. In VS Code, use the "Attach" launch configuration (found in .vscode/launch.json).

Alternatively, you can use the "Launch Program" configuration in VS Code if you prefer to launch the currently open file directly, but 'F5' is generally recommended.

To hit a breakpoint inside the sandbox container run:

bash
DEBUG=1 qwen

Note: If you have DEBUG=true in a project's .env file, it won't affect qwen due to automatic exclusion. Use .qwen/.env files for qwen-specific debug settings.

React DevTools

To debug the CLI's React-based UI, you can use React DevTools. Ink, the library used for the CLI's interface, is compatible with React DevTools version 4.x.

  1. Start the Qwen Code application in development mode:

    bash
    DEV=true npm start
  2. Install and run React DevTools version 4.28.5 (or the latest compatible 4.x version):

    You can either install it globally:

    bash
    npm install [email protected]
    react-devtools

    Or run it directly using npx:

    bash
    npx [email protected]

    Your running CLI application should then connect to React DevTools.

Sandboxing

TBD

Manual Publish

We publish an artifact for each commit to our internal registry. But if you need to manually cut a local build, then run the following commands:

text
npm run clean
npm install
npm run auth
npm run prerelease:dev
npm publish --workspaces

Developers Roadmap (`docs/developers/roadmap.md`)

Qwen Code RoadMap

Objective: Catch up with Claude Code's product functionality, continuously refine details, and enhance user experience.

Category Phase 1 Phase 2
User Experience ✅ Terminal UI✅ Support OpenAI Protocol✅ Settings✅ OAuth✅ Cache Control✅ Memory✅ Compress✅ Theme Better UIOnBoardingLogView✅ SessionPermission🔄 Cross-platform Compatibility✅ Coding Plan✅ Anthropic Provider✅ Multimodal Input✅ Unified Web Shell
Coding Workflow ✅ Slash Commands✅ MCP✅ PlanMode✅ TodoWrite✅ SubAgent✅ Multi Model✅ Chat Management✅ Tools (WebFetch, Bash, TextSearch, FileReadFile, EditFile) 🔄 Hooks✅ Skill✅ Headless Mode✅ Tools (WebSearch)✅ LSP Support✅ Concurrent Runner
Building Open Capabilities ✅ Custom Commands ✅ QwenCode SDK✅ Extension System
Integrating Community Ecosystem ✅ VSCode Plugin✅ ACP/Zed✅ GHA
Administrative Capabilities ✅ Stats✅ Feedback CostsDashboard✅ User Feedback Dialog

For more details, please see the list below.

Features

Completed Features

Feature Version Description Category Phase
Coding Plan V0.10.0 Alibaba Cloud Coding Plan authentication & models User Experience 2
Unified Web Shell V0.9.0 Shared browser UI for VS Code and qwen serve User Experience 2
Export Chat V0.8.0 Export sessions to Markdown/HTML/JSON/JSONL User Experience 2
Extension System V0.8.0 Full extension management with slash commands Building Open Capabilities 2
LSP Support V0.7.0 Experimental LSP service (--experimental-lsp) Coding Workflow 2
Anthropic Provider V0.7.0 Anthropic API provider support User Experience 2
User Feedback Dialog V0.7.0 In-app feedback collection with fatigue mechanism Administrative Capabilities 2
Concurrent Runner V0.6.0 Batch CLI execution with Git integration Coding Workflow 2
Multimodal Input V0.6.0 Image, PDF, audio, video input support User Experience 2
Skill V0.6.0 Extensible custom AI skills (experimental) Coding Workflow 2
GitHub Actions V0.5.0 qwen-code-action and automation Integrating Community Ecosystem 1
VSCode Plugin V0.5.0 VSCode extension plugin Integrating Community Ecosystem 1
QwenCode SDK V0.4.0 Open SDK for third-party integration Building Open Capabilities 1
Session V0.4.0 Enhanced session management User Experience 1
i18n V0.3.0 Internationalization and multilingual support User Experience 1
Headless Mode V0.3.0 Headless mode (non-interactive) Coding Workflow 1
ACP/Zed V0.2.0 ACP and Zed editor integration Integrating Community Ecosystem 1
Terminal UI V0.1.0+ Interactive terminal user interface User Experience 1
Settings V0.1.0+ Configuration management system User Experience 1
Theme V0.1.0+ Multi-theme support User Experience 1
Support OpenAI Protocol V0.1.0+ Support for OpenAI API protocol User Experience 1
Chat Management V0.1.0+ Session management (save, restore, browse) Coding Workflow 1
MCP V0.1.0+ Model Context Protocol integration Coding Workflow 1
Multi Model V0.1.0+ Multi-model support and switching Coding Workflow 1
Slash Commands V0.1.0+ Slash command system Coding Workflow 1
Tool: Bash V0.1.0+ Shell command execution tool (with is_background param) Coding Workflow 1
Tool: FileRead/EditFile V0.1.0+ File read/write and edit tools Coding Workflow 1
Custom Commands V0.1.0+ Custom command loading Building Open Capabilities 1
Feedback V0.1.0+ Feedback mechanism (/bug command) Administrative Capabilities 1
Stats V0.1.0+ Usage statistics and quota display Administrative Capabilities 1
Memory V0.0.9+ Project-level and global memory management User Experience 1
Cache Control V0.0.9+ Prompt caching control (Anthropic, DashScope) User Experience 1
PlanMode V0.0.14 Task planning mode Coding Workflow 1
Compress V0.0.11 Chat compression mechanism User Experience 1
SubAgent V0.0.11 Dedicated sub-agent system Coding Workflow 1
TodoWrite V0.0.10 Task management and progress tracking Coding Workflow 1
Tool: TextSearch V0.0.8+ Text search tool (grep, supports .qwenignore) Coding Workflow 1
Tool: WebFetch V0.0.7+ Web content fetching tool Coding Workflow 1
Tool: WebSearch V0.0.7+ Built-in web search (DashScope Responses API) Coding Workflow 1
OAuth V0.0.5+ OAuth login authentication (Qwen OAuth) User Experience 1

Features to Develop

Feature Priority Status Description Category
Better UI P1 Planned Optimized terminal UI interaction User Experience
OnBoarding P1 Planned New user onboarding flow User Experience
Permission P1 Planned Permission system optimization User Experience
Cross-platform Compatibility P1 In Progress Windows/Linux/macOS compatibility User Experience
LogView P2 Planned Log viewing and debugging feature User Experience
Hooks P2 In Progress Extension hooks system Coding Workflow
Costs P2 Planned Cost tracking and analysis Administrative Capabilities
Dashboard P2 Planned Management dashboard Administrative Capabilities

Distinctive Features to Discuss

Feature Status Description
Home Spotlight Research Project discovery and quick launch
Competitive Mode Research Competitive mode
Pulse Research User activity pulse analysis (OpenAI Pulse reference)
Code Wiki Research Project codebase wiki/documentation system

Developers Sdk Java (`docs/developers/sdk-java.md`)

Qwen Code Java SDK

The Qwen Code Java SDK provides a recommended daemon transport for qwen serve and retains the experimental legacy stdio API for compatibility. Both APIs ship in the same com.alibaba:qwencode-sdk artifact.

Requirements

  • Java >= 11 for 0.1.0-alpha
  • Maven >= 3.9.2 when building or publishing this SDK from source
  • A compatible qwen serve for the daemon API, or qwen-code >= 0.5.0 for the legacy stdio API

Dependencies

  • Logging API: org.slf4j:slf4j-api (choose an SLF4J provider in your application)
  • Utilities: org.apache.commons:commons-lang3
  • JSON Processing: Fastjson2 for encoding and Jackson Core for strict decoding
  • Testing: JUnit 5 (org.junit.jupiter:junit-jupiter)

Installation

Add the following dependency to your Maven pom.xml:

xml
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>qwencode-sdk</artifactId>
    <version>0.1.0-alpha</version>
</dependency>

Or if using Gradle, add to your build.gradle:

gradle
implementation 'com.alibaba:qwencode-sdk:0.1.0-alpha'

Building and Running

Build Commands

bash
# Compile the project
mvn compile

# Run tests
mvn test

# Package the JAR
mvn package

# Install to local repository
mvn install

Real daemon E2E from source

Run the real-daemon Java integration tests from the repository root after building both the workspaces and the root CLI bundle:

bash
npm run build
npm run bundle
npx tsx scripts/run-java-daemon-sdk-e2e.ts

npm run build alone does not refresh dist/cli.js; the E2E harness launches that bundle and fails with an explicit prerequisite error when it is missing.

Start qwen serve, then create an independent thread-scoped session. promptText returns only after a matching turn_complete; incomplete streams fail with PromptOutcomeIndeterminateException rather than returning partial text as success.

For the lifecycle guarantees assumed by 0.1.0-alpha, use the qwen-code build released from the same source revision as the SDK. The daemon must contain the idempotent per-client detach ledger from #7386, the per-epoch terminal guarantee from #7400, and this release's acknowledged admission cancellation plus FIFO cancel-drain fence. The #7400 commit alone is not sufficient: a same-wire daemon can acknowledge cancel before agent dispatch without stopping the admitted prompt, or let an unacknowledged session-scoped cancel reach a queued successor. The bundled ACP child uses one acknowledged admission-aware cancellation handshake; a custom standards-compliant ACP child without that extension receives one standard session/cancel notification. Feature negotiation cannot distinguish older same-wire daemon builds, so the SDK fails closed rather than reporting partial output as success.

The bundled cancellation handshake deliberately waits for the targeted prompt call to settle before the daemon dispatches its queued successor. It has no timeout that merely acknowledges cancellation: doing so could let a late session-scoped cancel reach the next prompt. If a provider, tool, or custom integration ignores its AbortSignal indefinitely, the cancel mutation can therefore remain outcome-unknown and that session must not be reused. Treat a formal prompt terminal received within the caller's observation boundary as authoritative; otherwise close or destroy the session after observation fails. Recovering a wedged shared ACP child without disturbing its sibling sessions requires stronger runtime isolation and is outside this alpha contract.

java
import com.alibaba.qwen.code.daemon.DaemonClient;
import com.alibaba.qwen.code.daemon.DaemonSessionClient;
import com.alibaba.qwen.code.daemon.PromptTextResult;
import java.net.URI;

try (DaemonClient daemon = DaemonClient.builder()
        .baseUri(URI.create("http://127.0.0.1:4170"))
        .build();
     DaemonSessionClient session = daemon.createSession()) {
    PromptTextResult result = session.promptText("Explain this repository");
    System.out.println(result.getText());
}

Callers that need to allocate the session identity before creation can pass an RFC UUID v1-v5. The SDK checks session_id_override before the mutation and reports a different returned ID as SessionCreationOutcomeUnknownException:

java
CreateSessionRequest request = CreateSessionRequest.builder()
        .sessionId("550E8400-E29B-41D4-A716-446655440000")
        .build();

try (DaemonSessionClient session = daemon.createSession(request)) {
    System.out.println(session.getSession().getSessionId());
}

The daemon normalizes the ID to lowercase and creates a new thread session. This is not an idempotent attach; after an ambiguous create outcome, recover with the known ID rather than retrying creation.

If qwen serve requires authentication, add
.bearerToken(System.getenv("QWEN_SERVER_TOKEN")) to the DaemonClient
builder. The SDK sends the bearer on REST and SSE requests and never puts it in
the URL.

Use startPrompt with a PromptObserver when you need ordered text, thought, tool, usage, permission, and raw event callbacks. Its acceptanceFuture() and completionFuture() views separately expose daemon admission and the reliable turn terminal. respondToPermission() returns false when the request was already resolved or no longer pending. Cancelling the future views does not cancel the daemon prompt; use cancelActivePrompt() for the session-level daemon cancel operation and still wait for the matching terminal. A cooperative cancellation completes with turn_complete and stopReason=cancelled; promptText() returns its PromptTextResult, so callers that distinguish cancellation must inspect result.getTerminal().getStopReason(). If the agent or provider fails while cancelling, the daemon can instead publish turn_error, which makes promptText() throw PromptTurnException.

When cancellation, deadline, teardown, or agent settlement race, the daemon's exactly-once latch publishes the first formal terminal and suppresses later candidates. Always branch on the received terminal itself; the last control mutation sent by the client does not determine the terminal kind or error code.

The SSE transport sends Accept-Encoding: identity and Last-Event-ID, validates framing and event IDs, deduplicates replay, and reconnects only the SSE GET. Prompt and other mutation requests are never retried automatically. HTTP 408 and 5xx responses to prompt admission, session creation, permission, cancel, heartbeat, detach, or delete are reported as outcome-unknown because they do not prove that the daemon rejected the mutation. Finite response bodies and SSE observation have independent deadlines.

Creation-time model selection is intentionally not exposed by the Java daemon SDK API in this alpha. The daemon reports a rejected modelServiceId only as an SSE event emitted before the create response, while this SDK opens its stream from the later prompt-admission watermark. Until the daemon returns a definitive create result or the SDK owns a separate session-event subscription from Last-Event-ID: 0, use the daemon's configured default model.

PromptRequest.Builder.deadline(Duration) requests a daemon-enforced prompt deadline and is accepted only when the daemon advertises prompt_absolute_deadline; otherwise the SDK fails before sending the prompt. The value must be between 1 and 2,147,483,647 milliseconds, matching the daemon's Node timer range. This is separate from observationTimeout(Duration), which only bounds local SSE observation and never sends a cancel mutation.

Before creating a session, the SDK requires the daemon to advertise the REST transport and session_scope_override; this prevents an older daemon from silently ignoring the requested thread scope and attaching the client to a shared session. When a caller supplies a session ID, the SDK additionally requires session_id_override before sending the mutation. When client_heartbeat is advertised, an open session sends a fresh heartbeat every minute so the daemon does not reap an otherwise idle client. Set heartbeatInterval(Duration.ZERO) on the DaemonClient builder to disable this behavior, or choose a different positive interval. A heartbeat is never retried; the next scheduled heartbeat is a separate keepalive. Prompt observation is bounded to 32 concurrent prompts per client by default and can be adjusted with maximumConcurrentPrompts. Admission and terminal future callbacks run away from transport workers; callbacks that remain blocked consume bounded publication capacity. SSE stream cleanup is also bounded, and a close that remains blocked retains its cleanup reservation. Either condition can cause a later startPrompt to fail with DaemonClientCapacityException rather than dropping a timeout close or growing threads and queued work without limit.

An indeterminate completion is an outcome boundary, not a session-reuse boundary. After PromptAdmissionUnknownException or PromptOutcomeIndeterminateException, that DaemonSessionClient permanently rejects further prompts even if local stream cleanup later succeeds; close or destroy the session instead. An observation timeout is published without waiting forever for a blocked stream close, while cleanup continues asynchronously and retains bounded client capacity until it finishes.

Legacy stdio API

The existing com.alibaba.qwen.code.cli API remains available:

java
public static void runSimpleExample() {
    List<String> result = QwenCodeCli.simpleQuery("hello world");
    result.forEach(logger::info);
}

For more advanced usage with custom transport options:

java
public static void runTransportOptionsExample() {
    TransportOptions options = new TransportOptions()
            .setModel("qwen3-coder-flash")
            .setPermissionMode(PermissionMode.AUTO_EDIT)
            .setCwd("./")
            .setEnv(new HashMap<String, String>() {{put("CUSTOM_VAR", "value");}})
            .setIncludePartialMessages(true)
            .setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS))
            .setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS))
            .setAllowedTools(Arrays.asList("read_file", "write_file", "glob"));

    List<String> result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options);
    result.forEach(logger::info);
}

For streaming content handling with custom content consumers:

text
/* Detailed source-code truncated for AI context efficiency. */

other examples see src/test/java/com/alibaba/qwen/code/cli/example

Java 11 migration and alpha limits

0.1.0-alpha raises the minimum Java version for the whole artifact from 8 to 11. Java 8 applications must remain on 0.0.3-alpha. Logback is no longer a runtime dependency; add the SLF4J provider your application uses.

This alpha deliberately fails closed when it cannot prove a prompt terminal. It does not guarantee exactly-once execution across daemon restarts, automatic epoch recovery, snapshot/resync, persisted cursors, or true prompt-ID-targeted cancellation. prompt_cancelled and queue events are advisory; only matching turn_complete and turn_error are terminal.

If session creation has an ambiguous transport outcome, the daemon may retain a session whose ID never reached the caller. The SDK does not retry creation and cannot detach that unknown session; daemon-side lifecycle reaping is the recovery boundary.

Architecture

The artifact contains two isolated implementations:

  • Daemon API: DaemonClient and DaemonSessionClient use REST mutations plus resumable SSE and own bounded HTTP, prompt, maintenance, and timer resources.
  • Legacy stdio API: QwenCodeCli, Session, and ProcessTransport manage a child CLI process using the existing CLI protocol DTOs and utilities.

The daemon implementation does not reuse the legacy process transport, session model, DTOs, or global executor.

Legacy stdio features

Permission Modes

The SDK supports different permission modes for controlling tool execution:

  • default: Write tools are denied unless approved via canUseTool callback or in allowedTools. Read-only tools execute without confirmation.
  • plan: Blocks all write tools, instructing AI to present a plan first.
  • auto-edit: Auto-approve edit tools (edit, write_file, notebook_edit) while other tools require confirmation.
  • yolo: All tools execute automatically without confirmation.

Session Event Consumers and Assistant Content Consumers

The SDK provides two key interfaces for handling events and content from the CLI:

SessionEventConsumers Interface

The SessionEventConsumers interface provides callbacks for different types of messages during a session:

  • onSystemMessage: Handles system messages from the CLI (receives Session and SDKSystemMessage)
  • onResultMessage: Handles result messages from the CLI (receives Session and SDKResultMessage)
  • onAssistantMessage: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage)
  • onPartialAssistantMessage: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage)
  • onUserMessage: Handles user messages (receives Session and SDKUserMessage)
  • onOtherMessage: Handles other types of messages (receives Session and String message)
  • onControlResponse: Handles control responses (receives Session and CLIControlResponse)
  • onControlRequest: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse)
  • onPermissionRequest: Handles permission requests (receives Session and CLIControlRequest, returns Behavior)

AssistantContentConsumers Interface

The AssistantContentConsumers interface handles different types of content within assistant messages:

  • onText: Handles text content (receives Session and TextAssistantContent)
  • onThinking: Handles thinking content (receives Session and ThinkingAssistantContent)
  • onToolUse: Handles tool use content (receives Session and ToolUseAssistantContent)
  • onToolResult: Handles tool result content (receives Session and ToolResultAssistantContent)
  • onOtherContent: Handles other content types (receives Session and AssistantContent)
  • onUsage: Handles usage information (receives Session and AssistantUsage)
  • onPermissionRequest: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior)
  • onOtherControlRequest: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload)

Relationship Between the Interfaces

Important Note on Event Hierarchy:

  • SessionEventConsumers is the high-level event processor that handles different message types (system, assistant, user, etc.)
  • AssistantContentConsumers is the low-level content processor that handles different types of content within assistant messages (text, tools, thinking, etc.)

Processor Relationship:

  • SessionEventConsumers → AssistantContentConsumers (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages)

Event Derivation Relationships:

  • onAssistantMessage → onText, onThinking, onToolUse, onToolResult, onOtherContent, onUsage
  • onPartialAssistantMessage → onText, onThinking, onToolUse, onToolResult, onOtherContent
  • onControlRequest → onPermissionRequest, onOtherControlRequest

Event Timeout Relationships:

Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event:

  • onSystemMessage ↔ onSystemMessageTimeout
  • onResultMessage ↔ onResultMessageTimeout
  • onAssistantMessage ↔ onAssistantMessageTimeout
  • onPartialAssistantMessage ↔ onPartialAssistantMessageTimeout
  • onUserMessage ↔ onUserMessageTimeout
  • onOtherMessage ↔ onOtherMessageTimeout
  • onControlResponse ↔ onControlResponseTimeout
  • onControlRequest ↔ onControlRequestTimeout

For AssistantContentConsumers timeout methods:

  • onText ↔ onTextTimeout
  • onThinking ↔ onThinkingTimeout
  • onToolUse ↔ onToolUseTimeout
  • onToolResult ↔ onToolResultTimeout
  • onOtherContent ↔ onOtherContentTimeout
  • onPermissionRequest ↔ onPermissionRequestTimeout
  • onOtherControlRequest ↔ onOtherControlRequestTimeout

Default Timeout Values:

  • SessionEventSimpleConsumers default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS)
  • AssistantContentSimpleConsumers default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS)

Timeout Hierarchy Requirements:

For proper operation, the following timeout relationships should be maintained:

  • onAssistantMessageTimeout return value should be greater than onTextTimeout, onThinkingTimeout, onToolUseTimeout, onToolResultTimeout, and onOtherContentTimeout return values
  • onControlRequestTimeout return value should be greater than onPermissionRequestTimeout and onOtherControlRequestTimeout return values

Transport Options

The TransportOptions class allows configuration of how the SDK communicates with the Qwen Code CLI:

  • pathToQwenExecutable: Path to the Qwen Code CLI executable
  • cwd: Working directory for the CLI process
  • model: AI model to use for the session
  • permissionMode: Permission mode that controls tool execution
  • env: Environment variables to pass to the CLI process
  • maxSessionTurns: Limits the number of conversation turns in a session
  • coreTools: A non-empty allowlist of core tools. An omitted or empty list is treated as unspecified; an empty list does not disable tools. MCP and other non-core tools have separate registration and permission rules. Use excludeTools to exclude named tools explicitly; allowedTools controls approval rather than registration.
  • excludeTools: List of tools to exclude from being available to the AI
  • allowedTools: List of tools that are pre-approved for use without additional confirmation
  • authType: Authentication type to use for the session
  • includePartialMessages: Enables receiving partial messages during streaming responses
  • turnTimeout: Timeout for a complete turn of conversation
  • messageTimeout: Timeout for individual messages within a turn
  • resumeSessionId: ID of a previous session to resume
  • otherOptions: Additional command-line options to pass to the CLI

Session Control Features

  • Session creation: Use QwenCodeCli.newSession() to create a new session with custom options
  • Session management: The Session class provides methods to send prompts, handle responses, and manage session state
  • Session cleanup: Always close sessions using session.close() to properly terminate the CLI process
  • Session resumption: Use setResumeSessionId() in TransportOptions to resume a previous session
  • Session interruption: Use session.interrupt() to interrupt a currently running prompt
  • Dynamic model switching: Use session.setModel() to change the model during a session
  • Dynamic permission mode switching: Use session.setPermissionMode() to change the permission mode during a session

Thread Pool Configuration

The SDK uses a thread pool for managing concurrent operations with the following default configuration:

  • Core Pool Size: 30 threads
  • Maximum Pool Size: 100 threads
  • Keep-Alive Time: 60 seconds
  • Queue Capacity: 300 tasks (using LinkedBlockingQueue)
  • Thread Naming: "qwen_code_cli-pool-{number}"
  • Daemon Threads: true
  • Rejected Execution Handler: CallerRunsPolicy

Tasks still running or queued in the default pool are abandoned when the JVM exits. Callers that require completion must wait for those tasks explicitly.

Error Handling

The SDK provides specific exception types for different error scenarios:

  • SessionControlException: Thrown when there's an issue with session control, including attempting to use a closed or unavailable session. Session construction and start() can throw it directly; QwenCodeCli.newSession() wraps lower-level creation and initialization failures in a RuntimeException.
  • SessionSendPromptException: Thrown when there's an issue sending a prompt or receiving a response

FAQ / Troubleshooting

Q: Do I need to install the Qwen CLI separately?

A: Yes. The daemon API requires a compatible qwen serve; the legacy stdio
API requires qwen-code 0.5.0 or higher.

Q: What Java versions are supported?

A: 0.1.0-alpha requires Java 11 or higher. Java 8 users must remain on 0.0.3-alpha.

Q: How do I handle long-running requests?

A: The SDK includes timeout utilities. You can configure timeouts using the Timeout class in TransportOptions.

Q: Why are some tools not executing?

A: This is likely due to permission modes. Check your permission mode settings and consider using allowedTools to pre-approve certain tools.

Q: How do I resume a previous session?

A: Use the setResumeSessionId() method in TransportOptions to resume a previous session.

Q: Can I customize the environment for the CLI process?

A: Yes, use the setEnv() method in TransportOptions to pass environment variables to the CLI process.

License

Apache-2.0 - see LICENSE for details.


Developers Sdk Python (`docs/developers/sdk-python.md`)

Python SDK

`qwen-code-sdk`

qwen-code-sdk is an experimental Python SDK for Qwen Code. v1 targets the
existing stream-json CLI protocol and keeps the transport surface small and
testable.

Scope

  • Package name: qwen-code-sdk
  • Import path: qwen_code_sdk
  • Runtime requirement: Python >=3.10
  • CLI dependency: external qwen executable is required in v1
  • Transport scope: process transport only
  • Not included in v1: ACP transport, SDK-embedded MCP servers

Install

bash
pip install qwen-code-sdk

For preview releases:

bash
pip install --pre qwen-code-sdk

If qwen is not on PATH, pass path_to_qwen_executable explicitly.
On Windows, the SDK discovers npm's qwen.cmd shim only through the effective
child PATH, ignoring extensionless shell shims and non-launchable PATHEXT
associations. A command in the current directory is considered only when that
directory is explicitly included in PATH.
You can override the lookup path through the env option; Windows environment
variable names are case-insensitive, so PATH, Path, and path are equivalent.
Closing a running Windows shim stops its CLI process tree through the system
taskkill utility; process exit and stderr cleanup waits have bounded timeouts.

Before writing SDK code, make sure the CLI works in the same shell:

bash
qwen --version

Quick Start

python
import asyncio

from qwen_code_sdk import (
    is_sdk_assistant_message,
    is_sdk_result_message,
    query,
)


def extract_text(message):
    content = message.get("message", {}).get("content", [])
    if not isinstance(content, list):
        return repr(content)
    texts = [
        block.get("text", "")
        for block in content
        if isinstance(block, dict) and block.get("type") == "text"
    ]
    return "".join(texts) if texts else "[no text content]"


def print_result(message):
    if message.get("is_error"):
        error = message.get("error") or {}
        print(f"Error: {error.get('message', 'Unknown error')}")
        return
    print(message.get("result", ""))


async def main() -> None:
    async with query(
        "Explain the repository structure.",
        {
            "cwd": "/path/to/project",
            "path_to_qwen_executable": "qwen",
        },
    ) as result:
        async for message in result:
            if is_sdk_assistant_message(message):
                print(extract_text(message))
            elif is_sdk_result_message(message):
                print_result(message)


asyncio.run(main())

asyncio.run() is appropriate for standalone scripts. If your application
already runs an event loop, such as Jupyter, FastAPI, or pytest-asyncio, call
await main() instead.

Sync Usage

Use query_sync when your host application is not async:

python
from qwen_code_sdk import is_sdk_result_message, query_sync


with query_sync(
    "Summarize this repository in one paragraph.",
    {
        "cwd": "/path/to/project",
        "path_to_qwen_executable": "qwen",
    },
) as result:
    for message in result:
        if is_sdk_result_message(message):
            if message.get("is_error"):
                error = message.get("error") or {}
                print(f"Error: {error.get('message', 'Unknown error')}")
            else:
                print(message.get("result", ""))

API Surface

Top-level entry points

  • query(prompt, options=None) -> Query
  • query_sync(prompt, options=None) -> SyncQuery

prompt supports either:

  • str for single-turn requests
  • AsyncIterable[SDKUserMessage] for multi-turn streams

`Query`

  • Async iterable over SDK messages
  • close()
  • interrupt()
  • set_model(model)
  • set_permission_mode(mode)
  • supported_commands()
  • mcp_server_status()
  • get_session_id()
  • is_closed()

`QueryOptions`

Option Type / values Description
cwd str Working directory for the CLI process.
model str Model override for this SDK session.
path_to_qwen_executable str qwen, an explicit binary path, or a .js CLI bundle.
permission_mode default, plan, auto-edit, auto, yolo Tool execution approval mode. auto lets an LLM classifier approve tool calls; yolo auto-approves all tools; use it only in trusted or sandboxed environments.
can_use_tool async callback Custom permission callback for tool requests.
env dict[str, str] Extra environment variables passed to the CLI process.
system_prompt str Override the system prompt.
append_system_prompt str Append extra instructions to the system prompt.
debug bool Forward CLI stderr to stderr when no stderr hook exists.
max_session_turns int Maximum turns before the CLI ends the session.
core_tools list[str] Non-empty core-tool allowlist. Omitted or empty lists are unspecified; core_tools=[] does not disable tools.
exclude_tools list[str] Exclude matching tools.
allowed_tools list[str] Allow matching tools without callback approval.
auth_type openai, anthropic, qwen-oauth, gemini, vertex-ai Authentication mode passed to the CLI.
include_partial_messages bool Emit partial assistant stream events.
resume UUID string Resume a known session id.
continue_session bool Continue the latest CLI session.
session_id UUID string Start or correlate a session with a known id.
timeout mapping Timeouts in seconds.
stderr callable Receives CLI stderr lines.

Use only one of resume, continue_session, or session_id in a request. The
SDK raises ValidationError if these session options are combined.

Unsupported in v1:

  • mcp_servers

Common Configuration

python
options = {
    "cwd": "/path/to/project",
    "path_to_qwen_executable": "qwen",
    "model": "qwen-plus",
    "permission_mode": "plan",
    "max_session_turns": 1,
    "env": {
        "OPENAI_MODEL": "qwen-plus",
    },
    "timeout": {
        "control_request": 60,
        "can_use_tool": 60,
        "stream_close": 60,
    },
}

Timeout values are seconds. env is merged on top of the parent process
environment, so you only need to pass variables that should differ for this SDK
session. Set secrets such as OPENAI_API_KEY in the parent environment or a
secrets manager rather than hardcoding them in source.

Permission Handling

When the CLI emits a can_use_tool control request, the SDK routes it through
can_use_tool(tool_name, tool_input, context).

  • Default behavior: deny
  • Default timeout: 60 seconds, configurable with timeout.can_use_tool
  • Timeout fallback: deny
  • Callback exceptions: converted to deny with an error message
  • Callback context: cancel_event, suggestions, and blocked_path
  • Callback contract: can_use_tool must be async with 3 positional arguments;
    stderr must accept 1 positional string argument

Example:

text
/* Detailed source-code truncated for AI context efficiency. */

If you do not pass can_use_tool, the SDK denies permission requests by
default.

Multi-Turn Sessions

For multi-turn sessions, pass an async iterable of SDKUserMessage objects:

python
import asyncio

from qwen_code_sdk import SDKUserMessage, is_sdk_result_message, query

SESSION_ID = "123e4567-e89b-12d3-a456-426614174000"


async def prompts():
    first: SDKUserMessage = {
        "type": "user",
        "session_id": SESSION_ID,
        "message": {
            "role": "user",
            "content": "Create a concise project summary.",
        },
        "parent_tool_use_id": None,
    }
    yield first

    second: SDKUserMessage = {
        "type": "user",
        "session_id": SESSION_ID,
        "message": {
            "role": "user",
            "content": "Also list the test files.",
        },
        "parent_tool_use_id": None,
    }
    yield second


async def main():
    async with query(
        prompts(),
        {
            "cwd": "/path/to/project",
            "path_to_qwen_executable": "qwen",
            "session_id": SESSION_ID,
        },
    ) as result:
        async for message in result:
            if is_sdk_result_message(message):
                if message.get("is_error"):
                    error = message.get("error") or {}
                    print(f"Error: {error.get('message', 'Unknown error')}")
                else:
                    print(message.get("result", ""))


asyncio.run(main())

All messages in the async iterable must be known upfront. The SDK sends them
sequentially to the CLI but cannot feed a prior response back into the generator.
If you need conversational turn-taking, manage each turn as a separate query()
call.

Runtime Controls

The returned Query object can control the running CLI process:

python
import asyncio

from qwen_code_sdk import is_sdk_result_message, query


async def main():
    async with query(
        "Inspect this repository and explain the test layout.",
        {
            "cwd": "/path/to/project",
            "path_to_qwen_executable": "qwen",
        },
    ) as result:
        commands = await result.supported_commands()
        print(commands)

        await result.set_permission_mode("plan")
        await result.set_model("qwen-plus")

        async for message in result:
            if is_sdk_result_message(message):
                if message.get("is_error"):
                    error = message.get("error") or {}
                    print(f"Error: {error.get('message', 'Unknown error')}")
                else:
                    print(message.get("result", ""))


asyncio.run(main())

Use interrupt() to cancel the current operation, close() to clean up the
underlying process, and get_session_id() to persist a session id for later.

Session Resume

python
import asyncio

from qwen_code_sdk import is_sdk_result_message, query


async def main():
    # Resume a known session by its id.
    async with query(
        "Continue from this session.",
        {
            "path_to_qwen_executable": "qwen",
            "resume": "123e4567-e89b-12d3-a456-426614174000",
        },
    ) as known:
        async for message in known:
            if is_sdk_result_message(message):
                if message.get("is_error"):
                    error = message.get("error") or {}
                    print(f"Error: {error.get('message', 'Unknown error')}")
                else:
                    print(message.get("result", ""))


asyncio.run(main())

To continue the latest session instead:

python
import asyncio

from qwen_code_sdk import is_sdk_result_message, query


async def main():
    async with query(
        "Continue the latest session.",
        {
            "path_to_qwen_executable": "qwen",
            "continue_session": True,
        },
    ) as latest:
        async for message in latest:
            if is_sdk_result_message(message):
                if message.get("is_error"):
                    error = message.get("error") or {}
                    print(f"Error: {error.get('message', 'Unknown error')}")
                else:
                    print(message.get("result", ""))


asyncio.run(main())

resume is useful when your application stores session ids. continue_session
delegates the selection of the latest session to the CLI.

Error Model

  • ValidationError: invalid options, invalid UUIDs, unsupported combinations
  • ControlRequestTimeoutError: initialize, interrupt, or other control request
    timed out
  • ProcessExitError: CLI exited non-zero
  • AbortError: control request or session was cancelled
python
from qwen_code_sdk import (
    ProcessExitError,
    ValidationError,
    is_sdk_result_message,
    query_sync,
)

try:
    with query_sync("Say hello", {"path_to_qwen_executable": "qwen"}) as result:
        for message in result:
            if is_sdk_result_message(message):
                if message.get("is_error"):
                    error = message.get("error") or {}
                    print(f"Error: {error.get('message', 'Unknown error')}")
                else:
                    print(message.get("result", ""))
except ValidationError as exc:
    print(f"Invalid SDK options: {exc}")
except ProcessExitError as exc:
    print(f"qwen exited with {exc.exit_code}: {exc}")

Troubleshooting

If the SDK cannot start the CLI:

  • Verify qwen --version works in the target environment
  • Pass path_to_qwen_executable if your shell uses nvm, pyenv, or other
    non-standard PATH setup
  • Use debug=True or stderr=print to surface CLI stderr while debugging

If session control calls time out:

  • Check that the target qwen version supports --input-format stream-json
  • Increase timeout.control_request
  • Verify that no wrapper script is swallowing stdout/stderr

Repository Integration

Repository-level helper commands:

  • npm run test:sdk:python
  • npm run lint:sdk:python
  • npm run typecheck:sdk:python
  • npm run smoke:sdk:python -- --qwen qwen

Real E2E Smoke

For a real runtime check (actual qwen process + real model call), run from
the repository root. The npm helper uses python3, so ensure it resolves to a
Python >=3.10 interpreter:

bash
npm run smoke:sdk:python -- --qwen qwen

This script runs:

  • async single-turn query
  • async control flow (supported_commands, permission mode updates)
  • sync query_sync query

It prints JSON and returns non-zero on failure.


Developers Sdk Typescript (`docs/developers/sdk-typescript.md`)

Typescript SDK

@qwen-code/sdk

A minimum experimental TypeScript SDK for programmatic access to Qwen Code.

Feel free to submit a feature request/issue/PR.

Installation

bash
npm install @qwen-code/sdk

Requirements

  • Node.js >= 22.0.0
  • Qwen Code >= 0.4.0 (stable). The SDK uses its bundled CLI by default; set pathToQwenExecutable only when you need to run a custom qwen binary or CLI bundle.

Quick Start

typescript
import { query } from '@qwen-code/sdk';

// Single-turn query
const result = query({
  prompt: 'What files are in the current directory?',
  options: {
    cwd: '/path/to/project',
  },
});

// Iterate over messages
for await (const message of result) {
  if (message.type === 'assistant') {
    console.log('Assistant:', message.message.content);
  } else if (message.type === 'result') {
    console.log('Result:', message.result);
  }
}

API Reference

`query(config)`

Creates a new query session with the Qwen Code.

Parameters

  • prompt: string | AsyncIterable<SDKUserMessage> - The prompt to send. Use a string for single-turn queries or an async iterable for multi-turn conversations.
  • options: QueryOptions - Configuration options for the query session.

QueryOptions

Option Type Default Description
cwd string process.cwd() The working directory for the query session. Determines the context in which file operations and commands are executed.
model string - The AI model to use (e.g., 'qwen-max', 'qwen-plus', 'qwen-turbo'). Takes precedence over OPENAI_MODEL and QWEN_MODEL environment variables.
pathToQwenExecutable string Bundled CLI Path to the Qwen Code executable. Supports multiple formats: 'qwen' (native binary from PATH), '/path/to/qwen' (explicit path), '/path/to/cli.js' (Node.js bundle), 'node:/path/to/cli.js' (force Node.js runtime), 'bun:/path/to/cli.js' (force Bun runtime). If not provided, the SDK uses the bundled CLI included with the package.
permissionMode 'default' | 'plan' | 'auto-edit' | 'auto' | 'yolo' 'default' Permission mode controlling tool execution approval. See Permission Modes for details.
canUseTool CanUseTool - Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See Custom Permission Handler.
env Record<string, string> - Environment variables to pass to the Qwen Code process. Merged with the current process environment.
systemPrompt string | QuerySystemPromptPreset - System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions.
mcpServers Record<string, McpServerConfig> - MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like command, args, url, httpUrl, etc. SDK servers use { type: 'sdk', name: string, instance: Server }.
abortController AbortController - Controller to cancel the query session. Call abortController.abort() to terminate the session and cleanup resources.
debug boolean false Enable debug mode for verbose logging from the CLI process.
maxSessionTurns number -1 (unlimited) Maximum number of conversation turns before the session automatically terminates. Must be an integer. A turn consists of a user message and an assistant response.
coreTools string[] - Uses the legacy coreTools / CLI --core-tools allowlist semantics. If specified, only matching core tools are registered for the session. This is the only allowlist-style option that restricts built-in tool registration; a whole-tool permissions.deny / excludeTools rule (and tools.disabled in settings.json) also removes a tool from the registry. permissions.allow in settings.json is pure auto-approval and never removes, demotes, or hides a tool (#10075). To keep a tool's schema out of the initial model request, use tools.eager in settings.json (requires restart, #9827) — tool_search, tool_call, structured_output, plan-mode lifecycle tools, task_stop, mcp__* and computer_use__* tools are exempt from that allowlist and keep their normal loading; tools demoted this way stay registered and reachable through tool_search + tool_call while both bridge tools are registered — when either is unregistered (tools.toolSearch.enabled: false denies both; a tool_search or tool_call deny rule, or a tools.disabled entry removes one) the demoted tools that remain hidden are not offered to the model and cannot be reached through the bridge for that session, and a warning is written to the CLI process's stderr (SDK forwards it only with piped stderr and effective debug logging; an explicit logLevel always wins); they stay registered, so a direct call by their own name is still evaluated and approved normally — except tools also listed in tools.visible, which are declared upfront, and sessions whose live history contains a direct call to a still-hidden demoted tool, which any tool-set refresh (resume, MCP discovery, the first plan-mode entry in a session, a subagent definition change) re-declares.; to remove a tool entirely, use a whole-tool excludeTools / permissions.deny rule — a rule with a specifier (such as 'Bash(rm *)') only denies matching invocations at runtime. MCP tools are exempt from deny-based removal: hide them with the per-server excludeTools / tools.disabled filters instead (deny still blocks their calls at runtime). Example: ['read_file', 'edit', 'run_shell_command'].
excludeTools string[] - Equivalent to permissions.deny in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name ('write_file'), shell command prefix ('Bash(rm *)'), or path patterns ('Read(.env)', 'Edit(/src/**)').
allowedTools string[] - Equivalent to permissions.allow in settings.json for auto-approval. Matching tools bypass canUseTool callback and execute automatically. Only applies when tool requires confirmation. Like permissions.allow, this is pure auto-approval and never affects which tools are registered or which schemas are sent (#10075). Supports same pattern matching as excludeTools. Example: ['Bash(git status)', 'Bash(npm test)'].
authType 'openai' | 'anthropic' | 'qwen-oauth' | 'gemini' | 'vertex-ai' - Authentication type for the AI service. When provided, the SDK forwards it to the CLI as --auth-type.
agents SubagentConfig[] - Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains.
includePartialMessages boolean false When true, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response.
resume string - Resume a previous session by providing its session ID. Equivalent to CLI's --resume flag.
sessionId string - Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's --session-id flag.

[!note]
For coreTools, aliases like Read, Edit, and Bash also work, but invocation specifiers such as Bash(git *) are stripped. coreTools restricts tool registration, not invocation patterns.

Empty core-tool allowlists

An omitted or empty coreTools list is treated as unspecified; coreTools: [] does not create a tool-free session. A non-empty list restricts core tools, not every tool category: MCP and other non-core tools have separate registration and permission rules. Use excludeTools to exclude named tools explicitly. allowedTools controls approval, not which tools are registered.

Timeouts

The SDK enforces the following default timeouts:

Timeout Default Description
canUseTool 1 minute Maximum time for canUseTool callback to respond. If exceeded, the tool request is auto-denied.
mcpRequest 1 minute Maximum time for SDK MCP tool calls to complete.
controlRequest 1 minute Maximum time for control operations like initialize(), setModel(), setPermissionMode(), getContextUsage(), and interrupt() to complete.
streamClose 1 minute Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers.

You can customize these timeouts via the timeout option:

typescript
import { query } from '@qwen-code/sdk';

const q = query({
  prompt: 'Your prompt',
  options: {
    timeout: {
      canUseTool: 60000, // 60 seconds for permission callback
      mcpRequest: 600000, // 10 minutes for MCP tool calls
      controlRequest: 60000, // 60 seconds for control requests
      streamClose: 15000, // 15 seconds for stream close wait
    },
  },
});

Message Types

The SDK provides type guards to identify different message types:

typescript
import {
  isSDKUserMessage,
  isSDKAssistantMessage,
  isSDKSystemMessage,
  isSDKResultMessage,
  isSDKPartialAssistantMessage,
} from '@qwen-code/sdk';

for await (const message of result) {
  if (isSDKAssistantMessage(message)) {
    // Handle assistant message
  } else if (isSDKResultMessage(message)) {
    // Handle result message
  }
}

Query Instance Methods

The Query instance returned by query() provides several methods:

typescript
const q = query({ prompt: 'Hello', options: {} });

// Get session ID
const sessionId = q.getSessionId();

// Check if closed
const closed = q.isClosed();

// Interrupt the current operation
await q.interrupt();

// Change permission mode mid-session
await q.setPermissionMode('yolo');

// Change model mid-session
await q.setModel('qwen-max');

// Get context window usage breakdown (token counts per category)
const usage = await q.getContextUsage();
// Pass true to hint that per-item details should be displayed
const detail = await q.getContextUsage(true);

// Close the session
await q.close();

interrupt() cancels only the active turn. For a multi-turn query created with
an async iterable prompt, the query and its input stream remain open, so later
messages from the iterable are processed normally. Use close() or abort the
configured AbortController when you want to end the entire session.

Daemon caller-supplied session IDs

DaemonClient.createOrAttachSession accepts an optional sessionId for callers that must persist an identity before session creation:

typescript
import { DaemonClient } from '@qwen-code/sdk';

const daemon = new DaemonClient({ baseUrl: 'http://127.0.0.1:4170' });
const session = await daemon.createOrAttachSession({
  workspaceCwd: '/path/to/project',
  sessionId: '550E8400-E29B-41D4-A716-446655440000',
});

console.log(session.sessionId); // 550e8400-e29b-41d4-a716-446655440000

The SDK requires the daemon's session_id_override capability before sending the mutation. REST mode serializes sessionId directly; an active ACP adapter maps it to session/new._meta["qwen-code/sessionId"]. The SDK verifies the success response and throws DaemonSessionIdProtocolError if the daemon returns a different ID.

This option always creates a new thread session and is not an idempotent attach. If the create outcome is ambiguous, use the known ID with load or resume. Omitting the option preserves the existing create-or-attach behavior.

Daemon startup model and reasoning selection

DaemonClient.createOrAttachSession and createStandaloneSession accept startupConfig: { modelServiceId, reasoningEffort? } and preflight session_startup_config before creation. The model is required; omit reasoning for model-only selection, including models with no reasoning control. An explicit effort is validated against the model. The SDK checks modelApplied: true and the canonical startupConfigApplied response; model-only confirmation omits reasoning fields.

Startup selection does not save shared defaults or impose a lifetime pin. Definite selection rejection fails creation; uncertain standalone creation retains its existing recovery result. A successful response whose startup confirmation is missing or mismatched is rejected directly and does not trigger standalone recovery/adoption. See the protocol.

Talking to running sessions

@qwen-code/sdk/peer lets a program that is not a Qwen Code session join the
sessions running as the same user on the same machine — a voice front-end, a
relay, a build watcher. The program shows up in qwen sessions ps, and in the
list_agents of every session that takes part in cross-session messaging — its
agents.crossSessionMessaging setting is on, and it was not started with
--bare or --safe-mode — which is also what lets those sessions message it
by name with send_message. It can message them back. It runs on Node only and
needs nothing beyond Node itself.

typescript
import { PeerEndpoint } from '@qwen-code/sdk/peer';

const endpoint = await PeerEndpoint.start({
  name: 'voice-bridge',
  onMessage: (message) =>
    console.log(`${message.fromName}: ${message.content}`),
});

const [session] = await endpoint.list();
if (session) {
  const sent = await endpoint.send({
    to: session.address,
    content: 'What are you working on?',
  });
  if (sent.kind === 'sent') {
    const receipt = await endpoint.awaitReceipt(sent.msgId, { final: true });
    console.log(receipt?.status); // delivered, denied, refused, ...
  }
}

await endpoint.close();

A message like that is held for the session's user to review. To direct a
session without that review, mint a controller token with
qwen sessions controllers add --label voice-bridge, give it to the endpoint,
and mark the sends that should present it:

typescript
const endpoint = await PeerEndpoint.start({
  name: 'voice-bridge',
  controllerToken: process.env['QWEN_CONTROLLER_TOKEN'],
});
await endpoint.send({
  to: 'my-app-3f',
  content: 'run the tests',
  controller: true,
});

Things to know:

  • A session has an inbox only while its agents.crossSessionMessaging setting
    is on, which it is by default, and it was not started with --bare or
    --safe-mode — those turn messaging off whatever the setting says. A session
    that turned it off does not appear in list(), and its own list_agents and
    send_message cannot see or reach the program either. qwen sessions ps
    lists the program regardless.
  • A message is delivered without review in exactly two cases: the send
    presents a controller token (controller: true), or its fromMode names the
    receiving session's own review class. fromMode is a claim nothing
    authenticates, so a program that is not a coding session should leave it
    out. Nothing in the record — not kind, not name — buys delivery. The
    receiving session's agents.crossSessionInbound setting outranks both:
    hold or refuse there wins over a controller token.
  • Mark only the sends meant to direct a session. Addresses are resolved from
    records any program running as you can write, so a controller send presents
    the token to whichever process's record answers to that address. A
    controller send to another peer endpoint is dropped unread, because an
    endpoint's inbox accepts only its own token.
  • The endpoint's inbox applies none of the protections a Qwen Code session
    applies to its own: no rate limit, no holds, and no duplicate window beyond
    the last 200 messages it answered. Every message is answered delivered and
    handed to onMessage as it arrives, so apply your own limits there if you
    need them. Without onMessage, every message is answered refused.
  • Call close() before exiting, including from your own signal handlers. A
    process killed without closing leaves its record behind until a Qwen Code
    session lists the directory and sees the process is gone.
  • UNIX domain sockets only: Windows is not supported yet.

The record schema, wire format and receipt states are documented in
Cross-Session Protocol.

Permission Modes

The SDK supports different permission modes for controlling tool execution:

  • default: Write tools are denied unless approved via canUseTool callback or in allowedTools. Read-only tools execute without confirmation.
  • plan: Blocks all write tools, instructing AI to present a plan first.
  • auto-edit: Auto-approve edit tools (edit, write_file, notebook_edit) while other tools require confirmation.
  • auto: Uses the built-in classifier to auto-approve safe tool calls and block risky ones, with manual-approval fallback after repeated policy blocks or classifier outages.
  • yolo: All tools execute automatically without confirmation.

Permission Priority Chain

Decision priority (highest first): deny > ask > allow > (default/interactive mode)

The first matching rule wins.

  1. excludeTools / permissions.deny - Blocks tools completely (returns permission error)
  2. permissions.ask - Always requires user confirmation
  3. permissionMode: 'plan' - Blocks all non-read-only tools
  4. permissionMode: 'yolo' - Auto-approves all tools
  5. allowedTools / permissions.allow - Auto-approves matching tools
  6. permissionMode: 'auto' - Classifier-mediated approval for remaining tools
  7. canUseTool callback - Custom approval logic (if provided, not called for allowed tools)
  8. Default behavior - Auto-deny in SDK mode (write tools require explicit approval)

Examples

Multi-turn Conversation

typescript
import { query, type SDKUserMessage } from '@qwen-code/sdk';

async function* generateMessages(): AsyncIterable<SDKUserMessage> {
  yield {
    type: 'user',
    session_id: 'my-session',
    message: { role: 'user', content: 'Create a hello.txt file' },
    parent_tool_use_id: null,
  };

  // Wait for some condition or user input
  yield {
    type: 'user',
    session_id: 'my-session',
    message: { role: 'user', content: 'Now read the file back' },
    parent_tool_use_id: null,
  };
}

const result = query({
  prompt: generateMessages(),
  options: {
    permissionMode: 'auto-edit',
  },
});

for await (const message of result) {
  console.log(message);
}

Custom Permission Handler

typescript
import { query, type CanUseTool } from '@qwen-code/sdk';

const canUseTool: CanUseTool = async (toolName, input, { signal }) => {
  // Allow all read operations
  if (toolName.startsWith('read_')) {
    return { behavior: 'allow', updatedInput: input };
  }

  // Prompt user for write operations (in a real app)
  const userApproved = await promptUser(`Allow ${toolName}?`);

  if (userApproved) {
    return { behavior: 'allow', updatedInput: input };
  }

  return { behavior: 'deny', message: 'User denied the operation' };
};

const result = query({
  prompt: 'Create a new file',
  options: {
    canUseTool,
  },
});

With External MCP Servers

typescript
import { query } from '@qwen-code/sdk';

const result = query({
  prompt: 'Use the custom tool from my MCP server',
  options: {
    mcpServers: {
      'my-server': {
        command: 'node',
        args: ['path/to/mcp-server.js'],
        env: { PORT: '3000' },
      },
    },
  },
});

Override the System Prompt

typescript
import { query } from '@qwen-code/sdk';

const result = query({
  prompt: 'Say hello in one sentence.',
  options: {
    systemPrompt: 'You are a terse assistant. Answer in exactly one sentence.',
  },
});

Append to the Built-in System Prompt

typescript
import { query } from '@qwen-code/sdk';

const result = query({
  prompt: 'Review the current directory.',
  options: {
    systemPrompt: {
      type: 'preset',
      preset: 'qwen_code',
      append: 'Be terse and focus on concrete findings.',
    },
  },
});

With SDK-Embedded MCP Servers

The SDK provides tool and createSdkMcpServer to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process.

`tool(name, description, inputSchema, handler)`

Creates a tool definition with Zod schema type inference.

Parameter Type Description
name string Tool name (1-64 chars, starts with letter, alphanumeric and underscores)
description string Human-readable description of what the tool does
inputSchema ZodRawShape Zod schema object defining the tool's input parameters
handler (args, extra) => Promise<Result> Async function that executes the tool and returns MCP content blocks

The handler must return a CallToolResult object with the following structure:

typescript
{
  content: Array<
    | { type: 'text'; text: string }
    | { type: 'image'; data: string; mimeType: string }
    | { type: 'resource'; uri: string; mimeType?: string; text?: string }
  >;
  isError?: boolean;
}

`createSdkMcpServer(options)`

Creates an SDK-embedded MCP server instance.

Option Type Default Description
name string Required Unique name for the MCP server
version string '1.0.0' Server version
tools SdkMcpToolDefinition[] - Array of tools created with tool()

Returns a McpSdkServerConfigWithInstance object that can be passed directly to the mcpServers option.

Example

typescript
import { z } from 'zod';
import { query, tool, createSdkMcpServer } from '@qwen-code/sdk';

// Define a tool with Zod schema
const calculatorTool = tool(
  'calculate_sum',
  'Add two numbers',
  { a: z.number(), b: z.number() },
  async (args) => ({
    content: [{ type: 'text', text: String(args.a + args.b) }],
  }),
);

// Create the MCP server
const server = createSdkMcpServer({
  name: 'calculator',
  tools: [calculatorTool],
});

// Use the server in a query
const result = query({
  prompt: 'What is 42 + 17?',
  options: {
    permissionMode: 'yolo',
    mcpServers: {
      calculator: server,
    },
  },
});

for await (const message of result) {
  console.log(message);
}

Abort a Query

typescript
import { query, isAbortError } from '@qwen-code/sdk';

const abortController = new AbortController();

const result = query({
  prompt: 'Long running task...',
  options: {
    abortController,
  },
});

// Abort after 5 seconds
setTimeout(() => abortController.abort(), 5000);

try {
  for await (const message of result) {
    console.log(message);
  }
} catch (error) {
  if (isAbortError(error)) {
    console.log('Query was aborted');
  } else {
    throw error;
  }
}

Error Handling

The SDK provides an AbortError class for handling aborted queries:

typescript
import { AbortError, isAbortError } from '@qwen-code/sdk';

try {
  // ... query operations
} catch (error) {
  if (isAbortError(error)) {
    // Handle abort
  } else {
    // Handle other errors
  }
}

Human-origin workflow hints

Streaming SDKUserMessage input supports per-message origin: { kind: 'human' } for original human submissions. Messages without the declaration and string convenience prompts remain ordinary chat without keyword activation. See the workflow intent contract (简体中文) for examples and exclusions.


Plans 2026 03 22 Agent Tool Display Design (`docs/plans/2026-03-22-agent-tool-display-design.md`)

Agent Tool Display Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a dedicated VSCode/web UI display for Agent tool executions so subagent progress, summaries, and failures render from structured rawOutput instead of falling back to the generic tool card.

Architecture: Preserve ACP rawOutput through the VSCode session/update pipeline into ToolCallData, then let the shared web UI router detect task_execution payloads and render a dedicated AgentToolCall component. Keep the change shared in packages/webui so VSCode and ChatViewer stay aligned.

Tech Stack: TypeScript, React, Vitest, shared @qwen-code/webui tool-call components.

Task 1: Lock in the failing data-flow behavior

Files:

  • Modify: packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts
  • Create: packages/vscode-ide-companion/src/webview/hooks/useToolCalls.test.tsx

Step 1: Write the failing tests

  • Add a session handler test asserting tool_call_update forwards rawOutput when ACP sends a task_execution payload.
  • Add a hook test asserting useToolCalls stores and updates rawOutput for an agent tool call.

Step 2: Run test to verify it fails

Run: npm test --workspace=packages/vscode-ide-companion -- --run qwenSessionUpdateHandler.test.ts useToolCalls.test.tsx

Expected: failures because rawOutput is not preserved in the current handler/hook pipeline.

Task 2: Lock in the failing renderer behavior

Files:

  • Create: packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx

Step 1: Write the failing test

  • Render the routed tool call with kind: 'other' plus rawOutput.type === 'task_execution'.
  • Assert the task description, active child tool, summary, and failure reason render from a dedicated agent display instead of generic text output.

Step 2: Run test to verify it fails

Run: npm test --workspace=packages/vscode-ide-companion -- --run packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx

Expected: failure because the router only keys off kind and no dedicated agent component exists.

Task 3: Preserve structured agent output end-to-end

Files:

  • Modify: packages/vscode-ide-companion/src/types/chatTypes.ts
  • Modify: packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts
  • Modify: packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts
  • Modify: packages/webui/src/components/toolcalls/shared/types.ts

Step 1: Implement the minimal data model changes

  • Add optional rawOutput to the VSCode session/webview tool-call types.
  • Forward rawOutput in QwenSessionUpdateHandler.
  • Store/merge rawOutput in useToolCalls.
  • Expose rawOutput in shared web UI tool-call data types.

Step 2: Run the focused tests

Run: npm test --workspace=packages/vscode-ide-companion -- --run qwenSessionUpdateHandler.test.ts useToolCalls.test.tsx

Expected: pass.

Task 4: Add the shared agent tool-call UI

Files:

  • Create: packages/webui/src/components/toolcalls/AgentToolCall.tsx
  • Modify: packages/webui/src/components/toolcalls/index.ts
  • Modify: packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx
  • Modify: packages/webui/src/components/ChatViewer/ChatViewer.tsx

Step 1: Implement the minimal renderer

  • Add a guard for rawOutput.type === 'task_execution'.
  • Render task description as the header.
  • Show agent name + status, currently running child tools, completion summary, and failure/cancel reason.
  • Keep the layout compatible with multiple parallel agent cards by rendering each tool call independently.

Step 2: Run the focused renderer test

Run: npm test --workspace=packages/vscode-ide-companion -- --run packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx

Expected: pass.

Task 5: Verify the integrated surface

Files:

  • Modify: packages/webui/src/index.ts

Step 1: Export the new shared component if needed

  • Re-export any new component/types needed by VSCode or ChatViewer.

Step 2: Run package verification

Run: npm test --workspace=packages/vscode-ide-companion -- --run qwenSessionUpdateHandler.test.ts useToolCalls.test.tsx packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx
Run: npm run check-types --workspace=packages/vscode-ide-companion
Run: npm run typecheck --workspace=packages/webui

Expected: all targeted tests and typechecks pass.


Users Common Workflow (`docs/users/common-workflow.md`)

Common workflows

Learn about common workflows with Qwen Code.

Each task in this document includes clear instructions, example commands, and best practices to help you get the most from Qwen Code.

Understand new codebases

Get a quick codebase overview

Suppose you've just joined a new project and need to understand its structure quickly.

1. Navigate to the project root directory

bash
cd /path/to/project

2. Start Qwen Code

bash
qwen

3. Ask for a high-level overview

text
give me an overview of this codebase

4. Dive deeper into specific components

text
explain the main architecture patterns used here
text
what are the key data models?
text
how is authentication handled?

[!tip]

  • Start with broad questions, then narrow down to specific areas
  • Ask about coding conventions and patterns used in the project
  • Request a glossary of project-specific terms

Find relevant code

Suppose you need to locate code related to a specific feature or functionality.

1. Ask Qwen Code to find relevant files

text
find the files that handle user authentication

2. Get context on how components interact

text
how do these authentication files work together?

3. Understand the execution flow

text
trace the login process from front-end to database

[!tip]

  • Be specific about what you're looking for
  • Use domain language from the project

Fix bugs efficiently

Suppose you've encountered an error message and need to find and fix its source.

1. Share the error with Qwen Code

text
I'm seeing an error when I run npm test

2. Ask for fix recommendations

text
suggest a few ways to fix the @ts-ignore in user.ts

3. Apply the fix

text
update user.tsto add the null check you suggested

[!tip]

  • Tell Qwen Code the command to reproduce the issue and get a stack trace
  • Mention any steps to reproduce the error
  • Let Qwen Code know if the error is intermittent or consistent

Refactor code

Suppose you need to update old code to use modern patterns and practices.

1. Identify legacy code for refactoring

text
find deprecated API usage in our codebase

2. Get refactoring recommendations

text
suggest how to refactor utils.js to use modern JavaScript features

3. Apply the changes safely

text
refactor utils.js to use ES 2024 features while maintaining the same behavior

4. Verify the refactoring

text
run tests for the refactored code

[!tip]

  • Ask Qwen Code to explain the benefits of the modern approach
  • Request that changes maintain backward compatibility when needed
  • Do refactoring in small, testable increments

Use specialized subagents

Suppose you want to use specialized AI subagents to handle specific tasks more effectively.

1. View available subagents

text
/agents

This shows all available subagents and lets you create new ones.

2. Use subagents automatically

Qwen Code automatically delegates appropriate tasks to specialized subagents:

text
review my recent code changes for security issues
text
run all tests and fix any failures

3. Explicitly request specific subagents

text
use the code-reviewer subagent to check the auth module
text
have the debugger subagent investigate why users can't log in

4. Create custom subagents for your workflow

text
/agents

Then select "create" and follow the prompts to define:

  • A unique identifier that describes the subagent's purpose (for example, code-reviewer, api-designer).
  • When Qwen Code should use this agent
  • Which tools it can access
  • A system prompt describing the agent's role and behavior

[!tip]

  • Create project-specific subagents in .qwen/agents/ for team sharing
  • Use descriptive description fields to enable automatic delegation
  • Limit tool access to what each subagent actually needs
  • Know more about Sub Agents
  • Know more about Approval Mode

Work with tests

Suppose you need to add tests for uncovered code.

1. Identify untested code

text
find functions in NotificationsService.swift that are not covered by tests

2. Generate test scaffolding

text
add tests for the notification service

3. Add meaningful test cases

text
add test cases for edge conditions in the notification service

4. Run and verify tests

text
run the new tests and fix any failures

Qwen Code can generate tests that follow your project's existing patterns and conventions. When asking for tests, be specific about what behavior you want to verify. Qwen Code examines your existing test files to match the style, frameworks, and assertion patterns already in use.

For comprehensive coverage, ask Qwen Code to identify edge cases you might have missed. Qwen Code can analyze your code paths and suggest tests for error conditions, boundary values, and unexpected inputs that are easy to overlook.

Create pull requests

Suppose you need to create a well-documented pull request for your changes.

1. Summarize your changes

text
summarize the changes I've made to the authentication module

2. Generate a pull request with Qwen Code

text
create a pr

3. Review and refine

text
enhance the PR description with more context about the security improvements

4. Add testing details

text
add information about how these changes were tested

[!tip]

  • Ask Qwen Code directly to make a PR for you
  • Review Qwen Code's generated PR before submitting
  • Ask Qwen Code to highlight potential risks or considerations

Handle documentation

Suppose you need to add or update documentation for your code.

1. Identify undocumented code

text
find functions without proper JSDoc comments in the auth module

2. Generate documentation

text
add JSDoc comments to the undocumented functions in auth.js

3. Review and enhance

text
improve the generated documentation with more context and examples

4. Verify documentation

text
check if the documentation follows our project standards

[!tip]

  • Specify the documentation style you want (JSDoc, docstrings, etc.)
  • Ask for examples in the documentation
  • Request documentation for public APIs, interfaces, and complex logic

Reference files and directories

Use @ to quickly include files or directories without waiting for Qwen Code to read them.

1. Reference a single file

text
Explain the logic in @src/utils/auth.js

This includes the full content of the file in the conversation.

2. Reference a directory

text
What's the structure of @src/components?

This provides a directory listing with file information.

3. Reference MCP resources

text
Show me the data from @github: repos/owner/repo/issues

This fetches data from connected MCP servers using the format @server: resource. See MCP for details.

[!tip]

  • File paths can be relative or absolute
  • @ file references add QWEN.md in the file's directory and parent directories to context
  • Directory references show file listings, not contents
  • You can reference multiple files in a single message (for example, "@file 1.js and @file 2.js")

Resume previous conversations

Suppose you've been working on a task with Qwen Code and need to continue where you left off in a later session.

Qwen Code provides two options for resuming previous conversations:

  • --continue to automatically continue the most recent conversation
  • --resume to display a conversation picker

1. Continue the most recent conversation

bash
qwen --continue

This immediately resumes your most recent conversation without any prompts.

2. Continue in non-interactive mode

bash
qwen --continue -p "Continue with my task"

Use -p (or --prompt) with --continue to resume the most recent conversation in non-interactive mode, perfect for scripts or automation.

3. Show conversation picker

bash
qwen --resume

This displays an interactive conversation selector with a clean list view showing:

  • Session summary (or initial prompt)
  • Metadata: time elapsed, message count, and git branch

Use arrow keys to navigate and press Enter to select a conversation. Press Esc to exit.

[!tip]

  • Conversation history is stored locally on your machine
  • Use --continue for quick access to your most recent conversation
  • Use --resume when you need to select a specific past conversation
  • When resuming, you'll see the entire conversation history before continuing
  • The resumed conversation starts with the same model and configuration as the original

How it works:

  1. Conversation Storage: All conversations are automatically saved locally with their full message history
  2. Message Deserialization: When resuming, the entire message history is restored to maintain context
  3. Tool State: Tool usage and results from the previous conversation are preserved
  4. Context Restoration: The conversation resumes with all previous context intact

Examples:

bash
# Continue most recent conversation
qwen --continue

# Continue most recent conversation with a specific prompt
qwen --continue -p "Show me our progress"

# Show conversation picker
qwen --resume

# Continue most recent conversation in non-interactive mode
qwen --continue -p "Run the tests again"

Run parallel Qwen Code sessions with Git worktrees

Suppose you need to work on multiple tasks simultaneously with complete code isolation between Qwen Code instances.

1. Understand Git worktrees

Git worktrees allow you to check out multiple branches from the same repository into separate directories. Each worktree has its own working directory with isolated files, while sharing the same Git history. Learn more in the official Git worktree documentation.

2. Create a new worktree

bash
# Create a new worktree with a new branch
git worktree add ../project-feature-a -b feature-a

# Or create a worktree with an existing branch
git worktree add ../project-bugfix bugfix-123

This creates a new directory with a separate working copy of your repository.

3. Run Qwen Code in each worktree

bash
# Navigate to your worktree
cd ../project-feature-a

# Run Qwen Code in this isolated environment
qwen

4. Run Qwen Code in another worktree

bash
cd ../project-bugfix
qwen

5. Manage your worktrees

bash
# List all worktrees
git worktree list

# Remove a worktree when done
git worktree remove ../project-feature-a

[!tip]

  • Each worktree has its own independent file state, making it perfect for parallel Qwen Code sessions
  • Changes made in one worktree won't affect others, preventing Qwen Code instances from interfering with each other
  • All worktrees share the same Git history and remote connections
  • For long-running tasks, you can have Qwen Code working in one worktree while you continue development in another
  • Use descriptive directory names to easily identify which task each worktree is for
  • Remember to initialize your development environment in each new worktree according to your project's setup. Depending on your stack, this might include:
    • JavaScript projects: Running dependency installation (npm install, yarn)
    • Python projects: Setting up virtual environments or installing with package managers
    • Other languages: Following your project's standard setup process

Use Qwen Code as a unix-style utility

Add Qwen Code to your verification process

Suppose you want to use Qwen Code as a linter or code reviewer.

Add Qwen Code to your build script:

json
// package.json
{
    ...
    "scripts": {
        ...
        "lint:Qwen Code": "qwen -p 'you are a linter. please look at the changes vs. main and report any issues related to typos. report the filename and line number on one line, and a description of the issue on the second line. do not return any other text.'"
    }
}

[!tip]

  • Use Qwen Code for automated code review in your CI/CD pipeline
  • Customize the prompt to check for specific issues relevant to your project
  • Consider creating multiple scripts for different types of verification

Pipe in, pipe out

Suppose you want to pipe data into Qwen Code, and get back data in a structured format.

Pipe data through Qwen Code:

bash
cat build-error.txt | qwen -p 'concisely explain the root cause of this build error' > output.txt

[!tip]

  • Use pipes to integrate Qwen-Code into existing shell scripts
  • Combine with other Unix tools for powerful workflows
  • Consider using --output-format for structured output

Control output format

Suppose you need Qwen Code's output in a specific format, especially when integrating Qwen Code into scripts or other tools.

1. Use text format (default)

bash
cat data.txt | qwen -p 'summarize this data' --output-format text > summary.txt

This outputs just Qwen Code's plain text response (default behavior).

2. Use JSON format

bash
cat code.py | qwen -p 'analyze this code for bugs' --output-format json > analysis.json

This outputs a JSON array of messages with metadata including cost and duration.

3. Use streaming JSON format

bash
cat log.txt | qwen -p 'parse this log file for errors' --output-format stream-json

This outputs a series of JSON objects in real-time as Qwen Code processes the request. Each message is a valid JSON object, but the entire output is not valid JSON if concatenated.

[!tip]

  • Use --output-format text for simple integrations where you just need Qwen Code's response
  • Use --output-format json when you need the full conversation log
  • Use --output-format stream-json for real-time output of each conversation turn

Ask Qwen Code about its capabilities

Qwen Code has built-in access to its documentation and can answer questions about its own features and limitations.

Example questions

text
can Qwen Code create pull requests?
text
how does Qwen Code handle permissions?
text
what slash commands are available?
text
how do I use MCP with Qwen Code?
text
how do I configure Qwen Code for Amazon Bedrock?
text
what are the limitations of Qwen Code?

[!note]

Qwen Code provides documentation-based answers to these questions. For executable examples and hands-on demonstrations, refer to the specific workflow sections above.

[!tip]

  • Qwen Code always has access to the latest Qwen Code documentation, regardless of the version you're using
  • Ask specific questions to get detailed answers
  • Qwen Code can explain complex features like MCP integration, enterprise configurations, and advanced workflows

Reuse saved workflows in a monorepo

With tools.workflowsEnabled enabled, a local terminal, headless, or direct ACP session can use saved workflows from trusted parent directories in the same Git repository. For example, when you start Qwen Code in repo/packages/app, a workflow named review is selected in this order:

text
repo/packages/app/.qwen/workflows/review.js
repo/packages/.qwen/workflows/review.js
repo/.qwen/workflows/review.js
~/.qwen/workflows/review.js

The nearest definition wins. Parent discovery stops at the closest Git root, including a worktree or submodule root. A directory outside a Git repository keeps its own project and user workflows. Extension workflows retain their qualified names, such as gcp:review.

Folder trust follows the operator's settings; workspace settings cannot enable or disable this ancestor check. When folder trust is enabled, the current target and each parent must be trusted by the saved folder-trust rules. Trusting only the current directory does not grant access to its parents; an unknown or denied parent stops the search toward the root. IDE trust alone does not grant parent access. Changes to the folder-trust file take effect on the next discovery or script lookup, so a previously listed command cannot use its cached path to read a parent whose trust was revoked. Refresh the command list or start a new session to see added or removed commands. When folder trust is disabled, local sessions allow parent discovery within the Git boundary. On POSIX systems, this disabled mode still excludes parents whose directory, .qwen, or workflow directory is writable by everyone. Windows permission bits do not describe ACLs, so this POSIX permission check does not apply there.

Parent workflows still require the usual workflow approval, which identifies the selected script. Content changes invalidate approvals pinned to the script's digest. Running a parent's workflow keeps the session's working directory, and saving to project scope writes to the current session's .qwen/workflows/ directory.

Daemon-managed ACP children, Web Shell, hosted sessions, and restricted sessions do not opt into parent discovery. Parent directories and their workflow files must be ordinary directories and files; symbolic links cannot grant access to a parent workflow.

Enforce evidence-based conclusions

When Qwen Code does high-stakes work — code review, incident troubleshooting, audits, or investigating production data — a confident but wrong conclusion costs more than no conclusion at all. You can encode your team's verification policy in QWEN.md (see Memory) so it applies to every session, without changing anyone's defaults.

The rules below are adapted from a production retrospective of a multi-agent deployment, where confident-but-wrong conclusions repeatedly traced back to the same failure modes: inferring state from code instead of querying it, treating one satisfied condition as proof, and repeating earlier statements as facts.

1. Add a verification policy to your project QWEN.md

Put this template in QWEN.md at your repository root and commit it, so the policy applies to the whole team:

markdown
# Verification policy

Treat these rules as mandatory in investigations, troubleshooting, reviews, and any task that draws conclusions:

1. Verify before concluding. Back claims about data or system state with evidence from the authoritative source (database, API, logs, command output). Conclusions drawn from reading code alone must be labeled "unverified inference".
2. Verify the full chain. Enumerate the causal chain and check every link; one satisfied necessary condition does not prove a conclusion.
3. Treat conversation history as leads, not facts. Statements in earlier transcripts — including your own prior assertions — are leads. Re-verify them at the source before repeating them as facts.
4. Troubleshoot in order: your own recent changes first, then docs and known issues, and only then external dependencies.
5. 100% evidence rule in reviews. In review and audit tasks, report no conclusion without evidence; attach verifiable evidence (file:line, query result, log excerpt) to every claim.

2. Choose the right scope

See Where to create QWEN.md for which file applies to whom — commit the project-root QWEN.md copy so the policy covers the whole team.

[!tip]

  • Keep the rules specific and actionable: "back every claim with a query result" works better than "be careful".
  • Keep the policy short — QWEN.md is followed more reliably when it is concise (see Memory).
  • Adapt the rules to your stack: name the actual query tools, dashboards, or log commands your team treats as authoritative sources.

Suggest a workflow for one human turn

Use ultracode or the existing workflow keyword in a supported human submission to suggest the Workflow tool for that turn. See workflow intent (简体中文) for entry points, SDK opt-in, and settings.


Users Integration Github Action (`docs/users/integration-github-action.md`)

GitHub Actions:qwen-code-action

Overview

qwen-code-action is a GitHub Action that integrates Qwen Code into your development workflow via the Qwen Code CLI. It acts both as an autonomous agent for critical routine coding tasks, and an on-demand collaborator you can quickly delegate work to.

Use it to perform GitHub pull request reviews, triage issues, perform code analysis and modification, and more using Qwen Code conversationally (e.g., @qwencoder fix this issue) directly inside your GitHub repositories.

Features

  • Automation: Trigger workflows based on events (e.g. issue opening) or schedules (e.g. nightly).
  • On-demand Collaboration: Trigger workflows in issue and pull request
    comments by mentioning the Qwen Code CLI (e.g., @qwencoder /review).
  • Extensible with Tools: Leverage Qwen Code models' tool-calling capabilities to interact with other CLIs like the GitHub CLI (gh).
  • Customizable: Use a QWEN.md file in your repository to provide
    project-specific instructions and context to Qwen Code CLI.

Quick Start

Get started with Qwen Code CLI in your repository in just a few minutes:

1. Get a Qwen API Key

Obtain your API key from DashScope (Alibaba Cloud's AI platform)

2. Add it as a GitHub Secret

Store your API key as a secret named QWEN_API_KEY in your repository:

  • Go to your repository's Settings > Secrets and variables > Actions
  • Click New repository secret
  • Name: QWEN_API_KEY, Value: your API key

3. Update your .gitignore

Add the following entries to your .gitignore file:

gitignore
# qwen-code-cli settings
.qwen/

# GitHub App credentials
gha-creds-*.json

4. Choose a Workflow

You have two options to set up a workflow:

Option A: Use setup command (Recommended)

  1. Start the Qwen Code CLI in your terminal:

    shell
    qwen
  2. In Qwen Code CLI in your terminal, type:

    text
    /setup-github

Option B: Manually copy workflows

  1. Copy the pre-built workflows from the examples/workflows directory to your repository's .github/workflows directory. Note: the qwen-dispatch.yml workflow must also be copied, which triggers the workflows to run.

5. Try it out

Pull Request Review:

  • Open a pull request in your repository and wait for automatic review
  • Comment @qwencoder /review on an existing pull request to manually trigger a review

Issue Triage:

  • Open an issue and wait for automatic triage
  • Comment @qwencoder /triage on existing issues to manually trigger triaging

General AI Assistance:

  • In any issue or pull request, mention @qwencoder followed by your request
  • Examples:
    • @qwencoder explain this code change
    • @qwencoder suggest improvements for this function
    • @qwencoder help me debug this error
    • @qwencoder write unit tests for this component

Workflows

This action provides several pre-built workflows for different use cases. Each workflow is designed to be copied into your repository's .github/workflows directory and customized as needed.

Qwen Code Dispatch

This workflow acts as a central dispatcher for Qwen Code CLI, routing requests to the appropriate workflow based on the triggering event and the command provided in the comment. For a detailed guide on how to set up the dispatch workflow, go to the Qwen Code Dispatch workflow documentation.

Issue Triage

This action can be used to triage GitHub Issues automatically or on demand. For a working issue triage setup, see the Qwen triage workflow.

Pull Request Review

This action can be used to automatically review pull requests when they are opened. For a detailed guide on how to set up the pull request review system, go to the GitHub PR Review workflow documentation.

Qwen Code CLI Assistant

This type of action can be used to invoke a general-purpose, conversational Qwen Code AI assistant within the pull requests and issues to perform a wide range of tasks. For a detailed guide on how to set up the general-purpose Qwen Code CLI workflow, go to the Qwen Code Assistant workflow documentation.

Configuration

Inputs

  • qwen*api_key: *(Optional)_ The API key for the Qwen API.

  • qwen*cli_version: *(Optional, default: latest)_ The version of the Qwen Code CLI to install. Can be "latest", "preview", "nightly", a specific version number, or a git branch, tag, or commit. For more information, see Qwen Code CLI releases.

  • qwen*debug: *(Optional)_ Enable debug logging and output streaming.

  • qwen*model: *(Optional)_ The model to use with Qwen Code.

  • prompt: (Optional, default: You are a helpful assistant.) A string passed to the Qwen Code CLI's --prompt argument.

  • settings: (Optional) A JSON string written to .qwen/settings.json to configure the CLI's project settings.
    For more details, see the documentation on settings files.

  • use*qwen_code_assist: *(Optional, default: false)_ Whether to use Code Assist for Qwen Code model access instead of the default Qwen Code API key.
    For more information, see the Qwen Code CLI documentation.

  • use*vertex_ai: *(Optional, default: false)_ Whether to use Vertex AI for Qwen Code model access instead of the default Qwen Code API key.
    For more information, see the Qwen Code CLI documentation.

  • extensions: (Optional) A list of Qwen Code CLI extensions to install.

  • upload*artifacts: *(Optional, default: false)_ Whether to upload artifacts to the github action.

  • use*pnpm: *(Optional, default: false)_ Whether or not to use pnpm instead of npm to install qwen-code-cli

  • workflow*name: *(Optional, default: ${{ github.workflow }})_ The GitHub workflow name, used for telemetry purposes.

Outputs

  • summary: The summarized output from the Qwen Code CLI execution.

  • error: The error output from the Qwen Code CLI execution, if any.

Repository Variables

We recommend setting the following values as repository variables so they can be reused across all workflows. Alternatively, you can set them inline as action inputs in individual workflows or to override repository-level values.

Name Description Type Required When Required
DEBUG Enables debug logging for the Qwen Code CLI. Variable No Never
QWEN_CLI_VERSION Controls which version of the Qwen Code CLI is installed. Variable No Pinning the CLI version
APP_ID GitHub App ID for custom authentication. Variable No Using a custom GitHub App

To add a repository variable:

  1. Go to your repository's Settings > Secrets and variables > Actions > New variable.
  2. Enter the variable name and value.
  3. Save.

For details about repository variables, refer to the GitHub documentation on variables.

Secrets

You can set the following secrets in your repository:

Name Description Required When Required
QWEN_API_KEY Your Qwen API key from DashScope. Yes Required for all workflows that call Qwen.
APP_PRIVATE_KEY Private key for your GitHub App (PEM format). No Using a custom GitHub App.

To add a secret:

  1. Go to your repository's Settings > Secrets and variables >Actions > New repository secret.
  2. Enter the secret name and value.
  3. Save.

For more information, refer to the official GitHub documentation on creating and using encrypted secrets.

Authentication

This action requires authentication to the GitHub API and optionally to Qwen Code services.

GitHub Authentication

You can authenticate with GitHub in two ways:

  1. Default GITHUB_TOKEN: For simpler use cases, the action can use the
    default GITHUB_TOKEN provided by the workflow.
  2. Custom GitHub App (Recommended): For the most secure and flexible
    authentication, we recommend creating a custom GitHub App.

For detailed setup instructions for both Qwen and GitHub authentication, go to the
Authentication documentation.

Extensions

The Qwen Code CLI can be extended with additional functionality through extensions.
These extensions are installed from source from their GitHub repositories.

For detailed instructions on how to set up and configure extensions, go to the
Extensions documentation.

Best Practices

To ensure the security, reliability, and efficiency of your automated workflows, we strongly recommend following our best practices. These guidelines cover key areas such as repository security, workflow configuration, and monitoring.

Key recommendations include:

  • Securing Your Repository: Implementing branch and tag protection, and restricting pull request approvers.
  • Monitoring and Auditing: Regularly reviewing action logs and enabling OpenTelemetry for deeper insights into performance and behavior.

For a comprehensive guide on securing your repository and workflows, please refer to our Best Practices documentation.

Customization

Create a QWEN.md file in the root of your repository to provide
project-specific context and instructions to Qwen Code CLI. This is useful for defining
coding conventions, architectural patterns, or other guidelines the model should
follow for a given repository.

Contributing

Contributions are welcome! Check out the Qwen Code CLI Contributing Guide for more details on how to get started.


Users Integration Jetbrains (`docs/users/integration-jetbrains.md`)

JetBrains IDEs

JetBrains IDEs provide native support for AI coding assistants through the Agent Client Protocol (ACP). This integration allows you to use Qwen Code directly within your JetBrains IDE with real-time code suggestions.

Features

  • Native agent experience: Integrated AI assistant panel within your JetBrains IDE
  • Agent Client Protocol: Full support for ACP enabling advanced IDE interactions
  • Symbol management: #-mention files to add them to the conversation context
  • Conversation history: Access to past conversations within the IDE
  • Reasoning effort: Choose Default, Low, Medium, High, Extra high, or Max from the agent's session options; each provider maps or clamps the requested tier for the active model
  • Context usage: See the current context-window occupancy while Qwen Code works

Requirements

  • JetBrains IDE with ACP support (IntelliJ IDEA, WebStorm, PyCharm, etc.)
  • Qwen Code CLI installed

Installation

Install from ACP Registry (Recommend)

  1. Install Qwen Code CLI:

    bash
    npm install @qwen-code/qwen-code
  2. Open your JetBrains IDE and navigate to AI Chat tool window.

  3. Click Add ACP Agent, then click Install.

    For users using JetBrains AI Assistant and/or other ACP agents, click Install From ACP Registry in Agents List, then install Qwen Code ACP.

  4. The Qwen Code agent should now be available in the AI Assistant panel.

Manual Install (for older version of JetBrains IDEs)

  1. Install Qwen Code CLI:

    bash
    npm install @qwen-code/qwen-code
  2. Open your JetBrains IDE and navigate to AI Chat tool window.

  3. Click the 3-dot menu in the upper-right corner and select Configure ACP Agent and configure Qwen Code with the following settings:

json
{
  "agent_servers": {
    "qwen": {
      "command": "/path/to/qwen",
      "args": ["--acp"],
      "env": {}
    }
  }
}
  1. The Qwen Code agent should now be available in the AI Assistant panel

Troubleshooting

Agent not appearing

  • Run qwen --version in terminal to verify installation
  • Ensure your JetBrains IDE version supports ACP
  • Restart your JetBrains IDE

Qwen Code not responding

  • Check your internet connection
  • Verify CLI works by running qwen in terminal
  • File an issue on GitHub if the problem persists

Users Integration Vscode (`docs/users/integration-vscode.md`)

Visual Studio Code

The VS Code extension (Beta) lets you see Qwen's changes in real-time through a native graphical interface integrated directly into your IDE, making it easier to access and interact with Qwen Code.

Features

  • Native IDE experience: Dedicated Qwen Code sidebar panel accessed via the Qwen icon
  • Auto-accept edits mode: Automatically apply Qwen's changes as they're made
  • File management: @-mention files or attach files and images using the system file picker
  • Conversation history: Access to past conversations
  • Multiple sessions: Run multiple Qwen Code sessions simultaneously

Requirements

  • VS Code 1.96.0 or higher

Installation

Download and install the extension from the Visual Studio Code Extension Marketplace.

Troubleshooting

Extension not installing

  • Ensure you have VS Code 1.96.0 or higher
  • Check that VS Code has permission to install extensions
  • Try installing directly from the Marketplace website

Qwen Code not responding

  • Check your internet connection
  • Start a new conversation to see if the issue persists
  • File an issue on GitHub if the problem continues

Users Integration Zed (`docs/users/integration-zed.md`)

Zed Editor

Zed Editor provides native support for AI coding assistants through the Agent Client Protocol (ACP). This integration allows you to use Qwen Code directly within Zed's interface with real-time code suggestions.

Features

  • Native agent experience: Integrated AI assistant panel within Zed's interface
  • Agent Client Protocol: Full support for ACP enabling advanced IDE interactions
  • File management: @-mention files to add them to the conversation context
  • Conversation history: Access to past conversations within Zed

Requirements

  • Zed Editor (latest version recommended)
  • Qwen Code CLI installed

Installation

Install from ACP Registry (Recommend)

  1. Install Qwen Code CLI:
bash
npm install @qwen-code/qwen-code
  1. Download and install Zed Editor

  2. In Zed, click the settings button in the top right corner, select "Add agent", choose "Install from Registry", find Qwen Code, then click Install.

Manual Install

  1. Install Qwen Code CLI:
bash
npm install @qwen-code/qwen-code
  1. Download and install Zed Editor

  2. In Zed, click the settings button in the top right corner, select "Add agent", choose "Create a custom agent", and add the following configuration:

json
"Qwen Code": {
  "type": "custom",
  "command": "qwen",
  "args": ["--acp"],
  "env": {}
}

Troubleshooting

Agent not appearing

  • Run qwen --version in terminal to verify installation
  • Check that the JSON configuration is valid
  • Restart Zed Editor

Qwen Code not responding

  • Check your internet connection
  • Verify CLI works by running qwen in terminal
  • File an issue on GitHub if the problem persists

Design Fork Subagent Fork Subagent Design (`docs/design/fork-subagent/fork-subagent-design.md`)

Fork Subagent Design

Implicit fork subagent that inherits the parent's full conversation context and shares prompt cache for cost-efficient parallel task execution.

Overview

When the Agent tool is called without subagent_type, it triggers an implicit fork — a background subagent that inherits the parent's conversation history, system prompt, and tool definitions. The fork uses CacheSafeParams to ensure its API requests share the same prefix as the parent's, enabling DashScope prompt cache hits.

Architecture

text
Parent conversation: [SystemPrompt | Tools | Msg1 | Msg2 | ... | MsgN (model)]
                              ↑ identical prefix for all forks ↑

Fork A: [...MsgN | placeholder results | "Research A"]  ← shared cache
Fork B: [...MsgN | placeholder results | "Modify B"]    ← shared cache
Fork C: [...MsgN | placeholder results | "Test C"]      ← shared cache

Key Components

1. FORK_AGENT (`forkSubagent.ts`)

Synthetic agent config, not registered in builtInAgents. Has a fallback systemPrompt but in practice uses the parent's rendered system prompt via generationConfigOverride.

2. CacheSafeParams Integration (`agent.ts` + `forkedQuery.ts`)

text
agent.ts (fork path)
  │
  ├── getCacheSafeParams()          ← parent's generationConfig snapshot
  │     ├── generationConfig        ← systemInstruction + tools + temp/topP
  │     └── history                 ← (not used — we build extraHistory instead)
  │
  ├── forkGenerationConfig          ← passed as generationConfigOverride
  └── forkToolsOverride             ← FunctionDeclaration[] extracted from tools
        │
        ▼
  AgentHeadless.execute(context, signal, {
    extraHistory,                   ← parent conversation history
    generationConfigOverride,       ← parent's exact systemInstruction + tools
    toolsOverride,                  ← parent's exact tool declarations
  })
        │
        ▼
  AgentCore.createChat(context, {
    extraHistory,
    generationConfigOverride,       ← bypasses buildChatSystemPrompt()
  })                                   AND skips getInitialChatHistory()
        │                              (extraHistory already has env context)
        ▼
  new GeminiChat(config, generationConfig, startHistory)
                          ↑ byte-identical to parent's config

3. History Construction (`agent.ts` + `forkSubagent.ts`)

The fork's extraHistory must end with a model message to maintain Gemini API's user/model alternation when agent-headless sends the task_prompt.

Three cases:

Parent history ends with extraHistory construction task_prompt
model (no function calls) [...rawHistory] (unchanged) buildChildMessage(directive)
model (with function calls) [...rawHistory, model(clone), user(responses+directive), model(ack)] 'Begin.'
user (unusual) rawHistory.slice(0, -1) (drop trailing user) buildChildMessage(directive)

4. Recursive Fork Prevention (`forkSubagent.ts`)

isInForkChild() scans conversation history for the <fork-boilerplate> tag. If found, the fork attempt is rejected with an error message.

5. Background Execution (`agent.ts`)

Fork uses void executeSubagent() (fire-and-forget) and returns FORK_PLACEHOLDER_RESULT immediately to the parent. Errors in the background task are caught, logged, and reflected in the display state.

Data Flow

text
1. Model calls Agent tool (no subagent_type)
2. agent.ts: import forkSubagent.js
3. agent.ts: getCacheSafeParams() → forkGenerationConfig + forkToolsOverride
4. agent.ts: build extraHistory from parent's getHistory(true)
5. agent.ts: build forkTaskPrompt (directive or 'Begin.')
6. agent.ts: createAgentHeadless(FORK_AGENT, ...)
7. agent.ts: void executeSubagent() — background
8. agent.ts: return FORK_PLACEHOLDER_RESULT to parent immediately
9. Background:
   a. AgentHeadless.execute(context, signal, {extraHistory, generationConfigOverride, toolsOverride})
   b. AgentCore.createChat() — uses parent's generationConfig (cache-shared)
   c. runReasoningLoop() — uses parent's tool declarations
   d. Fork executes tools, produces result
   e. updateDisplay() with final status

Graceful Degradation

If getCacheSafeParams() returns null (first turn, no history yet), the fork falls back to:

  • FORK_AGENT.systemPrompt for system instruction
  • prepareTools() for tool declarations

This ensures the fork always works, even without cache sharing.

Files

File Role
packages/core/src/agents/runtime/forkSubagent.ts FORK_AGENT config, buildForkedMessages(), isInForkChild(), buildChildMessage()
packages/core/src/tools/agent.ts Fork path: CacheSafeParams retrieval, extraHistory construction, background execution
packages/core/src/agents/runtime/agent-headless.ts execute() options: generationConfigOverride, toolsOverride
packages/core/src/agents/runtime/agent-core.ts CreateChatOptions.generationConfigOverride
packages/core/src/followup/forkedQuery.ts CacheSafeParams infrastructure (existing, no changes)

Design Compact Mode Compact Mode Design (`docs/design/compact-mode/compact-mode-design.md`)

Compact Mode Design: Competitive Analysis & Optimization

Historical design. The current Web Shell behavior is documented in
Web Shell compact mode and tool progress.

Ctrl+O compact/verbose mode toggle — competitive analysis with Claude Code, current implementation review, and optimization recommendations.

User documentation: Settings — ui.compactMode.

1. Executive Summary

Qwen Code and Claude Code both provide a Ctrl+O shortcut for toggling between compact and detailed tool output views, but the design philosophy, default state, and interaction model differ fundamentally. This document provides a deep source-level comparison, identifies UX gaps, and proposes optimizations for Qwen Code.

Dimension Claude Code Qwen Code
Default mode Compact (verbose=false) Verbose (compactMode=false)
Toggle semantics Temporary peek at details Persistent preference switch
Persistence Session-only, resets on restart Persisted to settings.json
Scope Global screen switch (prompt ↔ transcript) Per-component rendering toggle
Frozen snapshot None (no concept) None (removed)
Per-tool expand hint Yes ("ctrl+o to expand") Yes ("Press Ctrl+O to show full tool output")

2. Claude Code Implementation Analysis

2.1 Architecture

Claude Code uses a screen-based approach rather than a component-level rendering toggle:

text
┌──────────────────────────────────┐
│         AppState (Zustand)       │
│  verbose: boolean (default: false)│
│  screen: 'prompt' | 'transcript' │
└──────────┬───────────────────────┘
           │
     ┌─────┴──────┐
     │  Ctrl+O    │  toggles screen mode
     │  Handler    │  NOT a rendering flag
     └─────┬──────┘
           │
     ┌─────▼──────────────┐
     │    REPL.tsx         │
     │  screen='prompt'  → compact view (default)
     │  screen='transcript'→ detailed view
     └────────────────────┘

2.2 Key Source Files

Component File Key Logic
Toggle handler src/hooks/useGlobalKeybindings.tsx:90-132 Switches screen between 'prompt' and 'transcript'
Keybinding src/keybindings/defaultBindings.ts:44 app:toggleTranscript
State definition src/state/AppStateStore.ts:472 verbose: false (session-only)
Expand hint src/components/CtrlOToExpand.tsx:29-46 Per-tool "(ctrl+o to expand)" text
Message filter src/components/Messages.tsx:93-151 filterForBriefTool() for compact view
Permission src/components/permissions/PermissionRequest.tsx Rendered in overlay layer, never hidden

2.3 Design Decisions

  1. Compact is the default. Users see a clean interface out of the box; detail is opt-in.
  2. Session-scoped. verbose resets to false on every new session — Claude Code assumes users generally prefer the compact view and only need details temporarily.
  3. Screen-level toggle. Ctrl+O doesn't change how components render; it switches the entire display between a "prompt" screen (compact) and a "transcript" screen (detailed).
  4. No frozen snapshot. There is no snapshot freezing concept. When toggling, the display updates immediately with current state.
  5. Permission dialogs are separate. Tool approvals are rendered in a dedicated overlay layer that is never affected by the verbose/compact toggle.
  6. Per-tool hint. CtrlOToExpand component shows a contextual hint on individual tools when they produce large output, suppressed in sub-agents.

2.4 User Flow

text
Session start → compact mode (default)
     │
     ├─ Tool outputs are summarized in a single line
     ├─ Large tool output shows "(ctrl+o to expand)" hint
     │
     ├─ User presses Ctrl+O
     │     └─→ Screen switches to transcript (detailed view)
     │         └─ User sees all tool output, thinking, etc.
     │
     ├─ User presses Ctrl+O again
     │     └─→ Screen switches back to prompt (compact)
     │
     └─ Session ends → verbose resets to false

3. Qwen Code Implementation Analysis

3.1 Architecture

Qwen Code uses a component-level rendering flag that each UI component reads from context:

text
┌─────────────────────────────────────┐
│      CompactModeContext             │
│  compactMode: boolean (default: false)│
│  setCompactMode: (v) => void        │
└──────────┬──────────────────────────┘
           │
     ┌─────┴──────┐
     │  Ctrl+O    │  toggles compactMode
     │  Handler    │  persists to settings
     └─────┬──────┘
           │
     ┌─────▼──────────────────┐
     │  Each component reads  │
     │  compactMode and       │
     │  decides how to render │
     └────────────────────────┘
           │
     ┌─────▼──────────────────────────────┐
     │  ToolGroupMessage                   │
     │    showCompact = compactMode        │
     │      && !hasConfirmingTool          │
     │      && !hasErrorTool               │
     │      && !isEmbeddedShellFocused     │
     │      && !isUserInitiated            │
     └────────────────────────────────────┘

3.2 Key Source Files

Component File Key Logic
Toggle handler AppContainer.tsx:1684-1690 Toggles compactMode, persists to settings
Context CompactModeContext.tsx compactMode, setCompactMode
Tool group ToolGroupMessage.tsx:105-110 showCompact with 4 force-expand conditions
Tool message ToolMessage.tsx:346-350 Hides displayRenderer in compact mode
Compact display CompactToolGroupDisplay.tsx:49-108 Single-line summary with status + hint
Confirmation ToolConfirmationMessage.tsx:113-147 Simplified 3-option compact approval
Tips Tips.tsx:14-29 Startup tip rotation includes compact mode hint
Settings sync SettingsDialog.tsx:189-193 Syncs with CompactModeContext + refreshStatic
MainContent MainContent.tsx:60-76 Renders live pendingHistoryItems
Thinking HistoryItemDisplay.tsx:123-133 Hides gemini_thought in compact mode

3.3 Design Decisions

  1. Verbose is the default. Users see all tool output and thinking by default.
  2. Persistent preference. compactMode is saved to settings.json and survives across sessions.
  3. Component-level rendering. Each component reads compactMode from context and adjusts its own rendering.
  4. Force-expand protection. Four conditions override compact mode to ensure critical UI elements are always visible (confirmations, errors, shell, user-initiated).
  5. No snapshot freezing. The toggle always shows live output — no frozen snapshots.
  6. Settings dialog sync. Toggling compact mode from Settings updates React state immediately via setCompactMode.
  7. Non-intrusive discoverability. Compact mode is introduced via the startup Tips rotation rather than a persistent footer indicator, avoiding UI clutter.

3.4 User Flow

text
Session start → verbose mode (default)
     │
     ├─ All tool outputs, thinking, details visible
     │
     ├─ User presses Ctrl+O (or toggles in Settings)
     │     └─→ compactMode = true, persisted
     │         ├─ Tool groups show single-line summary
     │         ├─ Thinking/thought content hidden
     │         └─ Confirmations, errors, shell still expanded
     │
     ├─ User presses Ctrl+O again
     │     └─→ compactMode = false, persisted
     │         └─ All details visible again
     │
     └─ Next session → same mode as last session

4. Key Differences Deep Dive

4.1 Default Mode Philosophy

Aspect Claude Code (compact default) Qwen Code (verbose default)
First impression Clean, minimal — professional feel Information-rich — full transparency
Learning curve User must learn Ctrl+O to see details User can immediately see everything
Target audience Experienced users who trust the tool Users who want to understand what's happening
Information overload Avoided by default Possible for new users
Discoverability Per-tool "(ctrl+o to expand)" hints Startup Tips rotation + ? shortcuts + /help

Analysis: Claude Code's compact default works because its user base is generally experienced developers who trust the tool and don't need to see every tool invocation. Qwen Code's verbose default is appropriate for its earlier stage where building user trust through transparency is important.

4.2 Persistence Model

Aspect Claude Code Qwen Code
Persisted? No — session-only Yes — to settings.json
Rationale Verbose is temporary peek Mode is user preference
Restart behavior Always starts compact Starts with last-used mode

Analysis: Claude Code treats detail viewing as a momentary need — you look, then go back. Qwen Code treats it as a stable preference — some users always want details, others always want compact. Both are valid; Qwen Code's approach is more flexible.

4.3 Confirmation Protection

Aspect Claude Code Qwen Code
Mechanism Overlay/modal layer (structurally separate) Force-expand conditions in showCompact
Coverage Complete — approvals can never be hidden Complete — 4 conditions cover all interactive states
Compact confirmation UI N/A (overlay is always full) Simplified 3-option RadioButtonSelect

Analysis: Claude Code's architectural separation (overlay layer) is more robust. Qwen Code's force-expand approach is effective but requires each new interactive state to be explicitly added to the condition list.

4.4 Rendering Approach

Aspect Claude Code Qwen Code
Toggle scope Screen-level (prompt ↔ transcript) Component-level (each component decides)
Granularity All-or-nothing Fine-grained per component
Flexibility Low — global switch High — components can override
Consistency Guaranteed Depends on each component's implementation

Analysis: Qwen Code's component-level approach is more flexible (e.g., force-expand for specific conditions) but requires more discipline to maintain consistency. Claude Code's screen-level approach is simpler and guarantees consistent behavior.

5. Optimization Recommendations

5.1 [P0] Keep Verbose as Default — No Change Needed

Qwen Code's verbose default is the right choice for its current stage. Users who are new to the tool need transparency to build trust. As the product matures, consider making compact the default (like Claude Code).

5.2 [P1] Per-Tool Expansion for Large Outputs

Claude Code shows "(ctrl+o to expand)" on individual tools that produce large output. Qwen Code currently only has a global toggle. Consider:

  • When a single tool produces output exceeding N lines, show a per-tool "expand" hint in compact mode.
  • Scope: future enhancement, not current priority.

5.3 [P2] Consider Session-Scoped Override

Some users may want compact mode as their default but occasionally need verbose for a specific session. Consider supporting both:

  • settings.json → persistent default (current behavior)
  • Ctrl+O during session → temporary override for current session only (Claude Code behavior)
  • On session restart → revert to settings.json value

This gives users the best of both worlds. Implementation would require separating "settings default" from "session override" state.

5.4 [P2] Structural Separation for Confirmations

Currently, confirmation protection relies on showCompact conditions in ToolGroupMessage. Consider a more robust approach:

  • Render confirmations in a separate layer (like Claude Code's overlay approach).
  • This would make it architecturally impossible for compact mode to affect confirmations.
  • Lower priority since the current force-expand approach works correctly.

6. Current Implementation Status

After the feat/compact-mode-optimization branch changes:

Feature Status Notes
Startup Tips hint Done Compact mode tip in Tips rotation (non-intrusive)
Ctrl+O in keyboard shortcuts (?) Done Added to KeyboardShortcuts component
Ctrl+O in /help Done Added to Help component
Settings dialog sync Done Syncs compactMode with CompactModeContext
No snapshot freezing Done Toggle always shows live output
Confirmation protection Done Force-expand + WaitingForConfirmation guard
Shell protection Done !isEmbeddedShellFocused force-expand
Error protection Done !hasErrorTool force-expand
User docs updated Done settings.md, keyboard-shortcuts.md

7. File Reference

Qwen Code

File Purpose
packages/cli/src/ui/AppContainer.tsx Toggle handler, state initialization, context provider
packages/cli/src/ui/contexts/CompactModeContext.tsx Context definition
packages/cli/src/ui/components/messages/ToolGroupMessage.tsx Force-expand logic
packages/cli/src/ui/components/messages/ToolMessage.tsx Per-tool output hiding
packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx Compact view rendering
packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx Compact confirmation UI
packages/cli/src/ui/components/MainContent.tsx Pending history items rendering
packages/cli/src/ui/components/Tips.tsx Startup tip with compact mode hint
packages/cli/src/ui/components/Help.tsx /help shortcut entry
packages/cli/src/ui/components/KeyboardShortcuts.tsx ? shortcut entry
packages/cli/src/ui/components/SettingsDialog.tsx Settings sync
packages/cli/src/ui/components/HistoryItemDisplay.tsx Thinking content hiding
packages/cli/src/config/settingsSchema.ts Setting definition
packages/cli/src/config/keyBindings.ts Ctrl+O binding

Claude Code (Reference)

File Purpose
src/hooks/useGlobalKeybindings.tsx Toggle handler
src/state/AppStateStore.ts State definition (verbose: false)
src/components/CtrlOToExpand.tsx Per-tool expand hint
src/components/Messages.tsx Brief message filter
src/screens/REPL.tsx Screen-level mode switching
src/components/permissions/PermissionRequest.tsx Overlay-based confirmation

Design Channels Channels Design (`docs/design/channels/channels-design.md`)

Channels Design

External messaging integrations for Qwen Code — interact with an agent from Telegram, WeChat, and more.

User documentation: Channels Overview.

Overview

A channel connects an external messaging platform to a Qwen Code agent. Configured in settings.json, managed via qwen channel subcommands, multi-user (each user gets an isolated ACP session).

Architecture

text
/* Detailed source-code truncated for AI context efficiency. */

Platform Adapter — connects to external API, translates messages to/from Envelopes. ACP Bridge — spawns qwen-code --acp, manages sessions, emits textChunk/toolCall/disconnected events. Session Router — maps senders to ACP sessions via namespaced keys (<channel>:<sender>). Sender Gate / Group Gate — access control (allowlist / pairing / open) and mention gating. Channel Base — abstract base with Template Method pattern: plugins override connect, sendMessage, disconnect. Channel Registry — Map<string, ChannelPlugin> with collision detection.

Envelope

Normalized message format all platforms convert to:

  • Identity: senderId, senderName, chatId, channelName
  • Content: text, optional imageBase64/imageMimeType, optional referencedText
  • Context: isGroup, isMentioned, isReplyToBot, optional threadId

Plugin responsibilities: senderId must be stable/unique; chatId must distinguish DMs from groups; boolean flags must be accurate for gate logic; @mentions stripped from text.

Message Flow

text
Inbound:  User message → Adapter → GroupGate → SenderGate → Slash commands → SessionRouter → AcpBridge → Agent
Outbound: Agent response → AcpBridge → SessionRouter → Adapter → User

Slash commands (/clear, /help, /status) are handled in ChannelBase before reaching the agent.

Sessions

One qwen-code --acp process with multiple ACP sessions. Scope per channel: user (default), thread, or single. Routing keys namespaced as <channelName>:<key>.

Error Handling

  • Connection failures — logged; service continues if at least one channel connects
  • Bridge crashes — exponential backoff (max 3 retries), setBridge() on all channels, session restore
  • Session serialization — per-session promise chains prevent concurrent prompt collisions

Plugin System

The architecture is extensible — new adapters (including third-party) can be added without modifying core. Built-in channels use the same plugin interface (dogfooding).

Plugin Contract

A ChannelPlugin declares channelType, displayName, requiredConfigFields, and a createChannel() factory. Plugins implement three methods:

Method Responsibility
connect() Connect to platform and register message handlers
sendMessage(chatId, text) Format and deliver agent response
disconnect() Clean up on shutdown

On inbound messages, plugins build an Envelope and call this.handleInbound(envelope) — the base class handles the rest: access control, group gating, pairing, session routing, prompt serialization, slash commands, instructions injection, reply context, and crash recovery.

Extension Points

  • Custom slash commands via registerCommand()
  • Working indicators by wrapping handleInbound() with typing/reaction display
  • Tool call hooks via onToolCall()
  • Media handling by attaching to Envelope before handleInbound()

Discovery & Loading

External plugins are extensions managed by ExtensionManager, declared in qwen-extension.json:

json
{
  "name": "my-channel-extension",
  "version": "1.0.0",
  "channels": {
    "my-platform": {
      "entry": "dist/index.js",
      "displayName": "My Platform Channel"
    }
  }
}

Loading sequence at qwen channel start: load settings → register built-ins → scan extensions → dynamic import + validate → register (reject collisions) → validate config → createChannel() → connect().

Plugins run in-process (no sandbox), same trust model as npm dependencies.

Configuration

jsonc
{
  "channels": {
    "my-telegram": {
      "type": "telegram",
      "token": "$TELEGRAM_BOT_TOKEN", // env var reference
      "senderPolicy": "allowlist", // allowlist | pairing | open
      "allowedUsers": ["123456"],
      "sessionScope": "user", // user | thread | single
      "cwd": "/path/to/project",
      "model": "qwen3.5-plus",
      "instructions": "Keep responses short.",
      "groupPolicy": "disabled", // disabled | allowlist | open
      "dmPolicy": "open", // open | disabled
      "groups": { "*": { "requireMention": true } },
    },
  },
}

Auth is plugin-specific: static token (Telegram), app credentials (DingTalk), QR code login (WeChat), proxy token (TMCP).

CLI Commands

bash
# Channels
qwen channel start [name]                     # start all or one channel
qwen channel stop                             # stop running service
qwen channel status                           # show channels, sessions, uptime
qwen channel pairing list <ch>                # pending pairing requests
qwen channel pairing approve <ch> <code>      # approve a request

# Extensions
qwen extensions install <path-or-package>     # install
qwen extensions link <local-path>             # symlink for dev
qwen extensions list                          # show installed
qwen extensions remove <name>                 # uninstall

Package Structure

text
packages/channels/
├── base/                    # @qwen-code/channel-base
│   └── src/
│       ├── AcpBridge.ts     # ACP process lifecycle, session management
│       ├── SessionRouter.ts # sender ↔ session mapping, persistence
│       ├── SenderGate.ts    # allowlist / pairing / open
│       ├── GroupGate.ts     # group chat policy + mention gating
│       ├── PairingStore.ts  # pairing code generation + approval
│       ├── ChannelBase.ts   # abstract base: routing, slash commands
│       └── types.ts         # Envelope, ChannelConfig, etc.
├── telegram/                # @qwen-code/channel-telegram
├── weixin/                  # @qwen-code/channel-weixin
└── dingtalk/                # @qwen-code/channel-dingtalk

Future Work

Safety & Group Chat

  • Per-group tool restrictions — tools/toolsBySender deny/allow lists per group
  • Group context history — ring buffer of recent skipped messages, prepended on @mention
  • Regex mention patterns — fallback mentionPatterns for unreliable @mention metadata
  • Per-group instructions — instructions field on GroupConfig for per-group personas
  • /activation command — runtime toggle for requireMention, persisted to disk

Operational Tooling

  • qwen channel doctor — config validation, env vars, bot tokens, network checks
  • qwen channel status --probe — real connectivity checks per channel

Platform Expansion

  • Discord — Bot API + Gateway, servers/channels/DMs/threads
  • Slack — Bolt SDK, Socket Mode, workspaces/channels/DMs/threads

Multi-Agent

  • Multi-agent routing — multiple agents with bindings per channel/group/user
  • Broadcast groups — multiple agents respond to the same message

Plugin Ecosystem

  • Community plugin template — create-qwen-channel scaffolding tool
  • Plugin registry/discovery — qwen extensions search, version compatibility

Design Auto Memory Memory System (`docs/design/auto-memory/memory-system.md`)

Memory 记忆管理系统

本文介绍 Qwen Code 中 Managed Auto-Memory(托管自动记忆)的记忆管理机制、触发时机和实现细节。


目录

  1. 概述
  2. 存储结构
  3. 记忆类型
  4. 记忆条目格式
  5. 核心生命周期
  6. Extract — 提取
  7. Dream — 整合
  8. Recall — 召回
  9. Forget — 遗忘
  10. 索引重建
  11. 遥测埋点

概述

Managed Auto-Memory 是一套在 AI 会话过程中自动积累、整合和检索用户相关知识的持久化记忆系统。它通过四个核心操作维护记忆的生命周期:

操作 英文 触发方式 作用
提取 Extract 自动(每轮对话后) 从对话记录中提炼新知识写入记忆文件
整合 Dream 自动(周期性后台任务) 对记忆文件去重、合并,保持整洁
召回 Recall 自动(每轮对话前) 检索与当前请求相关的记忆注入到系统提示
遗忘 Forget 手动(用户命令 /forget) 精确删除指定的记忆条目

存储结构

目录布局

text
~/.qwen/                                      ← 全局基础目录(默认)
└── projects/
    └── <sanitized-git-root>/                 ← 项目标识(基于 Git 根路径)
        ├── meta.json                         ← 元数据(提取/整合时间戳、状态)
        ├── extract-cursor.json               ← 提取游标(已处理的对话偏移量)
        ├── consolidation.lock                ← Dream 进程互斥锁
        └── memory/                           ← 记忆主目录
            ├── MEMORY.md                     ← 索引文件(自动生成,汇总所有条目)
            ├── user.md                       ← 用户偏好记忆(示例)
            ├── feedback.md                   ← 反馈规范记忆(示例)
            ├── project/
            │   └── milestone.md              ← 项目记忆(支持子目录)
            └── reference/
                └── grafana.md                ← 外部资源记忆

环境变量覆盖:

  • QWEN_CODE_MEMORY_BASE_DIR:替换全局基础目录
  • QWEN_CODE_MEMORY_LOCAL=1:改用项目内路径 .qwen/memory/
  • QWEN_CODE_MEMORY_PROJECT_SCOPE=workspace:按精确 workspace 目录分区项目记忆。qwen serve 未显式设置或值为空白时会注入 workspace;standalone CLI 仍默认按 Git 根目录共享。非空取值会做 trim / 小写归一,无法识别的值会告警一次并回退到 git-root。
    • 团队记忆(getTeamAutoMemoryRoot)仍按 Git 根目录分区:同一 checkout 内的嵌套 workspace 仍共享团队记忆——团队记忆本就应跨 workspace 共享,不随本开关改变。
    • 切换 scope 不做迁移:切到 workspace 后,此前写在 git-root key 下的项目记忆会“失联”(切回去则看不到 workspace key 下新写的内容)。
    • 目录 key 由 sanitizeCwd 生成(非字母数字字符替换为 -),仅在标点上不同的兄弟目录(如 feature_1 与 feature-1)会映射到同一记忆目录;workspace 分区下这类命名会共享记忆,命名时需避开。
    • 待处理任务上限(MAX_PENDING = 16)是每个 lane 的,N 个 workspace 即允许 16·N 个并发记忆任务,目前没有守护进程级总量上限。

关键文件说明

文件 说明
meta.json 记录最后一次 Extract / Dream 的时间、会话 ID、涉及的记忆类型、执行状态
extract-cursor.json 记录当前会话已处理到对话历史的哪个偏移量,避免重复提取
consolidation.lock Dream 运行时的文件锁,内容为持有者 PID,超过 1 小时自动失效
MEMORY.md 所有主题文件的索引,每次 Extract/Dream 后重建,格式为 Markdown 列表

记忆类型

系统支持四种内置记忆类型,每种对应不同的信息维度:

类型 存储内容 何时写入 何时读取
user 用户的角色、技能背景、工作习惯 了解到用户角色/偏好/知识背景时 回答需要根据用户背景定制时
feedback 用户对 AI 行为的指导:避免什么、继续什么 用户纠正 AI 或确认某种非显而易见的做法时 影响 AI 行为方式时
project 项目进展、目标、决策、截止日期、Bug 追踪 了解到谁在做什么、为什么、截止何时时 帮助 AI 理解工作背景和动机时
reference 外部系统资源指针(Dashboard、工单系统、Slack 频道等) 得知某种外部资源及其用途时 用户提及外部系统或相关信息时

不应该存入记忆的内容:代码模式/约定、Git 历史、调试方案、临时任务状态、已在 QWEN.md/AGENTS.md 中记录的内容。


记忆条目格式

每个主题文件使用 YAML frontmatter + Markdown body 格式:

markdown
---
name: 记忆名称
description: 一句话描述(用于判断召回相关性,要具体)
type: user|feedback|project|reference
---

记忆主体内容(summary 行)

Why: 背后原因(让 AI 能理解边界情况而不是盲目遵守规则)
How to apply: 适用场景和使用方式

对于 feedback 和 project 类型,强烈建议填写 Why 和 How to apply,使记忆在边界情况下仍能正确应用。


核心生命周期

text
/* Detailed source-code truncated for AI context efficiency. */

Extract — 提取

触发时机

每次 AI 完成一轮响应后,由 scheduleAutoMemoryExtract 自动触发(后台非阻塞)。

调度逻辑(`extractScheduler.ts`)

mermaid
flowchart TD
    A[scheduleAutoMemoryExtract 被调用] --> B{本轮历史记录中\n是否有写记忆文件的工具调用?}
    B -- 是 --> C[登记 skipped 任务\n原因: memory_tool]
    B -- 否 --> D{isExtractRunning?}
    D -- 是 --> E{是否已有 queued 请求?}
    E -- 是 --> F[更新 queued 请求的\nhistory 参数]
    E -- 否 --> G[注册 pending 任务\n放入 queue]
    D -- 否 --> H[注册 running 任务\n调用 runTask]
    H --> I[markExtractRunning\nsetCurrentTaskId]
    I --> J[runAutoMemoryExtract]
    J --> K[任务完成]
    K --> L[clearExtractRunning\n检查 queue → startQueuedIfNeeded]
    F --> M[返回 skipped: queued]
    G --> M
    C --> N[返回 skipped: memory_tool]

跳过原因说明:

原因 含义
memory_tool 本轮主 Agent 已直接写了记忆文件,跳过以避免冲突
already_running 提取正在进行且无法入队
queued 已有提取在运行,本次请求已入队

核心提取流程(`extract.ts`)

mermaid
flowchart TD
    A[runAutoMemoryExtract] --> B[ensureAutoMemoryScaffold\n初始化目录和文件]
    B --> C[readExtractCursor\n读取上次处理到的位置]
    C --> D[history.slice startOffset\n只取未处理的消息切片]
    D --> E{slice 有新的 user 消息?}
    E -- 否 --> F[更新 cursor\n返回无 patches 结果]
    E -- 是 --> G[runAutoMemoryExtractionByAgent\n调用 forked agent 提取]
    G --> H{有 touched topics?}
    H -- 是 --> I[bumpMetadata\n更新 meta.json]
    I --> J[rebuildManagedAutoMemoryIndex\n重建 MEMORY.md]
    J --> K[writeExtractCursor\n记录最新 offset = history.length]
    H -- 否 --> K
    K --> L[返回 AutoMemoryExtractResult]

注意: isUnderMemoryPressure 门控位于 MemoryManager.runExtract() 中,不在本流程内。当 monitor 报告 hard/critical 压力时,MemoryManager 会跳过 extract 调用,不推进 cursor。

提取游标(Cursor):

  • 字段:{ sessionId, processedOffset, updatedAt }
  • 提取前先通过 readExtractCursor 读取当前进度,再用 history.slice(processedOffset) 仅处理未读部分
  • 每次提取后更新 processedOffset 为当前历史长度(params.history.length)
  • 跨会话时(sessionId 变化)从偏移量 0 重新开始
  • 注意:不再通过 buildTranscriptMessages / loadUnprocessedTranscriptSlice 构建转录文本——hasNewUserMessages 通过 history.slice(startOffset).some(m => m.role === 'user' && partToString(m.parts).trim().length > 0) 判断,仅在未读切片上做轻量字符串化,全量历史不再处理

Patch 过滤规则:

  • 摘要长度 < 12 字符 → 丢弃
  • 摘要以 ? 结尾 → 丢弃(疑问句)
  • 包含临时性关键词(today/now/currently/temporary 等)→ 丢弃
  • 相同 topic:summary 组合 → 去重

Dream — 整合

触发时机

每次 AI 完成一轮响应后,由 scheduleManagedAutoMemoryDream 自动触发(后台非阻塞)。但受多个门控条件保护,大多数情况下会被跳过。

调度门控(`dreamScheduler.ts`)

mermaid
flowchart TD
    A[scheduleManagedAutoMemoryDream 被调用] --> B{Dream 功能是否启用?}
    B -- 否 --> C[跳过: disabled]
    B -- 是 --> D[ensureAutoMemoryScaffold\n读取 lastDreamSessionId]
    D --> E{当前 sessionId\n== lastDreamSessionId?}
    E -- 是 --> F[跳过: same_session]
    E -- 否 --> G{elapsedHours ≥ 24h\n或从未 dream?}
    G -- 否 --> H[跳过: min_hours]
    G -- 是 --> I{距上次 session scan\n< 10 分钟?}
    I -- 是 --> J[跳过: min_sessions\n等待下次扫描窗口]
    I -- 否 --> K[扫描 chats/*.jsonl mtime\n统计上次 Dream 后的新会话数]
    K --> L{新会话数 ≥ 5?}
    L -- 否 --> M[跳过: min_sessions]
    L -- 是 --> N{lockExists?\nPID 检查 + 过期检查}
    N -- 是 --> O[跳过: locked]
    N -- 否 --> P{dedupeKey 是否已有\n同项目 Dream 任务?}
    P -- 是 --> Q[跳过: running\n返回已有 taskId]
    P -- 否 --> R[调度后台任务\nBgTaskScheduler]
    R --> S[acquireDreamLock\n写入 PID 到 consolidation.lock]
    S --> T[runManagedAutoMemoryDream]
    T --> U[更新 meta.json\n释放锁]

门控参数:

参数 默认值 说明
minHoursBetweenDreams 24 小时 两次 Dream 之间的最小时间间隔
minSessionsBetweenDreams 5 个会话 触发 Dream 所需的最小新会话数
SESSION_SCAN_INTERVAL_MS 10 分钟 会话文件扫描的节流间隔
DREAM_LOCK_STALE_MS 1 小时 lock 文件被视为过期的时间阈值

锁机制:

  • lock 文件位于 /consolidation.lock
  • 内容为持有进程的 PID
  • 检查时:若 PID 进程已不存在(kill(pid, 0) 失败)或 lock 超过 1 小时 → 视为过期,自动清除

整合执行流程(`dream.ts`)

mermaid
flowchart TD
    A[runManagedAutoMemoryDream] --> B{是否配置了 Config?}
    B -- 是 --> C[Agent 路径\nplanManagedAutoMemoryDreamByAgent]
    C --> D{Agent 是否修改了文件?}
    D -- 是 --> E[从文件路径推断 touched topics]
    E --> F[bumpMetadata\n重建 MEMORY.md 索引]
    F --> G[updateDreamMetadataResult]
    G --> H[记录遥测事件]
    H --> I[返回结果]
    B -- 否 --> J[机械去重路径]
    C -- 抛出异常 --> J
    D -- 否 --> J

    J --> K[scanAutoMemoryTopicDocuments\n读取所有主题文件]
    K --> L[对每个文件执行 buildDreamedBody]
    L --> M[解析 entries → 按 summary 去重\n按字母升序排序 → 重新渲染]
    M --> N{body 有变化?}
    N -- 是 --> O[写回文件]
    O --> P[记录 touched topic]
    N --> Q[检查跨文件重复\ndedupeKey = type:summary]
    Q --> R{发现重复文件?}
    R -- 是 --> S[合并 entries 到 canonical 文件\n删除重复文件]
    S --> P
    R -- 否 --> T{有 touched topics?}
    P --> T
    T -- 是 --> U[bumpMetadata\n重建 MEMORY.md 索引]
    U --> V[updateDreamMetadataResult\n记录遥测 → 返回结果]
    T -- 否 --> V

机械去重逻辑:

  1. 对每个主题文件内部:按 summary.toLowerCase() 去重,合并 why/howToApply 字段
  2. 按 summary 字母顺序重新排序
  3. 跨文件:相同 type:summary 的条目合并到最先发现的文件,删除重复文件

Recall — 召回

触发时机

每轮 AI 处理用户请求之前,由 resolveRelevantAutoMemoryPromptForQuery 自动触发,将相关记忆注入系统提示词。

召回流程(`recall.ts`)

mermaid
flowchart TD
    A[resolveRelevantAutoMemoryPromptForQuery] --> B[scanAllAutoMemoryTopicDocuments +\nscanAllUserAutoMemoryTopicDocuments\n扫描项目级与用户级全部主题文件]
    B --> C[filterExcludedAutoMemoryDocuments\n合并作用域并过滤排除列表中的文件]
    C --> D{query 为空\n或 docs 为空\n或 limit <= 0?}
    D -- 是 --> E[返回空 prompt\nstrategy: none]
    D -- 否 --> F{是否配置了 Config?}
    F -- 是 --> G[selectModelCandidateDocuments\n词法候选 + recent reserve\n最多 200 篇且交错排列]
    G --> H[selectRelevantAutoMemoryDocumentsByModel\n构建最多 25 KB manifest\n发起 side query 请求模型选择]
    H --> I{模型返回结果?}
    I -- 有文档 --> J[strategy: model]
    I -- 无文档 --> K[strategy: none\n仍然返回空]
    H -- "失败/异常" --> L[复用已计算的启发式排序]
    F -- 否 --> M[tokenize query\nNFKC + 非 CJK 字母整串 + CJK bigram\n最多 64 个 token]
    M --> N[scoreDocument 打分\ntitle +4 / description +3 / body +1\n词法命中后类型加成最多 +2]
    N --> O[过滤 score=0 的文档\n按分数降序、mtime 降序、输入顺序排列\n取 Top 5]
    L --> O
    O --> P{有得分文档?}
    P -- 是 --> Q[strategy: heuristic]
    P -- 否 --> K
    J --> R[buildRelevantAutoMemoryPrompt\n构建 Relevant Memory 区块]
    Q --> R
    R --> S[返回注入主系统提示的 prompt 片段]

关于 200 上限:这是换了截断依据,不是抬高了天花板。 旧路径按 scope 各自
保留最近 200 篇,截断发生在看 query 之前;新路径先扫全量,再按词法相关性 +
recency reserve 选出最多 200 篇候选。因此效果分三档:总量 ≤200 时两者都不按
数量丢弃(但新增了 25 KB manifest 上限,长 description 场景可能被截);总量
在 200–400 且单个 scope 不超 200 时,旧路径会把全部(最多 400 篇)送给
Selector,新路径最多 200 篇,且实测中 25 KB 的 manifest 预算会先于数量上限
生效——150+150 篇的实测里旧路径送 300 行、新路径只送 94 行,候选变少的幅度
比数量上限暗示的更大
;只有单个 scope 超过 200 时,
才是这次真正要解决的场景——旧的 recency 上限会让老而相关的文档永久不可见。
详见 docs/design/2026-08-09-bounded-memory-recall-candidates.md。

评分规则(启发式):

条件 加分
query token 出现在 title +4(每个 token)
query token 出现在 description +3(每个 token)
query token 出现在 body 前 1200 字符 +1(每个 token)
至少一次词法命中后,token 是类型特征关键词 +1,整篇文档最多 +2

Tokenize 规则:NFKC 归一化并转小写后,Han/Hiragana/Katakana/Hangul 连续片段
按 code point bigram 切分(单字不产生 token);其余至少 3 个字母、组合符或数字的
连续片段整串保留。后者基于 \p{L} 而非 [a-z0-9],因此西里尔、希腊、阿拉伯和
带重音拉丁文都能产生 token。CJK 是逐字符排除的,不能只依赖正则分支顺序——
\p{L} 也匹配 Han,否则 abc漢字 会被并成一个 token。Thai/Khmer/Lao 这类
无分词符又不在 CJK 集合内的文字,会整段变成一个 token:比之前完全没有 token 强,
但不是分词。

同分排序:按 mtime 降序,再按输入顺序(稳定排序),不按 type。

每种类型的特征关键词:

  • user:user, preference, background, role, terse
  • feedback:feedback, rule, avoid, style, summary
  • project:project, goal, incident, deadline, release
  • reference:reference, dashboard, ticket, docs, link

Prompt 构建规则:

  • 单次注入最多 5 篇文档(MAX_RELEVANT_DOCS)
  • 每篇文档 body 截断至 1200 字符(MAX_DOC_BODY_CHARS)
  • 超出截断时追加提示:"NOTE: Relevant memory truncated for prompt budget."
  • 包含文档的新鲜度信息(基于文件 mtime)

MAX_RELEVANT_DOCS 限制的是单次注入,不是单轮总量。 Fast 阶段投递 2 篇、
ToolResult 阶段又投递 5 篇全新文档时,本轮进入模型的是 7 篇——去重只消除
重复,不压缩总和。这是放弃跨阶段预算核算的有意结果(见
2026-08-08-native-memory-recall-reliability.md):两次 Prompt 各自有界,
每篇 body 仍截断到 1200 字符,Fast 上限为 2,因此最坏情况有界且不大,只是不等于 5。

投递时机(Delivery)

"选中了 Memory" 不等于 "主模型看到了 Memory"。Recall 在 UserQuery 到达时异步启动,
投递发生在两个时机:

mermaid
flowchart TD
    A[UserQuery 到达\n启动 Recall Prefetch] --> B{等待结束\n以先到者为准:\nRecall 完成 / Fast 就绪 /\n取消 / 100 ms 上限}
    B --> B1{Recall 是否完成?}
    B1 -- 是 --> C{选中结果非空?}
    C -- 是 --> C1[注入首轮 Prompt\nphase: refined]
    C -- 否 --> C0[丢弃\nno_relevant_results]
    B1 -- 否 --> D{是否有确定性\nFast 结果?}
    D -- 是 --> E[注入首轮 Prompt\nphase: fast\n最多 2 篇 MAX_FAST_RECALL_DOCS]
    D -- 否 --> F[首轮不注入]
    E --> G[Recall 继续运行]
    F --> G
    G --> H{本轮是否有\nToolResult?}
    H -- 是 --> I{Recall 是否已完成?}
    I -- 是 --> J[排除 Fast 已投递文档\n按剩余文档重建 Prompt]
    I -- 否 --> M
    J --> J1{还有剩余文档?}
    J1 -- 是 --> K[注入 ToolResult\nphase: refined]
    J1 -- 否 --> L{Recall 选中了文档?}
    L -- 是 --> L1[丢弃\nalready_delivered]
    L -- 否 --> L2[丢弃\nno_relevant_results]
    H -- 否 --> M{选中文档是否\n全部已被 Fast 投递?}
    M -- 是 --> L1
    M -- 否 --> M1[丢弃\nno_safe_delivery_point]

为什么需要 Fast 阶段:当存在 Config 时 Recall 会等待 Model Selector,
而它是一次网络 Side Query(中止上限 30 秒),因此 100 ms 预算通常会超时。
若没有 Fast 阶段,没有工具调用的轮次将完全拿不到 Memory——而这正是
用户级 Memory 最重要的场景。Fast 结果复用 selectModelCandidateDocuments
为 Model Manifest 已经算好的候选,不产生额外扫描或 I/O。

100 ms 是上限而不是固定开销:Fast 结果在 Recall 扫完 Memory 树之后才发布,
所以真正决定它能否赶上的是扫描耗时,不是打分耗时(后者是微秒级)。
recall-scan-latency.test.ts 测量同一 Session 内复用文档缓存后的稳态扫描,
不能证明首次冷缓存扫描在 100 ms 内完成。首次扫描需要读取和解析全部主题文件,
大语料下可能超过上限;此时该轮最多等待 100 ms,但不会投递尚未就绪的 Fast 结果。
后续召回复用文档缓存,Fast 就绪后会立即结束等待,不再继续等待通常更慢的
Model Selector。该上限只约束等待成本,并不保证每个语料规模都能完成首次投递。

Fast 阶段的边界:Fast 结果就是确定性结果,因此它只能解决时机问题,
解决不了匹配问题。与文档没有任何词面重叠的 Query 产生不了 Fast 结果,
这类 Query 在无工具回合仍然拿不到 Memory——只有 Model Selector 能覆盖它们,
而无工具回合等不到 Selector。语料中的 semantic-no-lexical 分片专门测量这一点。

去重:两个阶段来自同一次扫描,Model Selector 并未把 Fast 文档视为已排除,
因此 ToolResult 投递前必须过滤掉 Fast 已投递的 filePath 并重建 Prompt。

丢弃口径:同一条规则也适用于取消路径。若最终选中的文档已被 Fast 阶段
全部投递,无论本轮是因为无工具调用、New Query、Reset、Abort 还是 Shutdown
结束,都记为 already_delivered 而不是对应的取消原因——否则「Memory 从未
到达模型」这一桶会被实际已送达的回合灌水。只有部分重叠时仍记取消原因,
因为不在 Fast 集合里的那些文档确实没有投递点。


Forget — 遗忘

触发时机

由用户手动执行 /forget <query> 命令触发。

遗忘流程(`forget.ts`)

mermaid
flowchart TD
    A[forgetManagedAutoMemoryEntries\nquery + config] --> B[ensureAutoMemoryScaffold]
    B --> C[listIndexedForgetCandidates\n扫描所有文件的所有 entry]
    C --> D[为每个 entry 生成稳定 ID\n单 entry 文件: relativePath\n多 entry 文件: relativePath:index]
    D --> E{是否配置了 Config?}
    E -- 是 --> F[selectByModel\n构建 selection prompt\n发起 side query temperature=0]
    F --> G{模型选择成功?}
    G -- 是 --> H[strategy: model]
    G -- 失败 --> I[selectByHeuristic\n关键词匹配]
    E -- 否 --> I
    I --> J[strategy: heuristic]
    H --> K[遍历选中的 candidates]
    J --> K
    K --> L{entries.length == 1?}
    L -- 是 --> M[删除整个文件\nfs.unlink]
    L -- 否 --> N[解析文件中的所有 entries\n移除目标 entry\n重新渲染写回]
    M --> O[记录 removedEntries]
    N --> O
    O --> P{有 touched topics?}
    P -- 是 --> Q[bumpMetadata\n重建 MEMORY.md 索引]
    P --> R[返回 AutoMemoryForgetResult]
    Q --> R

Entry ID 设计:

  • 单条目文件(常见情况):relativePath(如 feedback/no-summary.md)
  • 多条目文件:relativePath:index(如 feedback/style.md:2)
  • 使用稳定 ID 使模型可以精确定位条目而不影响同文件的其他条目

索引重建

MEMORY.md 是所有主题文件的导航索引,每次 Extract 或 Dream 后调用 rebuildManagedAutoMemoryIndex 重建:

text
- [用户偏好](user/preferences.md) — 用户是资深 Go 工程师,第一次接触 React
- [反馈规范](feedback/style.md) — 保持回复简洁,不要尾部总结
- [项目里程碑](project/milestone.md) — 移动端发布切分支前的合并冻结窗口

索引限制:

  • 每行预算 150 字符(MAX_INDEX_LINE_CHARS):标题与描述先各自按字段上限(MAX_INDEX_FIELD_CHARS,120 字符)截断;再按整行预算让描述让位——能整段放下就整段保留,否则截到剩余空间,但会被截到不足 MIN_INDEX_HOOK_CHARS(24)字符时整条描述省略;](path) 链接目标必须完整可解析,链接本身就超过 150 字符时该行允许超长(不截链接)
  • 最多 200 行
  • 总大小不超过 25,000 字节

遥测埋点

系统内置三类遥测事件,用于监控记忆操作的性能和效果:

Extract 遥测

字段 类型 说明
trigger 'auto' 触发方式(当前仅自动)
status 'completed' | 'failed' 执行结果
patches_count number 提取到的有效 patch 数量
touched_topics string[] 被写入的记忆类型列表
duration_ms number 总耗时(毫秒)

Dream 遥测

字段 类型 说明
trigger 'auto' 触发方式
status 'updated' | 'noop' | 'failed' 执行结果
deduped_entries number 机械路径去重的条目数量
touched_topics string[] 被修改的记忆类型列表
duration_ms number 总耗时(毫秒)

Recall 遥测

字段 类型 说明
query_length number 查询字符串长度
docs_scanned number 扫描的文档总数
docs_selected number 最终注入的文档数
strategy 'none' | 'heuristic' | 'model' 选择策略
duration_ms number 总耗时(毫秒)

Recall Delivery 遥测

记录选中的 Memory 是否真的送达主模型(Selection 事件无法回答这个问题)。

字段 类型 说明
phase 'fast' | 'refined' 投递阶段
delivery_point 'initial' | 'tool_result' | 'discarded' 投递位置
discard_reason 'no_safe_delivery_point' | 'new_query' | 'reset' | 'abort' | 'shutdown' | 'no_relevant_results' | 'already_delivered' 丢弃原因
strategy 'none' | 'heuristic' | 'model' 选择方式
docs_selected number 结果文档数(投递事件为实际投递数;discarded 事件为选中数)
latency_ms number 自发起的耗时

phase 与 strategy 正交,互不替代。 phase 描述何时送达:fast 是预算
超时后注入的确定性结果,refined 是 Model Selector 选出的结果。strategy 描述
如何选出。fast 投递必然是 heuristic;refined 投递常规为 model,
在 Selector 失败走 Fallback 时为 heuristic。仅凭 strategy 判断阶段,
会把"确定性结果先到"与"Selector 故障"混为一谈。


相关源文件索引

文件 职责
packages/core/src/memory/types.ts 类型定义:AutoMemoryType、AutoMemoryMetadata、AutoMemoryExtractCursor
packages/core/src/memory/paths.ts 路径计算:getAutoMemoryRoot、isAutoMemPath、各类文件路径 helpers
packages/core/src/memory/store.ts 脚手架初始化:ensureAutoMemoryScaffold,索引/元数据读写
packages/core/src/memory/scan.ts 扫描主题文件:scanAutoMemoryTopicDocuments,解析 frontmatter
packages/core/src/memory/entries.ts 条目解析和渲染:parseAutoMemoryEntries、renderAutoMemoryBody
packages/core/src/memory/extract.ts 提取核心逻辑:runAutoMemoryExtract,游标管理,patch 去重
packages/core/src/memory/extractScheduler.ts 提取调度器:ManagedAutoMemoryExtractRuntime,队列/运行状态机
packages/core/src/memory/extractionAgentPlanner.ts 提取 Agent:runAutoMemoryExtractionByAgent
packages/core/src/memory/dream.ts 整合核心逻辑:runManagedAutoMemoryDream,Agent 路径 + 机械去重
packages/core/src/memory/dreamScheduler.ts 整合调度器:ManagedAutoMemoryDreamRuntime,门控检查,锁管理
packages/core/src/memory/dreamAgentPlanner.ts 整合 Agent:planManagedAutoMemoryDreamByAgent
packages/core/src/memory/recall.ts 召回逻辑:resolveRelevantAutoMemoryPromptForQuery,启发式+模型双路径
packages/core/src/memory/forget.ts 遗忘逻辑:forgetManagedAutoMemoryEntries,候选生成+精确删除
packages/core/src/memory/indexer.ts 索引重建:rebuildManagedAutoMemoryIndex,buildManagedAutoMemoryIndex
packages/core/src/memory/prompt.ts 系统提示模板:记忆类型说明、格式示例、使用规范
packages/core/src/memory/governance.ts 治理建议类型:AutoMemoryGovernanceSuggestionType
packages/core/src/memory/state.ts 提取运行状态:isExtractRunning、markExtractRunning、clearExtractRunning
packages/core/src/memory/memoryAge.ts 新鲜度描述:memoryAge、memoryFreshnessText

Design Adaptive Output Token Escalation Adaptive Output Token Escalation Design (`docs/design/adaptive-output-token-escalation/adaptive-output-token-escalation-design.md`)

Output Token Limit and Escalation Design

Defaults to the model's declared output limit unless the user or environment configures max_tokens, then uses escalation and multi-turn recovery only when a response still hits MAX_TOKENS.

Problem

Every API request reserves a fixed GPU slot proportional to max_tokens. A low default can reduce slot reservation, but it also makes normal large responses more likely to truncate. For file-writing workflows, that can produce incomplete tool-call arguments and force the scheduler to reject the partial write.

Solution

Use the model's declared output limit by default. When a response is truncated (the model hits max_tokens):

  1. Escalate to the model's full output limit (with 64K as a floor when the current limit is lower)
  2. If still truncated, recover by keeping the partial response in history and injecting a continuation message, up to 3 times
  3. If recovery is exhausted, fall back to the tool scheduler's truncation guidance

This favors correctness for large generation and file-edit tasks. Operators that need a lower reservation can still set QWEN_CODE_MAX_OUTPUT_TOKENS, and that explicit value is respected.

Architecture

text
/* Detailed source-code truncated for AI context efficiency. */

Token limit determination

The effective max_tokens is resolved in the following priority order:

Priority Source Value (known model) Value (unknown model) Escalation behavior
1 (highest) User config (samplingParams.max_tokens) min(userValue, modelLimit) userValue No escalation
2 Environment variable (QWEN_CODE_MAX_OUTPUT_TOKENS) min(envValue, modelLimit) envValue No escalation
3 (lowest) Model/default output limit modelLimit DEFAULT_OUTPUT_TOKEN_LIMIT = 32K Escalates to model limit (64K floor) + recovery

A "known model" is one that has an explicit entry in OUTPUT_PATTERNS (checked via hasExplicitOutputLimit()). For known models, the effective value is always capped at the model's declared output limit to avoid API errors. Unknown models (custom deployments, self-hosted endpoints) pass the user's value through directly, since the backend may support larger limits.

This logic is implemented in three content generators:

  • DefaultOpenAICompatibleProvider.applyOutputTokenLimit() — OpenAI-compatible providers
  • DashScopeProvider — inherits applyOutputTokenLimit() from the default provider
  • AnthropicContentGenerator.buildSamplingParameters() — Anthropic provider

Escalation mechanism

The escalation logic lives in geminiChat.ts, placed outside the main retry loop. This is intentional:

  1. The retry loop handles transient errors (rate limits, invalid streams, content validation)
  2. Truncation is not an error — it's a successful response that was cut short
  3. Errors from the escalated stream should propagate directly to the caller, not be caught by retry logic

Escalation steps (geminiChat.ts)

text
1. Stream completes successfully (lastError === null)
2. Last chunk has finishReason === MAX_TOKENS
3. Guard checks pass:
   - maxTokensEscalated === false (prevent infinite escalation)
   - hasUserMaxTokensOverride === false (respect user intent)
4. Compute escalated limit: max(ESCALATED_MAX_TOKENS, tokenLimit(model, 'output'))
5. Pop the partial model response from chat history
6. Yield RETRY event (isContinuation: false) → UI discards partial output and resets buffers
7. Re-send the same request with maxOutputTokens: escalatedLimit

Recovery steps (geminiChat.ts)

If the escalated response is also truncated (finishReason === MAX_TOKENS), the recovery loop runs up to MAX_OUTPUT_RECOVERY_ATTEMPTS (3) times:

text
1. Partial model response is already in history (pushed by processStreamResponse)
2. Push a recovery user message: OUTPUT_RECOVERY_MESSAGE
3. Yield RETRY event (isContinuation: true) → UI keeps text buffer for continuation
4. Re-send with updated history (model sees its partial output + recovery instruction)
5. If still truncated and attempts remain, loop back to step 1
6. If recovery attempt throws (empty response, network error):
   - Pop the dangling recovery message from history
   - Break out of recovery loop

State cleanup on RETRY (turn.ts)

When the Turn class receives a RETRY event, it clears accumulated state to prevent inconsistencies:

  • pendingToolCalls — cleared to avoid duplicate tool calls if the first truncated response contained completed tool calls that are repeated in the escalated response
  • pendingCitations — cleared to avoid duplicate citations
  • finishReason — reset to undefined so the new response's finish reason is used

The isContinuation flag is passed through to the UI so it can decide whether to reset text buffers (escalation) or keep them (recovery).

Constants

Defined in geminiChat.ts and tokenLimits.ts:

Constant Value Purpose
ESCALATED_MAX_TOKENS 64,000 Floor for escalation when the model limit is low
MAX_OUTPUT_RECOVERY_ATTEMPTS 3 Max multi-turn recovery attempts after escalation

The effective escalated limit is max(ESCALATED_MAX_TOKENS, tokenLimit(model, 'output')):

Model Escalated limit
Claude Opus 4.6 131,072 (128K)
GPT-5 / o-series 131,072 (128K)
Qwen3.x 65,536 (64K)
Unknown models 64,000 (floor)

Design decisions

Why not use an 8K default?

  • An 8K default is a slot-reservation/capacity optimization, not a correctness requirement. It trades correctness (large responses truncate) for backend throughput (a request reserves a GPU slot proportional to max_tokens, so a lower value over-reserves less).
  • Large file generation and edit tool calls can legitimately exceed 8K, so an 8K default turns a normal request into a truncate → escalate round-trip (and, in the worst case, a retry loop).
  • Claude Code keeps the same 8K cap but gates it behind a feature flag (tengu_otk_slot_v1) that defaults to off for third-party providers ("not validated on Bedrock/Vertex") — i.e. its default behavior for non-first-party serving is exactly "use the model's declared limit." qwen-code's providers are all third-party / OpenAI-compatible / self-hosted, so matching that default-off behavior is the safe choice; assuming the low default is safe for every backend is not.
  • The capacity tradeoff is not lost, only made opt-in: operators on a capacity-constrained self-hosted backend can set QWEN_CODE_MAX_OUTPUT_TOKENS (e.g. 8000) to restore the lower per-request reservation. A GrowthBook-style feature flag is intentionally not reintroduced — qwen-code has no such infrastructure, and the env var already covers the need.

Why escalate to model limit instead of fixed 64K?

  • Models with higher output limits (Claude Opus 128K, GPT-5 128K) were constrained to 64K unnecessarily
  • Using the model's actual limit captures the vast majority of long outputs without a second retry
  • ESCALATED_MAX_TOKENS (64K) serves as a floor for unknown models where tokenLimit() returns the default 32K

Why multi-turn recovery instead of progressive escalation?

  • Progressive escalation (for example 16K -> 32K -> 64K) requires regenerating the full response each time
  • Multi-turn recovery keeps the partial response and lets the model continue, saving tokens and latency
  • Recovery messages are cheap (~40 tokens each) compared to regenerating large responses
  • The 3-attempt limit prevents infinite loops while covering most practical cases

Why is escalation outside the retry loop?

  • Truncation is a success case, not an error
  • Errors from the escalated stream (rate limits, network failures) should propagate directly rather than being silently retried with incorrect parameters
  • Keeps the retry loop focused on its original purpose (transient error recovery)
  • Recovery errors are caught separately to avoid aborting the entire conversation

Design Prompt Suggestion Prompt Suggestion Design (`docs/design/prompt-suggestion/prompt-suggestion-design.md`)

Prompt Suggestion (NES) Design

Predicts what the user would naturally type next after the AI completes a response, showing it as ghost text in the input prompt.

Implementation status: prompt-suggestion-implementation.md. Speculation engine: speculation-design.md.

Overview

A prompt suggestion (Next-step Suggestion / NES) is a short prediction (2-12 words) of the user's next input, generated by an LLM call after each AI response. It appears as ghost text in the input prompt. The user can accept it with Tab/Enter/Right Arrow or dismiss it by typing.

Architecture

text
/* Detailed source-code truncated for AI context efficiency. */

Suggestion Generation

LLM Prompt

text
[SUGGESTION MODE: Suggest what the user might naturally type next.]

FIRST: Read the LAST FEW LINES of the assistant's most recent message — that's where
next-step hints, tips, and actionable suggestions usually appear. Then check the user's
recent messages and original request.

Your job is to predict what THEY would type - not what you think they should do.
THE TEST: Would they think "I was just about to type that"?

PRIORITY: If the assistant's last message contains a tip or hint like "Tip: type X to ..."
or "type X to ...", extract X as the suggestion. These are explicit next-step hints.

EXAMPLES:
Assistant says "Tip: type post comments to publish findings" → "post comments"
Assistant says "type /review to start" → "/review"
User asked "fix the bug and run tests", bug is fixed → "run the tests"
After code written → "try it out"
Task complete, obvious follow-up → "commit this" or "push it"

Format: 2-12 words, match the user's style. Or nothing.
Reply with ONLY the suggestion, no quotes or explanation.

Filter Rules (12)

Rule Example blocked
done "done"
meta_text "nothing found", "no suggestion", "silence"
meta_wrapped "(silence)", "[no suggestion]"
error_message "api error: 500"
prefixed_label "Suggestion: commit"
too_few_words "hmm" (but allows "yes", "commit", "push" etc.)
too_many_words > 12 words
too_long >= 100 chars
multiple_sentences "Run tests. Then commit."
has_formatting newlines, markdown bold
evaluative "looks good", "thanks" (with \b word boundaries)
ai_voice "Let me...", "I'll...", "Here's..."

Guard Conditions

AppContainer useEffect (13 checks in code):

Guard Check
Settings toggle enableFollowupSuggestions
Non-interactive config.isInteractive()
SDK mode !config.getSdkMode()
Streaming transition Responding → Idle (2 checks)
API error (history) historyManager.history[last]?.type !== 'error'
API error (pending) !pendingGeminiHistoryItems.some(type === 'error')
Confirmation dialogs shell + general + loop detection (3 checks)
Permission dialog isPermissionsDialogOpen
Elicitation settingInputRequests.length === 0
Plan mode ApprovalMode.PLAN

Inside generatePromptSuggestion():

Guard Check
Early conversation modelTurns < 2

Separate feature flags (not in guard block):

Flag Controls
enableCacheSharing Whether to use forked query or fallback to generateJson
enableSpeculation Whether to start speculation on suggestion display

State Management

FollowupState

typescript
interface FollowupState {
  suggestion: string | null;
  isVisible: boolean;
  shownAt: number; // timestamp for telemetry
}

FollowupController

Framework-agnostic controller shared by CLI (Ink) and WebUI (React):

  • setSuggestion(text) — 300ms delayed show, null clears immediately
  • accept(method) — clears state, fires onAccept via microtask, 100ms debounce lock
  • dismiss() — clears state, logs ignored telemetry
  • clear() — hard reset all state + timers
  • Object.freeze(INITIAL_FOLLOWUP_STATE) prevents accidental mutation

Keyboard Interaction

Key CLI WebUI
Tab Fill input (no submit) Fill input (no submit)
Enter Fill + submit Fill + submit (explicitText param)
Right Arrow Fill input (no submit) Fill input (no submit)
Typing Dismiss + abort speculation Dismiss
Paste Dismiss + abort speculation Dismiss

Key Binding Note

The Tab handler uses key.name === 'tab' explicitly (not ACCEPT_SUGGESTION matcher) because ACCEPT_SUGGESTION also matches Enter, which must fall through to the SUBMIT handler.

Telemetry

PromptSuggestionEvent

Field Type Description
outcome accepted/ignored/suppressed Final outcome
prompt_id string Default: 'user_intent'
accept_method tab/enter/right How user accepted
time_to_accept_ms number Time from shown to accept
time_to_ignore_ms number Time from shown to dismiss
time_to_first_keystroke_ms number Time to first keystroke while shown
suggestion_length number Character count
similarity number 1.0 for accept, 0.0 for ignore
was_focused_when_shown boolean Terminal had focus
reason string For suppressed: filter rule name

SpeculationEvent

Field Type Description
outcome accepted/aborted/failed Speculation result
turns_used number API round-trips
files_written number Files in overlay
tool_use_count number Tools executed
duration_ms number Wall-clock time
boundary_type string What stopped speculation
had_pipelined_suggestion boolean Next suggestion generated

Feature Flags and Settings

Setting Type Default Description
enableFollowupSuggestions boolean true Master toggle for prompt suggestions
enableCacheSharing boolean true Use cache-aware forked queries
enableSpeculation boolean false Predictive execution engine
fastModel (top-level) string "" Model for all background tasks (empty = use main model). Set via /model --fast

Internal Prompt ID Filtering

Background operations use dedicated prompt IDs (INTERNAL_PROMPT_IDS in utils/internalPromptIds.ts) to prevent their API traffic and tool calls from appearing in the user-visible UI:

Prompt ID Used by
prompt_suggestion Suggestion generation
forked_query Cache-aware forked queries
speculation Speculation engine

Filtering applied:

  • loggingContentGenerator — skips logApiRequest and OpenAI interaction logging for internal IDs
  • logApiResponse / logApiError — skips chatRecordingService.recordUiTelemetryEvent
  • logToolCall — skips chatRecordingService.recordUiTelemetryEvent
  • uiTelemetryService.addEvent — not filtered (ensures /stats token tracking works)

Thinking Mode

Thinking/reasoning is explicitly disabled (thinkingConfig: { includeThoughts: false }) for all background task paths:

  • Forked query path (createForkedChat) — overrides thinkingConfig in the cloned generationConfig, covering both suggestion generation and speculation
  • BaseLlm fallback path (generateViaBaseLlm) — per-request config overrides base content generator's thinking settings

This is safe because:

  • Cache prefix is determined by systemInstruction + tools + history, not thinkingConfig — cache hits are unaffected
  • All backends (Gemini, OpenAI-compatible, Anthropic) handle includeThoughts: false by omitting the thinking field — no API errors on models without thinking support
  • Suggestion generation and speculation don't benefit from reasoning tokens

Design Prompt Suggestion Prompt Suggestion Implementation (`docs/design/prompt-suggestion/prompt-suggestion-implementation.md`)

Prompt Suggestion Implementation Status

Tracks the implementation status of the prompt suggestion (NES) feature across all packages.

Core Module (`packages/core/src/followup/`)

Component Status Lines Description
followupState.ts ✅ Done ~230 Framework-agnostic controller with timer/debounce
suggestionGenerator.ts ✅ Done ~260 LLM generation + 12 filter rules + forked query support
forkedQuery.ts ✅ Done ~240 CacheSafeParams + createForkedChat + runForkedQuery
overlayFs.ts ✅ Done ~140 Copy-on-write overlay filesystem
speculationToolGate.ts ✅ Done ~150 Tool boundary enforcement with AST shell parser
speculation.ts ✅ Done ~540 Speculation engine with pipelined suggestion + model override

CLI Integration (`packages/cli/`)

Component Status Description
AppContainer.tsx ✅ Done Suggestion generation, speculation lifecycle, UI rendering
InputPrompt.tsx ✅ Done Tab/Enter/Right Arrow acceptance, dismiss + abort
Composer.tsx ✅ Done Props threading
UIStateContext.tsx ✅ Done promptSuggestion + dismissPromptSuggestion
useFollowupSuggestions.tsx ✅ Done React hook with telemetry + keystroke tracking
settingsSchema.ts ✅ Done 3 feature flags + fastModel setting
settings.schema.json ✅ Done VSCode settings schema

WebUI Integration (`packages/webui/`)

Component Status Description
InputForm.tsx ✅ Done Tab/Enter/Right Arrow + explicitText submit
useFollowupSuggestions.ts ✅ Done React hook with onOutcome support
followup.ts ✅ Done Subpath entry
components.css ✅ Done Ghost text styling
vite.config.followup.ts ✅ Done Separate build config

Telemetry (`packages/core/src/telemetry/`)

Component Status Description
PromptSuggestionEvent ✅ Done 10 fields
SpeculationEvent ✅ Done 7 fields
logPromptSuggestion() ✅ Done OpenTelemetry logger
logSpeculation() ✅ Done OpenTelemetry logger

Test Coverage

Test File Tests Description
followupState.test.ts 14 Controller timer, debounce, accept callback, onOutcome, clear
suggestionGenerator.test.ts 16 All 12 filter rules + edge cases + false positives
overlayFs.test.ts 15 COW write, read resolution, apply, cleanup, path traversal
speculationToolGate.test.ts 27 Tool categories, approval mode, shell AST, path rewrite
forkedQuery.test.ts 6 Cache params save/get/clear, deep clone, version detection
speculation.test.ts 7 ensureToolResultPairing edge cases
smoke.test.ts 21 Cross-module E2E: filter + overlay + toolGate + cache + pairing
InputPrompt.test.tsx 4 Tab, Enter+submit, Right Arrow, completion guard

Audit History

Round Issues Found Issues Fixed
R1-R4 10 10 (rule engine → LLM, state simplification)
R5-R6 2 2 (Enter keybinding conflict, Right Arrow telemetry)
R7-R8 3 3 (WebUI telemetry, dead type, test coverage)
R9 0 — (convergence)
R10-R11 1 1 (historyManager dep)
R12-R13 1 1 (evaluative regex word boundaries)
Phase 1+2 R1-R4 20+ 20+ (permission bypass, overlay safety, race conditions)
Total 37+ 37+

Claude Code Alignment

Feature Alignment Notes
Prompt text 100% Identical (brand name only)
12 filter rules 100%+ \b word boundaries improvement
UI interaction (Tab/Enter/Right) 100%
Guard conditions 100% 13 checks
Telemetry 100% 10+7 fields
Cache sharing ✅ DashScope cache_control
Speculation ✅ COW overlay + tool gating
Pipelined suggestion ✅ Generated after speculation completes
State management 100%+ Controller pattern, Object.freeze

Design Prompt Suggestion Speculation Design (`docs/design/prompt-suggestion/speculation-design.md`)

Speculation Engine Design

Speculatively executes the accepted suggestion before the user confirms, using copy-on-write file isolation. Results appear instantly when the user presses Tab.

Overview

When a prompt suggestion is shown, the speculation engine immediately starts executing it in the background using a forked GeminiChat. File writes go to a temporary overlay directory. If the user accepts the suggestion, overlay files are copied to the real filesystem and the speculated conversation is injected into the main chat history. If the user types something else, the speculation is aborted and the overlay is cleaned up.

Architecture

text
/* Detailed source-code truncated for AI context efficiency. */

Copy-on-Write Overlay

text
Real CWD: /home/user/project/
Overlay:  /tmp/qwen-speculation/12345/a1b2c3d4/

Write to src/app.ts:
  1. Copy /home/user/project/src/app.ts → overlay/src/app.ts (first time only)
  2. Tool writes to overlay/src/app.ts

Read from src/app.ts:
  - If in writtenFiles → read from overlay/src/app.ts
  - Otherwise → read from /home/user/project/src/app.ts

New file (src/new.ts):
  - Create overlay/src/new.ts directly (no original to copy)

Accept:
  - copyFile(overlay/src/app.ts → /home/user/project/src/app.ts)
  - copyFile(overlay/src/new.ts → /home/user/project/src/new.ts)
  - rm -rf overlay/

Abort:
  - rm -rf overlay/

Tool Gate Security

Tool Action Condition
read_file, grep, glob, ls, lsp allow Read paths resolved through overlay
edit, write_file redirect Only in auto-edit / yolo approval mode
edit, write_file boundary In default / plan approval mode
shell allow isShellCommandReadOnlyAST() returns true
shell boundary Non-read-only commands
web_fetch, web_search boundary Network requests require user consent
agent, skill, memory, ask_user, todo_write, exit_plan_mode boundary Cannot interact with user during speculation
Unknown / MCP tools boundary Safe default

Path Rewrite

  • Write tools: rewritePathArgs() redirects file_path to overlay via overlayFs.redirectWrite()
  • Read tools: resolveReadPaths() redirects file_path to overlay via overlayFs.resolveReadPath() if previously written
  • Rewrite failure: Treated as boundary (e.g., absolute path outside cwd throws in redirectWrite)

Boundary Handling

When a boundary is hit mid-turn:

  1. Already-executed tool calls are preserved (index-based tracking, not name-based)
  2. Unexecuted function calls are stripped from the model message
  3. Partial tool responses are added to history
  4. ensureToolResultPairing() validates completeness before injection

Pipelined Suggestion

After speculation completes (no boundary), a second LLM call generates the next suggestion:

text
Context: original conversation + "commit this" + speculated messages
→ LLM predicts: "push it"
→ Stored in state.pipelinedSuggestion
→ On accept: setPromptSuggestion("push it") — appears instantly

This enables Tab-Tab-Tab workflows where each acceptance immediately shows the next step.

The pipelined suggestion reuses the exported SUGGESTION_PROMPT constant from suggestionGenerator.ts (not a local copy) to ensure consistent quality with initial suggestions.

Fast Model

startSpeculation accepts an optional options.model parameter, threaded through runSpeculativeLoop and generatePipelinedSuggestion to runForkedQuery. Configured via the top-level fastModel setting (empty = use main model). The same fastModel is used for all background tasks: suggestion generation, speculation, and pipelined suggestions. Set via /model --fast <name> or settings.json.

UI Rendering

When speculation completes, acceptSpeculation renders results via historyManager.addItem():

  • User messages: rendered as type: 'user' items
  • Model text: rendered as type: 'gemini' items
  • Tool calls: rendered as type: 'tool_group' items with structured IndividualToolCallDisplay entries (tool name, argument description, result text, status)

This shows the user the full speculation output including tool call details, not just plain text.

Forked Query (Cache Sharing)

CacheSafeParams

typescript
interface CacheSafeParams {
  generationConfig: GenerateContentConfig; // systemInstruction + tools
  history: Content[]; // curated, max 40 entries
  model: string;
  version: number; // increments on config changes
}
  • Saved after each successful main turn in GeminiClient.sendMessageStream()
  • Cleared on startChat() / resetChat() to prevent cross-session leakage
  • History truncated to 40 entries; createForkedChat uses shallow copies (params are already deep-cloned snapshots)
  • Thinking mode explicitly disabled (thinkingConfig: { includeThoughts: false }) — reasoning tokens are not needed for speculation and would waste cost/latency. This does not affect cache prefix matching (determined by systemInstruction + tools + history only)
  • Version detection via JSON.stringify comparison of systemInstruction + tools

Cache Mechanism

DashScope already enables prefix caching via:

  • X-DashScope-CacheControl: enable header
  • cache_control: { type: 'ephemeral' } annotations on messages and tools

The forked GeminiChat uses identical generationConfig (including tools) and history prefix, so DashScope's existing cache mechanism produces cache hits automatically.

Constants

Constant Value Description
MAX_SPECULATION_TURNS 20 Maximum API round-trips
MAX_SPECULATION_MESSAGES 100 Maximum messages in speculated history
SUGGESTION_DELAY_MS 300 Delay before showing suggestion
ACCEPT_DEBOUNCE_MS 100 Debounce lock for rapid accepts
MAX_HISTORY_FOR_CACHE 40 History entries saved in CacheSafeParams

File Structure

text
packages/core/src/followup/
├── followupState.ts          # Framework-agnostic state controller
├── suggestionGenerator.ts    # LLM-based suggestion generation + 12 filter rules
├── forkedQuery.ts            # Cache-aware forked query infrastructure
├── overlayFs.ts              # Copy-on-write overlay filesystem
├── speculationToolGate.ts    # Tool boundary enforcement
├── speculation.ts            # Speculation engine (start/accept/abort)
└── index.ts                  # Module exports

Design Session Recap Session Recap Design (`docs/design/session-recap/session-recap-design.md`)

Session Recap Design

A brief (1-2 sentence) "where did I leave off" summary surfaced when the user
returns to an idle session, either on demand (/recap) or after the
terminal has been blurred for 5+ minutes.

Overview

When a user /resumes an old session days later, scrolling back through
pages of history to remember what they were doing and what came next
is a real friction point. Just reloading messages does not solve this
UX problem.

The goal is to proactively surface a brief 1-2 sentence recap when the user
returns:

  • High-level task (what they are doing) → next step (what to do next).
  • Visually distinct from real assistant replies, so it is never mistaken
    for new model output.
  • Best-effort: failures must be silent and never break the main flow.

Triggers

Trigger Conditions Implementation
Manual User runs /recap recapCommand.ts calls the same underlying service
Auto Terminal blurred (DECSET 1004 focus protocol) for ≥ 5 min + focus returns + stream is Idle useAwaySummary.ts — 5min blur timer + useFocus event listener
Daemon HTTP Remote client calls POST /session/:id/recap server.ts route → bridge.generateSessionRecap (ext-method roundtrip) → acpAgent.ts calls generateSessionRecap(session.getConfig(), signal)

All three paths funnel into the same generateSessionRecap() function
in core/services/sessionRecap.ts to guarantee identical behavior. The
auto-trigger is gated by general.showSessionRecap (default: off —
explicit opt-in, so ambient LLM calls are never silently added to a
user's bill); the manual command and daemon HTTP route ignore that
setting (the caller is making an explicit request).

Daemon access path

The daemon route is non-strict-gated (mirrors /session/:id/prompt's
posture — recap costs tokens but mutates no state). Capability tag
session_recap advertises the route on /capabilities.features. SDK
helpers: DaemonClient.recapSession(sessionId, opts) and
DaemonSessionClient.recap(opts). See
docs/developers/qwen-serve-protocol.md § POST /session/:id/recap
for the wire contract and error envelope.

Cancellation is absent in v1. The route does not listen for HTTP
client disconnect, no AbortSignal is threaded into
bridge.generateSessionRecap, and the ACP child handler passes a
never-aborting AbortController().signal to the core helper (no
cross-process abort plumbing yet). The only ceilings are the bridge's
60s SESSION_RECAP_TIMEOUT_MS backstop and the transport-closed race
against ACP channel death. Wiring an HTTP-side AbortController in
isolation would be cosmetic — the child-side LLM call would still run
to completion, so e2e cancel is not achievable without the cross-
process abort piece. This is acceptable for v1 because recap is short
(single-attempt side-query, maxOutputTokens: 300, ~1–5s typical).
A future request-id-based cancel ext-method can plumb full end-to-end
cancellation if/when the bandwidth cost justifies it.

Architecture

text
/* Detailed source-code truncated for AI context efficiency. */

Files

File Responsibility
packages/core/src/services/sessionRecap.ts One-shot LLM call + history filter + tag extraction
packages/cli/src/ui/hooks/useAwaySummary.ts Auto-trigger React hook
packages/cli/src/ui/commands/recapCommand.ts /recap manual entry point
packages/cli/src/ui/components/messages/StatusMessages.tsx AwayRecapMessage renderer (※ + bold recap: + italic content, all dim)
packages/cli/src/ui/types.ts HistoryItemAwayRecap type
packages/cli/src/ui/components/HistoryItemDisplay.tsx Dispatches away_recap history items to the renderer
packages/cli/src/config/settingsSchema.ts general.showSessionRecap + general.sessionRecapAwayThresholdMinutes settings

Prompt Design

System Prompt

generationConfig.systemInstruction replaces the main agent's system
prompt for this single call, so the model behaves only as a recap
generator and not as a coding assistant.

Note that GeminiClient.generateContent() internally runs the prompt
through getCustomSystemPrompt(), which appends the user's memory
(QWEN.md / managed auto-memory) as a suffix. The final system prompt is
therefore recap prompt + user memory — useful project context for the
recap, not a leak.

Bullets below correspond 1:1 with RECAP_SYSTEM_PROMPT:

  • Under 40 words, 1-2 plain sentences (no markdown / lists / headings). For Chinese, treat the budget as roughly 80 characters total.
  • First sentence: the high-level task. Then: the concrete next step.
  • Explicitly forbid: listing what was done, reciting tool calls, status reports.
  • Match the dominant language of the conversation (English or Chinese).
  • Wrap output in <recap>...</recap>; nothing outside the tags.

Structured Output + Extraction

The model is instructed to wrap its answer in <recap>...</recap>:

text
<recap>Refactoring loopDetectionService.ts to address long-session OOM. Next step is to implement option B.</recap>

Why: some models (GLM family, reasoning models) write a "thinking"
paragraph before the final answer. Returning the raw text would leak
that reasoning into the UI.

extractRecap() has three fallback tiers:

  1. Both tags present: take what is between <recap>...</recap> (preferred).
  2. Only the open tag (e.g. maxOutputTokens truncated the close tag):
    take everything after the open tag.
  3. Tag missing entirely: return empty string → service returns null
    → UI renders nothing.

The third tier is "skip rather than show the wrong thing" — surfacing
the model's reasoning preamble is worse than showing no recap at all.

Call Parameters

Parameter Value Reason
model getFastModel() ?? getModel() Recap doesn't need a frontier model
tools [] One-shot query, no tool use
maxOutputTokens 300 Headroom for 1-2 short sentences + tags
temperature 0.3 Mostly deterministic, with a bit of natural variation
systemInstruction The recap-only prompt above Replaces the main agent's role definition

History Filtering

geminiClient.getChat().getHistory() returns a Content[] that
includes:

  • user / model text messages
  • model functionCall parts
  • user functionResponse parts (which can hold full file contents)
  • model thought parts (part.thought / part.thoughtSignature,
    the model's hidden reasoning)

filterToDialog() keeps only user / model parts that have non-empty
text and are not thoughts
. Two reasons:

  • Tool calls / responses: a single functionResponse can be 10K+
    tokens. 30 such messages would drown the recap LLM in irrelevant
    detail, both wasting tokens and biasing the recap toward
    implementation noise like "called X tool to read Y file".
  • Thought parts: carry the model's internal reasoning. Including
    them risks treating hidden chain-of-thought as dialogue and
    surfacing it in the recap text.

After dropping empty messages, takeRecentDialog slices to the last 30
messages and refuses to start the slice on a dangling model/tool
response.

Concurrency and Edge Cases

Auto-trigger hook state machine

useAwaySummary keeps three refs:

Ref Meaning
blurredAtRef Blur start time (not cleared until focus returns)
recapPendingRef Whether an LLM call is in flight
inFlightRef The current in-flight AbortController

useEffect deps: [enabled, config, isFocused, isIdle, addItem, thresholdMs].

Event Action
!enabled || !config Abort in-flight call + clear inFlightRef + clear blurredAtRef
!isFocused and blurredAtRef === null Set blurredAtRef = Date.now()
isFocused and blurredAtRef === null Return early (no blur cycle to handle — first render or right after a brief-blur reset)
isFocused and blur duration < 5 min Clear blurredAtRef, wait for next blur cycle
isFocused and blur ≥ 5 min and recapPendingRef Return (dedupe)
isFocused and blur ≥ 5 min and !isIdle Preserve blurredAtRef and wait for the turn to finish (isIdle is in the deps, so the effect re-fires when streaming completes)
isFocused and blur ≥ 5 min and shouldFireRecap returns false Clear blurredAtRef and return — conversation hasn't moved enough since the last recap (≥ 2 user turns required, mirrors Claude Code)
isFocused and all conditions met Clear blurredAtRef, set recapPendingRef = true, create AbortController, send the LLM request

The .then callback re-checks isIdleRef.current: if the user has
started a new turn while the LLM was running, the late-arriving recap
is dropped to avoid inserting it mid-turn.

The .finally clears recapPendingRef, and clears inFlightRef only
if inFlightRef.current === controller (so it doesn't overwrite a
newer controller).

A second useEffect aborts the in-flight controller on unmount.

`/recap` gating

CommandContext.ui.isIdleRef exposes the current stream state
(mirroring the existing btwAbortControllerRef pattern). In
interactive mode, recapCommand refuses when !isIdleRef.current
or pendingItem !== null. pendingItem alone is insufficient
because a normal model reply runs with streamingState === Responding
and a null pendingItem.

Configuration and Model Selection

User-facing knobs

Setting Default Notes
general.showSessionRecap false Auto-trigger only. Manual /recap ignores this.
general.sessionRecapAwayThresholdMinutes 5 Minutes blurred before auto-recap fires on focus-in. Matches Claude Code's default.
fastModel unset Recommended (e.g. qwen3-coder-flash) for fast and cheap recaps.

Model fallback

config.getFastModel() ?? config.getModel():

  • User has a fastModel set and it is valid for the current auth type
    → use fastModel.
  • Otherwise → fall back to the main session model (works, just costlier
    and slower).

Observability

createDebugLogger('SESSION_RECAP') emits:

  • caught exceptions from the recap path (debugLogger.warn).

All failures are fully transparent to the user — recap is an
auxiliary feature and never throws into the UI. Developers can grep for
the [SESSION_RECAP] tag in the debug log file: written by default to
~/.qwen/debug/<sessionId>.txt (latest.txt symlinks to the current
session); disable via QWEN_DEBUG_LOG_FILE=0.

Out of Scope

Item Why not
Progress UI for /recap (spinner / pendingItem) 3-5 second wait is tolerable; adds complexity.
Automated tests Service is small (~150 lines), end-to-end tested manually first; unit tests can land in a separate PR.
Localized prompts The system prompt is for the model; English is the most reliable substrate. The model selects the output language from the conversation.
QWEN_CODE_ENABLE_AWAY_SUMMARY env var Claude Code uses it to keep the feature on when telemetry is disabled; Qwen Code's current telemetry model doesn't need this.
Auto-recap on /resume completion A natural follow-up but needs a hook point in useResumeCommand; out of scope for this PR.

Design Session Title Session Title Design (`docs/design/session-title/session-title-design.md`)

Session Title Design

A 3-7 word sentence-case session title generated by the fast model after
the first assistant turn. Persisted in the session JSONL with a
titleSource: 'auto' | 'manual' tag, surfaced in the session picker,
and regeneratable on demand via /rename --auto.

Overview

/rename (#3093) lets a user label a session so they can find it again in
the picker later, but until they run it the picker shows the first user
prompt — often truncated mid-sentence, or describing a framing question
rather than what the session actually became about. Manual renaming is
optional friction most users never do.

The goal is to make session names useful by default:

  • Descriptive of what the session actually accomplished, not just the
    opening line. 3-7 words, sentence case, git-commit-subject style.
  • Best-effort: fires in the background after the first reply; if it
    fails the user never sees an error.
  • Deferential to the user: never clobber a /rename title the user
    chose deliberately, even across CLI tabs on the same session.
  • Explicitly regeneratable via /rename --auto for the "auto title
    became stale / I want a fresh one" case.

Triggers

Trigger Conditions Implementation
Auto After recordAssistantTurn fires. Skipped if an existing or pending explicit title is set, recording has failed, another attempt is in-flight, cap reached, non-interactive, env disabled, or no fast model. ChatRecordingService.maybeTriggerAutoTitle — fire-and-forget
Manual User runs /rename --auto renameCommand.ts via tryGenerateSessionTitle

Both paths funnel into a single function — tryGenerateSessionTitle(config, signal) — to guarantee identical prompt, schema, model selection, and
sanitization. The auto trigger is a best-effort background call; the
manual /rename --auto is a blocking user action that surfaces a
reason-specific error on failure.

Architecture

text
/* Detailed source-code truncated for AI context efficiency. */

Files

File Responsibility
packages/core/src/services/sessionTitle.ts One-shot LLM call + history filter + sanitize. Exports tryGenerateSessionTitle.
packages/core/src/services/chatRecordingService.ts maybeTriggerAutoTitle trigger, guards, cross-process re-read, abort-on-finalize.
packages/core/src/services/sessionService.ts getSessionTitleInfo public accessor; renameSession accepts titleSource.
packages/core/src/utils/sessionStorageUtils.ts extractLastJsonStringFields + readLastJsonStringFieldsSync atomic pair reader.
packages/core/src/utils/terminalSafe.ts stripTerminalControlSequences shared by sentence-case and kebab paths.
packages/cli/src/ui/commands/renameCommand.ts /rename --auto, sentinel parser, failure-reason message map.
packages/cli/src/ui/components/SessionPicker.tsx Dim styling for titleSource === 'auto'.

Prompt Design

System Prompt

Replaces the main agent's system prompt for this single call so the model
only tries to label the session, not behave as a coding assistant.

Bullets below correspond 1:1 with TITLE_SYSTEM_PROMPT:

  • 3-7 words, sentence case (only first word and proper nouns capitalized).
  • No trailing punctuation, no markdown, no quotes.
  • Match the dominant language of the conversation; for Chinese, budget
    roughly 12-20 characters.
  • Be specific about the user's actual goal — name the feature, bug, or
    subject area. Avoid vague catch-alls like "Code changes" or "Help
    request".
  • Four good examples (three English + one Chinese) and four bad examples
    (too vague / too long / wrong case / trailing punctuation).
  • Return only a JSON object with a single title key.

Structured Output (JSON schema)

Instead of wrapping output in tags (as session-recap does), we use
BaseLlmClient.generateJson with a function-calling schema:

ts
const TITLE_SCHEMA = {
  type: 'object',
  properties: {
    title: {
      type: 'string',
      description:
        'A concise sentence-case session title, 3-7 words, no trailing punctuation.',
    },
  },
  required: ['title'],
};

Why function calling rather than free text + tag extraction:

  1. Cross-provider reliability — OpenAI-compatible endpoints, Gemini, and
    Qwen's native tool-calling all implement function calling; tag parsing
    would rely on every model respecting a text convention.
  2. No reasoning-preamble leakage — the function call arguments come back
    structured, so a "thinking" paragraph before the answer can't bleed
    into the title.
  3. Simpler post-processing — a single typeof result.title === 'string'
    check plus sanitizeTitle covers every realistic model drift.

The model may still return something the schema allows but the UX
rejects (empty string, whitespace-only, 500 chars, markdown fencing,
control chars). sanitizeTitle handles all of these and returns '' →
service returns {ok: false, reason: 'empty_result'}.

Call Parameters

Parameter Value Reason
model getFastModel() — no fallback Auto-titling on main-model tokens is too expensive to be silent.
schema TITLE_SCHEMA Forces {title: string}; filters shape drift at the transport layer.
maxOutputTokens 100 More than enough for 7 words plus schema overhead.
temperature 0.2 Mostly deterministic — session titles benefit from stability across regeneration.
maxAttempts 1 Titles are best-effort cosmetic metadata; retries would queue behind user-visible main traffic.

Contrast with session-recap, which falls back to the main model. Title
generation is triggered automatically and often; silently spending
main-model tokens without a user opt-in is a real bill surprise. Manual
/rename --auto explicitly fails with no_fast_model rather than
fallback — forcing the user to make the fast-model choice consciously.

History Filtering

geminiClient.getChat().getHistory() returns Content[] that includes
tool calls, tool responses (often 10K+ tokens of file content), and model
thought parts. Feeding that raw into the title LLM would bias the label
toward implementation noise like "Called grep on auth module".

filterToDialog keeps only user / model entries with non-empty text
and no thought / thoughtSignature parts. takeRecentDialog slices to
the last 20 messages and refuses to start on a dangling model/tool
response. flattenToTail converts to "Role: text" lines and slices the
last 1000 characters.

The 1000-character tail slice

A session that starts with help me debug X but pivots to refactoring Y
should be titled about Y. Titling by the head locks in the opening
framing; titling by the tail captures what the session became.

UTF-16 surrogate handling

.slice(-1000) on a UTF-16 code-unit boundary can orphan a high or low
surrogate if a CJK supplementary char or emoji gets cut. Some providers
respond to the resulting invalid UTF-16 with a 400 — which, without
handling, would burn an attempt for no reason. flattenToTail drops a
leading orphaned low surrogate; sanitizeTitle scrubs any orphaned
surrogate after the max-length trim on the output path too.

Persistence

Durable result contract

ChatRecordingService.recordCustomTitle() returns Promise.
true means the custom_title record has completed the recorder's ordered
JSONL write, the in-memory title and source have been updated, and the title
observer has been notified. It does not mean merely accepted into the write
queue. A file-creation failure, an existing recorder failure, an earlier
queued write failure, or the title write's own rejection returns false and
leaves the prior title and observer state unchanged. The recorder's canonical
flush() still reports the sticky writer failure.

The observer runs only after persistence. Observer exceptions are isolated
because an already written title cannot be retroactively classified as a
failed rename. The callback also receives the session ID captured in the
persisted record; consumers must not read the mutable Config session at callback
time. This prevents a delayed write from an outgoing recorder from renaming the
new prompt bar after /clear, resume, or branch switches sessions.

Background auto-title uses the same private persistence path but remains
best-effort. Public calls are explicit title requests, including
recordCustomTitle(title, 'auto') from /rename --auto and remote control
surfaces.

Record shape

CustomTitleRecordPayload grows an optional titleSource: 'auto' | 'manual' field:

jsonc
{
  "type": "system",
  "subtype": "custom_title",
  "systemPayload": {
    "customTitle": "Debug login button on mobile",
    "titleSource": "auto",
  },
}

The field is optional, and absent-in-legacy records are treated as
undefined. SessionPicker dims rows only on a strict === 'auto'
match — a pre-change user /rename title is never silently reclassified
as a model guess.

Resume hydration

On resume, ChatRecordingService constructor calls
sessionService.getSessionTitleInfo(sessionId) to read both the
title and its source. Without hydrating the source, finalize()'s
re-append (which runs on every session lifecycle event) would rewrite
auto as manual on every resume cycle — silently stripping the dim
affordance.

Atomic pair read

extractLastJsonStringFields returns customTitle and titleSource
from the same matching line in a single scan. Two separate
readLastJsonStringFieldSync calls could land on different records if
an older line has only the primary field, yielding a mismatched pair.
The extractor also requires a proper closing quote on the primary value,
so a crash-truncated trailing record can't win the latest-match race.

Full-file scan cap

Phase-2 (when the tail-window fast path misses) streams the whole file
in 64KB chunks. Capped at MAX_FULL_SCAN_BYTES = 64 MB so a corrupt
multi-GB JSONL can't freeze the session picker on the main event loop.
The picker's latency envelope survives corruption.

Session reads open with O_NOFOLLOW (falls back to plain read-only on
Windows, where the constant is not exposed). Defense in depth so a
symlink planted in ~/.qwen/projects//chats/ can't redirect a
metadata read to an unrelated file.

Concurrency and Edge Cases

Trigger guard order

maybeTriggerAutoTitle checks these conditions in this exact order — each
short-circuits the rest so the cheap ones run first:

  1. currentCustomTitle set → skip. Never overwrite manual / prior auto.
  2. Writer degraded → skip. A fail-stop recorder cannot persist a title, so
    do not spend more fast-model tokens.
  3. Explicit title write pending → skip. User and remote intent wins even
    before its JSONL write settles.
  4. autoTitleController !== undefined → skip. One attempt at a time.
  5. autoTitleAttempts >= 3 → skip. Cap bounds total waste.
  6. autoTitleDisabledByEnv() → skip. QWEN_DISABLE_AUTO_TITLE=1
    explicit opt-out.
  7. !config.isInteractive() → skip. Headless qwen -p / CI never spends
    fast-model tokens on a one-shot session.
  8. !config.getFastModel() → skip. No fast-model → no-op.

Why the cap is 3, not 1

The first assistant turn can be a pure tool-call with no user-visible
text (e.g. the model opens with a grep). tryGenerateSessionTitle
returns {ok: false, reason: 'empty_history'} in that case. Without a
retry window, an entire session's chance at a title would be burned on
turn 1 before the user said anything interesting. Cap of 3 covers the
common "first turn is noise" case while still bounding runaway retry on
a persistently failing fast model.

Cross-process manual-rename race

Two CLI tabs on the same session file can diverge in memory. Tab A runs
/rename foo and writes titleSource: manual. Tab B's
ChatRecordingService has its own currentCustomTitle = undefined and
would naively overwrite with an auto title.

After the LLM call resolves, the IIFE re-reads the JSONL via
sessionService.getSessionTitleInfo. If the file shows
source: 'manual', the IIFE bails AND syncs its in-memory state so
subsequent turns respect the rename too. Cost: one 64KB tail read per
successful generation; negligible.

Abort propagation on `finalize()`

autoTitleController doubles as the in-flight flag. finalize() (run
on session switch and process shutdown) calls
autoTitleController.abort() before re-appending the title record. The
LLM socket is cancelled promptly; session switch doesn't wait on a slow
fast-model call. The IIFE's finally block clears
autoTitleController only if it's still the active one, so a finalize
mid-flight doesn't race a concurrent recordAssistantTurn.

Explicit rename lands mid-flight

Every public recordCustomTitle() call increments a pending-explicit counter
and aborts the active background controller before it waits for JSONL. This
includes /rename --auto, so a model-generated explicit rename has the same
priority as a literal user title. The background task checks the counter,
abort signal, current title, and writer state both after generation and just
before persistence. Concurrent explicit renames are serialized by the writer;
their in-memory results commit in that same order. If an explicit title arrives
after a background title is already queued, normal writer ordering makes the
explicit title the final record.

While an explicit title write is pending, finalize() does not re-anchor the
previous cached title. Otherwise that stale record could be queued behind the
rename and become the JSONL tail after the new title successfully lands.
Title-anchor accounting follows logical writer queue order. Descendant records
queued behind any unresolved title write are counted, but threshold re-anchor
is deferred until the final cached title is known, so neither shutdown nor the
32KB anchor path can append a stale title behind a successful rename.

The cross-process re-read still protects manual titles written by another CLI
process. It is not a compare-and-swap; an external rename in the final check-to-
write window remains an accepted race.

Branch title ordering

/branch finalizes and flushes the source recorder before copying it. It then
loads the provisional fork, computes a unique branch title, persists that title
through SessionService, and reloads the fork so its final tail and title cache
include the appended record. Only after those steps succeed does it switch the
core recorder and UI. A source flush failure, title failure, or final reload
failure deletes the incomplete fork and leaves the original session active.
forkSession() also removes its exclusive-created target if writing or closing
the new JSONL fails before the call can report success, so the caller cannot
leak a partial file during the window before it marks the fork as created.

Daemon boundary

Live ACP title operations await recordCustomTitle() and therefore report a
truthful persisted or success result without a second flush() that could
attribute a later unrelated write failure to the rename. The daemon metadata
PATCH remains intentionally optimistic: it updates live state and publishes
its event immediately, while child persistence is best-effort and a false
result is debug-logged. Strongly consistent HTTP metadata updates are outside
this design.

Configuration

User-facing knobs

Setting / env var Default Effect
fastModel unset Required for auto-titling. Unset → no-op (no main-model fallback).
QWEN_DISABLE_AUTO_TITLE=1 unset Opt out of the auto trigger without unsetting fastModel. /rename --auto still works on request.

No settings.json toggle — the env var is the only user-visible
off-switch. Rationale: the feature is cosmetic and cheap; a settings
toggle would add a UI surface for something that can live as a one-time
env export for the few users who want to disable it.

Why auto doesn't fall back to the main model

Auto-titling is triggered unconditionally after every assistant turn.
If a user without a fast model were silently charged main-model tokens
for every new session's title, the cost delta is invisible until the
monthly bill arrives. Failing quietly (no-op, no title, no cost) is the
safer default. /rename --auto surfaces no_fast_model as an
actionable error so the user can set one if they want to.

Observability

createDebugLogger('SESSION_TITLE') emits debugLogger.warn from the
generator's catch block. Failures are fully transparent to the user —
auto-title is an auxiliary feature and never throws into the UI.

Developers can grep for the [SESSION_TITLE] tag in the debug log
(~/.qwen/debug/<sessionId>.txt; latest.txt symlinks to the current
session). A working end-to-end call produces no log output; a failing
one gets one WARN line with the underlying error message.

Security Hardening

The title value is rendered verbatim in the terminal (session picker)
AND persisted in a user-readable JSONL file. Both surfaces are attack
reachable if a compromised or prompt-injected fast model returns
hostile text.

Concern Guard
ANSI / OSC-8 / CSI injection stripTerminalControlSequences before both JSONL write and picker render.
Clickable-link smuggle via OSC-8 Same — OSC sequences stripped as whole units, not just the ESC byte.
Invalid UTF-16 surrogates Scrubbed in flattenToTail (LLM input) and sanitizeTitle (LLM output after max-length trim).
Subtype-line spoof via user message content lineContains: '"subtype":"custom_title"' — user text that happens to contain the literal phrase can't shadow a real record.
Symlink redirect on session reads O_NOFOLLOW (no-op on Windows where the constant is missing).
Truncated trailing JSONL record extractLastJsonStringFields requires a closing quote before a record wins the latest-match race.
Pathological file size freezing the picker MAX_FULL_SCAN_BYTES = 64 MB cap on Phase-2 full-file scan.
Paired CJK bracket decorators (【Draft】) Stripped as a unit so a lone closing bracket doesn't dangle.

Out of Scope

Item Why not
Auto-regenerate when the title goes stale /rename --auto is the explicit user-triggered path. Silent mid-session title swaps would confuse users scrolling back through the picker.
WebUI / VSCode dim-styling parity Those surfaces read customTitle already and will show auto titles as if manual. A follow-up can wire the titleSource through.
Settings-dialog toggle for auto generation Env var is the single knob. Full settings UI is easy to add later if user demand surfaces.
i18n locale catalog entries for new strings Consistent with existing /rename strings, which fall through to English. A repo-wide i18n pass is out of scope.
Migration to re-classify legacy records Back-compat by design: absent titleSource is treated as manual. Rewriting old records would risk losing user intent.
Non-interactive auto-titling qwen -p / CI scripts throw the session away; fast-model tokens for a title no one will ever resume is pure waste.

Design Slash Command Compare (`docs/design/slash-command/compare.md`)

Qwen Code Command 模块重构方案

1. 目标定义

本方案以以下原则为唯一前提:

  • 代码架构可以不照搬 Claude Code
  • 但命令系统的核心功能、使用体验、交互体验必须 95% 对齐 Claude Code

这里的“对齐”指用户可直接感知的能力,包括:

  1. 命令来源覆盖
  2. 命令帮助与发现性
  3. 命令补全与 mid-input slash command 体验
  4. ACP / non-interactive 可用性
  5. prompt command / skill 的模型调用能力

本次重构不是补几个字段,也不是把现有 SlashCommand 小修小补,而是把 command 模块从“interactive UI 附属能力”升级为“跨 interactive / ACP / non-interactive / model 的统一命令平台”。


2. 重写后的结论

Qwen 现有 command 系统的问题,不是完全没有能力,而是:

  1. 只在 interactive 主路径上较完整
  2. 类型模型太薄,无法承载 Claude 级别的产品面
  3. ACP / non-interactive 依赖白名单,扩展性极差
  4. command 来源虽然存在,但没有形成对用户可见的统一心智
  5. prompt command 与模型 skill 暴露体系割裂

因此新的方案必须同时解决四件事:

  1. 补齐 Claude Code 的能力面
  2. 保留 Qwen 统一 outcome 模型的工程优势
  3. 建立统一 registry / resolver / executor / adapter 架构
  4. 让帮助、补全、ACP available commands、文档共用同一套元数据

3. 重构原则

3.1 功能对齐优先于实现对齐

允许不同:

  • 内部类名
  • 模块拆分方式
  • 执行器实现
  • effect / outcome 结构

不允许不同:

  • 命令来源覆盖明显缩水
  • 命令帮助和补全体验明显缩水
  • ACP / non-interactive 可用性明显缩水
  • prompt command 与模型能力融合明显缩水

如果出现取舍,优先级应为:

  1. 用户体验对齐
  2. 命令能力覆盖对齐
  3. 模式一致性对齐
  4. 内部实现简洁

3.2 保留 Qwen 的统一 outcome 模型

不建议机械复制 Claude 的执行实现。

Qwen 当前统一结果模型仍然值得保留,因为它天然适合:

  • UI 接管
  • 审批/确认
  • tool 调度
  • prompt 提交
  • 跨模式适配

但它必须被升级为能够承载 Claude 级别的 command 能力,而不是继续作为简化版 UI 命令框架存在。

3.3 类型、来源、模式、可见性必须彻底解耦

新的 command 模型至少要把以下维度拆开:

  1. 类型:命令怎么执行
  2. 来源:命令从哪里来
  3. 模式能力:在哪些运行环境可用
  4. 可见性:对用户可见还是对模型可见

4. 需要对齐的 Claude Code 能力面

4.1 命令类型

Qwen 需要显式支持三类命令:

  1. prompt
  2. local
  3. local-jsx

4.2 命令来源

Qwen 的 command schema 从第一阶段开始就必须覆盖以下来源:

  1. built-in commands
  2. bundled skills
  3. skill dir commands
  4. workflow commands
  5. plugin commands
  6. plugin skills
  7. dynamic skills
  8. mcp prompts
  9. mcp skills

这里不能再退回到“先只支持当前已有那几类”。

4.3 命令元数据

至少补齐以下字段:

  1. argumentHint
  2. whenToUse
  3. examples
  4. sourceLabel
  5. userFacingName
  6. alias
  7. immediate
  8. isSensitive
  9. userInvocable
  10. modelInvocable
  11. supportedModes
  12. requiresUi

4.4 体验能力

至少补齐以下体验:

  1. alias 命中补全
  2. source badge
  3. 参数提示
  4. recently used 排序
  5. mid-input slash command 检测与补全
  6. 命令目录式 Help
  7. ACP available commands 的完整表达

5. 新 command 模型

5.1 核心结构

建议引入统一 CommandDescriptor,作为所有命令的注册格式。

它至少包含四部分:

  1. identity
  2. metadata
  3. capabilities
  4. handler

`identity`

  • id
  • name
  • altNames
  • canonicalPath

`metadata`

  • description
  • argumentHint
  • whenToUse
  • examples
  • group
  • source
  • sourceLabel
  • userFacingName
  • hidden

`capabilities`

  • type: prompt | local | local-jsx
  • supportedModes: interactive | acp | non_interactive
  • requiresUi
  • supportsDialog
  • supportsStreaming
  • supportsToolInvocation
  • supportsConfirmation
  • remoteSafe
  • readOnly
  • immediate
  • isSensitive
  • userInvocable
  • modelInvocable

`handler`

  • resolveArgs()
  • execute()
  • completion()
  • fallback()

5.2 三种命令类型的职责

`prompt`

用于:

  • skills
  • file commands
  • workflow prompt commands
  • plugin skills
  • mcp prompt / skill

特点:

  • 产生 prompt / skill 资产
  • 默认支持 interactive / ACP / non-interactive
  • 可以被用户调用,也可以被模型调用

`local`

用于:

  • 查询类命令
  • 配置类命令
  • headless 可执行的状态类命令
  • 大多数 built-in commands 的核心执行入口

特点:

  • 不依赖 UI
  • 应成为 ACP / non-interactive 的主承载类型

`local-jsx`

用于:

  • picker
  • 面板
  • wizard
  • interactive UI shell

特点:

  • 只处理 interactive UI
  • 不能再作为唯一执行入口
  • 必须提供 fallback 或对应 local 子命令

6. 命令来源模型

6.1 外部来源模型

这是给用户看的来源模型,必须和 Claude Code 的心智尽量一致:

  • builtin-command
  • bundled-skill
  • skill-dir-command
  • workflow-command
  • plugin-command
  • plugin-skill
  • dynamic-skill
  • builtin-plugin-skill
  • mcp-prompt
  • mcp-skill

这组字段将直接用于:

  • Help 分组
  • Completion source badge
  • ACP available commands
  • 文档导出

6.2 内部归一化模型

为了不被外部命名绑死,内部再补一层实现字段:

  • providerType
  • artifactType
  • activationMode
  • builtinProvided
  • originPath
  • namespace

这样可以做到:

  • 外部体验按 Claude 对齐
  • 内部实现仍保持 Qwen 可维护性

6.3 冲突策略

统一按稳定 id 管理,展示名和输入名分离:

  1. id:稳定唯一标识
  2. name:输入主名
  3. userFacingName:帮助/补全展示名

冲突优先级建议:

  1. built-in
  2. bundled / skill-dir / workflow
  3. plugin / builtin-plugin
  4. dynamic
  5. mcp 独立 namespace

7. 统一执行架构

7.1 `CommandRegistry`

职责:

  1. 聚合所有 loader/provider
  2. 建立多维索引
  3. 输出帮助、补全、ACP、文档视图
  4. 提供用户可见命令和模型可见命令的独立视图

必须支持的 provider:

  1. BuiltinCommandLoader
  2. BundledSkillLoader
  3. FileCommandLoader
  4. McpPromptLoader
  5. WorkflowCommandLoader
  6. PluginCommandLoader
  7. PluginSkillLoader
  8. DynamicSkillProvider
  9. BuiltinPluginSkillLoader

即便部分 provider 首期未完全落地,schema 和 API 也必须先支持。

7.2 `CommandResolver`

职责:

  1. 解析 slash command
  2. 解析 alias
  3. 解析 subcommand path
  4. 识别 mid-input slash token
  5. 输出 canonical resolved command

7.3 `CommandExecutor`

职责:

  1. 做 capability 检查
  2. 执行 prompt | local | local-jsx
  3. 统一产出 outcome
  4. 处理 fallback / unsupported

7.4 `ModeAdapter`

必须拆出三种 adapter:

  1. InteractiveModeAdapter
  2. AcpModeAdapter
  3. NonInteractiveModeAdapter

这样三种模式才能共用同一套 command registry 和 executor,而不是各自硬编码。


8. UI 命令重构原则:核心命令与交互壳分离

这是 ACP 和 non-interactive 真正可用的关键。

凡是当前本质为“打开 dialog”的命令,都必须改造成:

  1. 一个 interactive shell
  2. 一组 local 子命令

第一批必须拆分的命令

  1. /model
  2. /permissions
  3. /mcp
  4. /resume
  5. /hooks
  6. /extensions
  7. /agents
  8. /approval-mode

目标形态示例

`/model`

  • /model
  • /model show
  • /model list
  • /model set

`/permissions`

  • /permissions
  • /permissions show
  • /permissions set <mode>
  • /permissions allow <tool>
  • /permissions deny <tool>

`/mcp`

  • /mcp
  • /mcp list
  • /mcp show <server>
  • /mcp enable <server>
  • /mcp disable <server>

9. Prompt Command / Skill 统一设计

这是重构里的 P0,不是后补能力。

9.1 目标

建立统一的 Model-Invocable Prompt Command Registry,把以下资产合并为一个模型可调用视图:

  1. bundled skills
  2. file commands
  3. workflow prompt commands
  4. plugin skills
  5. mcp prompts / mcp skills

9.2 关键字段

必须新增:

  1. userInvocable
  2. modelInvocable
  3. allowedTools
  4. whenToUse
  5. argSchema 或最小参数描述
  6. contextMode: inline | fork
  7. agent
  8. effort

9.3 与 `SkillTool` 的关系

重构后不应再由 SkillTool 只消费狭义 skills。

应改成:

  1. CommandRegistry.getModelInvocablePromptCommands() 产出统一视图
  2. SkillTool 或未来统一 command tool 消费该视图
  3. 用户 slash command 与模型 skill invocation 共用同一套 prompt-command 资产池

这样 Qwen 才能在体验上接近 Claude 对 /review、/commit、/openspec-apply 这类能力的处理方式。


10. Help / Completion / Discoverability 重做

10.1 Completion

补全项至少要展示:

  1. label
  2. description
  3. argumentHint
  4. sourceBadge
  5. modeBadges
  6. aliasHit
  7. recentlyUsedScore

排序至少考虑:

  1. 精确命中
  2. alias 命中
  3. 最近使用
  4. prefix 命中
  5. fuzzy 命中

10.2 Mid-input slash command

必须补齐:

  1. 光标附近 slash token 检测
  2. ghost text 提示
  3. Tab 完成
  4. 有效命令 token 高亮

第一阶段先对齐输入体验;是否引入更强的“内嵌命令执行语义”可在后续迭代。

10.3 Help

Help 不再是平铺列表,而是完整命令目录。

至少分组为:

  1. Built-in Commands
  2. Bundled Skills
  3. Skill Dir Commands
  4. Workflow Commands
  5. Plugin Commands
  6. Plugin Skills
  7. Dynamic Skills
  8. Builtin Plugin Skills
  9. MCP Commands / MCP Skills

每条命令至少展示:

  1. 名称
  2. 参数提示
  3. 描述
  4. 来源
  5. 支持模式
  6. 是否模型可调用
  7. 子命令摘要

11. ACP / Non-Interactive 重构

11.1 彻底废弃白名单思路

旧方案:

  • built-in allowlist
  • FILE / SKILL 特判
  • 其它结果类型 unsupported

新方案:

  • 每个命令自己声明 capability
  • registry 负责过滤
  • adapter 负责执行和 fallback

11.2 outcome 支持目标

interactive

  • submit_prompt
  • message
  • stream_messages
  • tool
  • dialog
  • load_history
  • confirm_action
  • confirm_shell_commands

acp

  • submit_prompt
  • message
  • stream_messages
  • tool
  • confirm_action
  • confirm_shell_commands
  • dialog fallback

non_interactive

  • submit_prompt
  • message
  • stream_messages
  • tool
  • confirm_action
  • confirm_shell_commands
  • dialog fallback / structured failure

11.3 ACP available commands 输出

必须至少包含:

  1. name
  2. description
  3. argumentHint
  4. source
  5. examples
  6. supportedModes
  7. interactiveOnly
  8. subcommands
  9. modelInvocable

12. 文档、帮助、补全共用同一份元数据

重构后以下内容必须由同一个 registry 视图导出:

  1. Help
  2. Completion
  3. ACP available commands
  4. 文档导出

这是为了解决当前“实现、帮助、文档三套命令面不一致”的问题。


13. 实施分期

Phase 1:底座重建

交付:

  1. 新 CommandDescriptor
  2. 完整来源 schema
  3. capability 模型
  4. userInvocable / modelInvocable
  5. CommandRegistry
  6. CommandResolver
  7. CommandExecutor
  8. 三种 ModeAdapter
  9. getModelInvocablePromptCommands()

Phase 2:核心命令迁移

交付:

  1. /model
  2. /permissions
  3. /mcp
  4. /resume
  5. /hooks
  6. /extensions
  7. /agents
  8. /approval-mode

这些命令都必须完成“interactive shell + local 子命令”重构。

Phase 3:模型能力打通

交付:

  1. SkillTool 接入统一 registry 视图
  2. file command / bundled skill / mcp prompt / plugin skill 进入统一 model-invocable 集合
  3. prompt command 与 skill 资产彻底统一

Phase 4:体验层对齐 Claude

交付:

  1. recently used 排序
  2. source badge
  3. argument hint
  4. mode badge
  5. 完整 help 目录
  6. mid-input slash command 体验
  7. 文档自动导出或校验

14. 验收标准

完成后至少满足:

  1. 帮助、补全、ACP、文档都能表达完整来源模型
  2. 除纯 UI 壳命令外,大多数 built-in command 可在 ACP / non-interactive 使用
  3. prompt command 与模型 skill 调用使用同一套资产池
  4. 命令体验在帮助、补全、来源表达、参数提示、mid-input 体验上达到 Claude Code 95% 水平
  5. 不再依赖 built-in allowlist 维持 ACP / non-interactive 命令能力

15. 最终判断

这次重构的本质不是“给现有 SlashCommand 多加几个字段”,而是:

  • 用 Qwen 的内部架构风格,交付一个在外部体验上 95% 对齐 Claude Code 的 command 平台

如果必须二选一:

  • 内部实现更像 Claude
  • 外部体验更像 Claude

本方案明确选择后者。


Design Slash Command Phase1 Technical Design (`docs/design/slash-command/phase1-technical-design.md`)

Phase 1 技术设计文档:基础设施重建

1. 设计目标与约束

1.1 目标

  • 建立统一的命令元数据模型,覆盖来源(source)、执行类型(commandType)、模式能力(supportedModes)、可见性(userInvocable / modelInvocable)四个维度
  • 用 capability-based 过滤替换 non-interactive/acp 中的硬编码白名单
  • 为 Phase 2/3 的能力扩展提供稳定的底层接口

1.2 硬性约束

  • 零行为变化:non-interactive 和 acp 模式下现有可用命令集保持不变(例外:修复 MCP_PROMPT 被错误拦截,属于 bug fix)
  • 向后兼容:SlashCommand 接口的新增字段全部为可选或有合理默认值,现有命令代码无需立即修改
  • 不新增执行器:不创建 ModeAdapter / CommandExecutor 等新执行架构,只扩展现有 CommandService 和过滤逻辑
  • 不改变现有命令能力:不为任何命令新增 local 子命令,不修改任何命令的 action 实现

2. 新增类型定义

2.1 文件位置

所有新增类型定义在 packages/cli/src/ui/commands/types.ts,与现有 SlashCommand 接口共文件。

2.2 `ExecutionMode`

typescript
/**
 * 运行模式枚举。
 * - interactive:React/Ink UI 模式(终端交互)
 * - non_interactive:无交互 CLI 模式(文本/JSON 输出)
 * - acp:ACP/Zed 集成模式
 */
export type ExecutionMode = 'interactive' | 'non_interactive' | 'acp';

2.3 `CommandSource`

typescript
/**
 * 命令来源枚举,用于 Help 分组、补全 badge、ACP available commands。
 *
 * 与 CommandKind 的区别:
 * - CommandKind 是内部加载器分类(4 种),影响加载逻辑
 * - CommandSource 是面向用户的来源分类(9 种),影响展示和心智模型
 *
 * 两者可能重叠,但职责不同,不合并。
 */
export type CommandSource =
  | 'builtin-command' // 内置命令(BuiltinCommandLoader)
  | 'bundled-skill' // 随包分发的 skill(BundledSkillLoader)
  | 'skill-dir-command' // 用户/项目 .qwen/commands/ 下的文件命令(FileCommandLoader,非插件)
  | 'plugin-command' // 插件提供的命令(FileCommandLoader,extensionName 不为空)
  | 'mcp-prompt'; // MCP server 提供的 prompt(McpPromptLoader)
// 以下来源预留,Phase 1 不实现对应 Loader,但 schema 先定义:
// | 'workflow-command'
// | 'plugin-skill'
// | 'dynamic-skill'
// | 'builtin-plugin-skill'
// | 'mcp-skill'

2.4 `CommandType`

typescript
/**
 * 命令执行类型,描述命令"怎么执行"。
 *
 * - prompt:产生 submit_prompt,将内容提交给模型。适用于 skill、file command、MCP prompt。
 *   默认 supportedModes 为所有模式,默认 modelInvocable 为 true。
 *
 * - local:在本地执行逻辑,不依赖 React/Ink UI。可返回 message、stream_messages、
 *   submit_prompt、tool 等类型。适用于查询类、配置类、状态类 built-in 命令。
 *   默认 supportedModes 为 ['interactive'],需显式声明 supportedModes 才能开放给其他模式。
 *   这与 Claude Code 的 supportsNonInteractive: true 语义一致——非交互支持需要显式声明,而非自动推断。
 *
 * - local-jsx:依赖 React/Ink UI 的命令(打开 dialog、渲染 JSX 组件等)。
 *   默认 supportedModes 仅为 ['interactive']。
 */
export type CommandType = 'prompt' | 'local' | 'local-jsx';

2.5 扩展 `SlashCommand` 接口

在现有接口上追加新字段,全部为可选以保证向后兼容:

text
/* Detailed source-code truncated for AI context efficiency. */

3. 各 Loader 的字段填充规范

3.1 填充原则

  • source 和 sourceLabel 由 Loader 在构建 SlashCommand 时填充,命令自身不声明
  • commandType:Loader 填充默认值;built-in 命令由命令文件自身声明
  • supportedModes:通过 getEffectiveSupportedModes() 推断,不需要显式填充(除非需要覆盖默认值)
  • modelInvocable:Loader 填充,built-in 命令始终为 false,prompt 类型命令为 true

3.2 `BuiltinCommandLoader`

typescript
// 不填充 source/sourceLabel/commandType — 由各命令文件自声明
// 因为 built-in 命令的 commandType 是 local 或 local-jsx,需要逐个标注

// 注入 source 和 sourceLabel:
for (const cmd of rawCommands) {
  enrichedCommands.push({
    ...cmd,
    source: 'builtin-command',
    sourceLabel: 'Built-in',
    userInvocable: cmd.userInvocable ?? true,
    modelInvocable: false, // built-in 命令不允许模型调用
  });
}

3.3 `BundledSkillLoader`

typescript
return skills.map((skill) => ({
  name: skill.name,
  description: skill.description,
  kind: CommandKind.SKILL,
  source: 'bundled-skill' as CommandSource,
  sourceLabel: 'Skill',
  commandType: 'prompt' as CommandType,
  userInvocable: true,
  modelInvocable: true,
  action: async (...) => { ... },
}));

3.4 `FileCommandLoader`

typescript
// 在 createSlashCommandFromDefinition 中:
return {
  name: baseCommandName,
  description,
  kind: CommandKind.FILE,
  extensionName,
  // source 根据 extensionName 决定:
  source: extensionName ? 'plugin-command' : 'skill-dir-command',
  sourceLabel: extensionName ? `Plugin: ${extensionName}` : 'Custom',
  commandType: 'prompt',
  userInvocable: true,
  modelInvocable: !extensionName, // 插件命令暂不允许模型调用,用户/项目命令允许
  action: async (...) => { ... },
};

注:插件命令(plugin-command)暂不标记为 modelInvocable,避免安全隐患。后续 Phase 可以按需开放,由用户通过配置控制。

3.5 `McpPromptLoader`

typescript
const newPromptCommand: SlashCommand = {
  name: commandName,
  description: prompt.description || `Invoke prompt ${prompt.name}`,
  kind: CommandKind.MCP_PROMPT,
  source: 'mcp-prompt',
  sourceLabel: `MCP: ${serverName}`,
  commandType: 'prompt',
  userInvocable: true,
  modelInvocable: true,
  // ... 其余现有字段
};

4. Built-in 命令的 `commandType` 声明规范

4.1 分类标准

commandType 判断标准
local action 只使用 ui.addItem(文本类型)、返回 message / stream_messages / submit_prompt / tool,不依赖 React 组件渲染
local-jsx action 返回 dialog,或 action 中调用 ui.addItem 时传入含 JSX 的复杂类型(如 HistoryItemHelp、HistoryItemStats),或依赖 confirm_action / load_history / quit

注意:ui.addItem(message/error/info 类型) 是 local;ui.addItem(help/stats/tools/about 等复杂 UI 类型) 是 local-jsx。

4.2 Built-in 命令分类表

local 类(声明 commandType: 'local',supportedModes 推断为 all modes):

命令文件 命令名 说明
btwCommand.ts btw 返回 submit_prompt 或 stream_messages
bugCommand.ts bug 返回 submit_prompt 或 stream_messages
compressCommand.ts compress 已有 executionMode 适配,返回 message/submit_prompt
contextCommand.ts context 返回 message(含 UI 渲染但文本可替代)
exportCommand.ts export 文件 I/O,返回 message
initCommand.ts init 返回 submit_prompt/message/confirm_action
memoryCommand.ts memory 子命令返回 message(文件 I/O)
planCommand.ts plan 返回 submit_prompt
summaryCommand.ts summary 已有 executionMode 适配,返回 submit_prompt/message
insightCommand.ts insight 返回 stream_messages

注意:contextCommand 和 insightCommand 虽然当前返回 addItem 调用,但其本质是文本内容,属于 local。

local-jsx 类(声明 commandType: 'local-jsx',supportedModes 推断为 ['interactive']):

命令文件 命令名 不能 headless 的原因
aboutCommand.ts about addItem(HistoryItemAbout) — 复杂 UI 组件
agentsCommand.ts agents dialog: subagent_create/subagent_list
approvalModeCommand.ts approval-mode dialog: approval-mode
arenaCommand.ts arena dialog: arena_*
authCommand.ts auth dialog: auth
clearCommand.ts clear ui.clear() 直接操作终端
copyCommand.ts copy 剪贴板操作,无 headless 路径
directoryCommand.tsx directory JSX 组件
docsCommand.ts docs 打开浏览器
editorCommand.ts editor dialog: editor
extensionsCommand.ts extensions dialog: extensions_manage
helpCommand.ts help addItem(HistoryItemHelp) — 复杂 Help UI
hooksCommand.ts hooks dialog: hooks
ideCommand.ts ide IDE 进程检测与交互
languageCommand.ts language dialog + reloadCommands
mcpCommand.ts mcp dialog: mcp
modelCommand.ts model dialog: model/fast-model
permissionsCommand.ts permissions dialog: permissions
quitCommand.ts quit quit result 类型
restoreCommand.ts restore load_history result 类型
resumeCommand.ts resume dialog: resume
settingsCommand.ts settings dialog: settings
setupGithubCommand.ts setup-github confirm_shell_commands + 交互式操作
skillsCommand.ts skills addItem(HistoryItemSkillsList) — 复杂 UI
statsCommand.ts stats addItem(HistoryItemStats) — 复杂 UI
statuslineCommand.ts statusline UI 状态配置
terminalSetupCommand.ts terminal-setup 终端配置向导
themeCommand.ts theme dialog: theme
toolsCommand.ts tools addItem(HistoryItemTools) — 复杂 UI
trustCommand.ts trust dialog: trust
vimCommand.ts vim toggleVimEnabled() — UI 状态

5. `getEffectiveSupportedModes` 推断规则

此函数是 Phase 1 的核心逻辑,替代原有白名单,将被 filterCommandsForMode 调用。

typescript
/**
 * 获取命令的实际支持模式列表。
 *
 * 推断优先级(从高到低):
 * 1. 命令显式声明的 supportedModes(最高优先级)
 * 2. 基于 commandType 的推断
 * 3. 基于 CommandKind 的兜底(向后兼容)
 */
export function getEffectiveSupportedModes(cmd: SlashCommand): ExecutionMode[] {
  // 优先级 1:显式声明
  if (cmd.supportedModes !== undefined) {
    return cmd.supportedModes;
  }

  // 优先级 2:基于 commandType 推断
  if (cmd.commandType !== undefined) {
    switch (cmd.commandType) {
      case 'prompt':
        // prompt 类型无 UI 依赖,天然全模式可用
        return ['interactive', 'non_interactive', 'acp'];
      case 'local':
        // local 类型保守默认:仅 interactive。
        // 需要非交互支持的命令须显式声明 supportedModes(对应 Claude Code 的 supportsNonInteractive: true)。
        // Phase 2 中逐个验证并解锁,防止未适配的命令意外暴露给 headless 调用者。
        return ['interactive'];
      case 'local-jsx':
        return ['interactive'];
    }
  }

  // 优先级 3:兜底(基于 CommandKind,向后兼容旧代码)
  switch (cmd.kind) {
    case CommandKind.BUILT_IN:
      // built-in 命令未声明 commandType 时保守默认(interactive only)
      // 这个分支在 Phase 1 完成后应不再被命中(所有 built-in 都有 commandType)
      return ['interactive'];
    case CommandKind.FILE:
    case CommandKind.SKILL:
    case CommandKind.MCP_PROMPT:
      // 这三类命令的 action 天然无 UI 依赖,历史行为也是全模式可用
      return ['interactive', 'non_interactive', 'acp'];
    default:
      return ['interactive'];
  }
}
typescript
/**
 * 根据 supportedModes 过滤适合当前模式的命令。
 * 替代原 filterCommandsForNonInteractive 函数。
 */
export function filterCommandsForMode(
  commands: readonly SlashCommand[],
  mode: ExecutionMode,
): SlashCommand[] {
  return commands.filter((cmd) =>
    getEffectiveSupportedModes(cmd).includes(mode),
  );
}

6. `CommandService` 接口扩展

在 packages/cli/src/services/CommandService.ts 中新增两个方法:

typescript
export class CommandService {
  // ── 现有方法(保持不变)────────────────────────────────────────────────
  getCommands(): readonly SlashCommand[] {
    return this.commands;
  }

  // ── Phase 1 新增方法 ──────────────────────────────────────────────────

  /**
   * 返回在指定执行模式下可用的命令列表。
   * 替代原有白名单 + filterCommandsForNonInteractive 的组合。
   *
   * @param mode 目标运行模式
   * @returns 适合该模式的命令列表(不含 hidden 命令)
   */
  getCommandsForMode(mode: ExecutionMode): readonly SlashCommand[] {
    return this.commands.filter((cmd) => {
      if (cmd.hidden) return false;
      return getEffectiveSupportedModes(cmd).includes(mode);
    });
  }

  /**
   * 返回所有 modelInvocable 为 true 的命令。
   * Phase 2 中 SkillTool 将消费此方法;Phase 1 仅提供接口。
   *
   * @returns 模型可调用的命令列表
   */
  getModelInvocableCommands(): readonly SlashCommand[] {
    return this.commands.filter(
      (cmd) => !cmd.hidden && cmd.modelInvocable === true,
    );
  }
}

注意:getEffectiveSupportedModes 和 filterCommandsForMode 应作为 CommandService 内部使用的工具函数,或提取到独立的 packages/cli/src/services/commandUtils.ts 文件并导出,以便测试和复用。


7. `nonInteractiveCliCommands.ts` 重构

7.1 删除内容

typescript
// ❌ 删除
export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
  'init', 'summary', 'compress', 'btw', 'bug', 'context',
] as const;

// ❌ 删除
function filterCommandsForNonInteractive(
  commands: readonly SlashCommand[],
  allowedBuiltinCommandNames: Set<string>,
): SlashCommand[] { ... }

7.2 新增内容

typescript
// ✅ 新增(或从 commandUtils 导入)
import { filterCommandsForMode } from '../services/commandUtils.js';

7.3 `handleSlashCommand` 函数签名变更

typescript
// ❌ 旧签名
export const handleSlashCommand = async (
  rawQuery: string,
  abortController: AbortController,
  config: Config,
  settings: LoadedSettings,
  allowedBuiltinCommandNames: string[] = [...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE],
): Promise<NonInteractiveSlashCommandResult>

// ✅ 新签名(移除 allowedBuiltinCommandNames)
export const handleSlashCommand = async (
  rawQuery: string,
  abortController: AbortController,
  config: Config,
  settings: LoadedSettings,
): Promise<NonInteractiveSlashCommandResult>

7.4 内部实现变更

typescript
// 旧:
const filteredCommands = filterCommandsForNonInteractive(
  allCommands,
  allowedBuiltinSet,
);

// 新:
const executionMode = isAcpMode ? 'acp' : 'non_interactive';
const filteredCommands = filterCommandsForMode(allCommands, executionMode);

7.5 `getAvailableCommands` 函数签名变更

typescript
// ❌ 旧签名
export const getAvailableCommands = async (
  config: Config,
  abortSignal: AbortSignal,
  allowedBuiltinCommandNames: string[] = [...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE],
): Promise<SlashCommand[]>

// ✅ 新签名
export const getAvailableCommands = async (
  config: Config,
  abortSignal: AbortSignal,
  mode: ExecutionMode = 'acp',
): Promise<SlashCommand[]>

新增 mode 参数替代原来的白名单参数,ACP Session 调用时可明确指定 'acp',non-interactive 调用时指定 'non_interactive'。


8. `Session.ts`(ACP)调用变更

typescript
// ❌ 旧调用
const slashCommandResult = await handleSlashCommand(
  inputText,
  abortController,
  this.config,
  this.settings,
  // 不传,使用默认白名单
);

// ✅ 新调用(无变化,移除了不再存在的默认参数)
const slashCommandResult = await handleSlashCommand(
  inputText,
  abortController,
  this.config,
  this.settings,
);

// ─────────────────────────────────────────

// ❌ 旧调用
const slashCommands = await getAvailableCommands(
  this.config,
  abortController.signal,
);

// ✅ 新调用(明确指定 mode)
const slashCommands = await getAvailableCommands(
  this.config,
  abortController.signal,
  'acp',
);

9. 文件变更总览

9.1 修改的文件

文件 修改内容
packages/cli/src/ui/commands/types.ts 新增 ExecutionMode、CommandSource、CommandType 类型;扩展 SlashCommand 接口
packages/cli/src/services/CommandService.ts 新增 getCommandsForMode()、getModelInvocableCommands() 方法
packages/cli/src/nonInteractiveCliCommands.ts 删除白名单常量和旧过滤函数;更新两个导出函数的签名;引入 filterCommandsForMode
packages/cli/src/acp-integration/session/Session.ts 更新 handleSlashCommand 和 getAvailableCommands 调用
packages/cli/src/services/BuiltinCommandLoader.ts 在构建命令时注入 source: 'builtin-command'、sourceLabel: 'Built-in'、modelInvocable: false
packages/cli/src/services/BundledSkillLoader.ts 注入 source: 'bundled-skill'、commandType: 'prompt'、modelInvocable: true
packages/cli/src/services/FileCommandLoader.ts / command-factory.ts 注入 source、commandType: 'prompt'、modelInvocable(根据 extensionName)
packages/cli/src/services/McpPromptLoader.ts 注入 source: 'mcp-prompt'、commandType: 'prompt'、modelInvocable: true
各 built-in 命令文件(10 个 local + 27 个 local-jsx) 声明 commandType: 'local' 或 commandType: 'local-jsx'

9.2 新增的文件

文件 内容
packages/cli/src/services/commandUtils.ts getEffectiveSupportedModes()、filterCommandsForMode() 工具函数及其导出

9.3 不变的文件

  • packages/cli/src/ui/commands/commands.ts(parseSlashCommand 无需修改)
  • packages/cli/src/ui/hooks/slashCommandProcessor.ts(interactive 路径无需修改)
  • packages/cli/src/ui/noninteractive/nonInteractiveUi.ts(stub UI 无需修改)
  • 所有命令的 action 实现(Phase 1 不修改任何命令行为)

10. 行为影响分析

10.1 变化汇总

场景 旧行为 新行为 性质
non-interactive 下执行 /init ✅ 允许(白名单) ✅ 允许(commandType: local) 无变化
non-interactive 下执行 /summary ✅ 允许 ✅ 允许 无变化
non-interactive 下执行 /compress ✅ 允许 ✅ 允许 无变化
non-interactive 下执行 /btw ✅ 允许 ✅ 允许 无变化
non-interactive 下执行 /bug ✅ 允许 ✅ 允许 无变化
non-interactive 下执行 /context ✅ 允许 ✅ 允许 无变化
non-interactive 下执行 /model ❌ unsupported ❌ unsupported(commandType: local-jsx) 无变化
non-interactive 下执行 file command ✅ 允许(CommandKind.FILE) ✅ 允许(commandType: prompt) 无变化
non-interactive 下执行 bundled skill ✅ 允许(CommandKind.SKILL) ✅ 允许(commandType: prompt) 无变化
non-interactive 下执行 MCP prompt ❌ 被 CommandKind 拦截 ✅ 允许(commandType: prompt) Bug fix
non-interactive 下执行 /export ❌ 不在白名单 ❌ 不允许(commandType: local,默认 interactive only) 无变化
non-interactive 下执行 /memory ❌ 不在白名单 ❌ 不允许(commandType: local,默认 interactive only) 无变化
non-interactive 下执行 /plan ❌ 不在白名单 ❌ 不允许(commandType: local,默认 interactive only) 无变化

关于 local 命令的保守默认策略:commandType: 'local' 的默认 supportedModes 为 ['interactive'],这与 Claude Code 的设计一致——local 类型命令需要显式声明 supportsNonInteractive: true 才能在非交互模式下运行。Phase 1 中白名单内的 6 个命令(init、summary、compress、btw、bug、context)通过显式声明 supportedModes: ['interactive', 'non_interactive', 'acp'] 来等价替换原白名单效果。Phase 2 中需要扩展的命令(如 /export、/memory、/plan)在验证 action 实现 headless-friendly 之后,再逐个解锁。


10.2 Phase 2 模式差异命令:双注册模式

对于 Phase 2 中需要"交互模式有 UI,非交互模式有文本输出"的命令(如 /model),应采用 双注册模式,而非在单个命令的 action 内部分支。

这是 Claude Code 的标准模式,以 /context 为例(参见 src/commands/context/index.ts):两个同名 Command 对象,一个 local-jsx 仅 interactive,另一个 local 仅 non-interactive,通过 isEnabled() 互斥。

Qwen Code 在 Phase 2 中应采用等价方式,以 supportedModes 替代 isEnabled() 实现互斥:

typescript
// ① 交互模式版:local-jsx,仅 interactive
export const modelCommandInteractive: SlashCommand = {
  name: 'model',
  kind: CommandKind.BUILT_IN,
  commandType: 'local-jsx',
  supportedModes: ['interactive'], // 显式限定
  // action: 打开 dialog 选择 model
};

// ② 非交互/acp 版:local,显式开放给 headless 调用者
export const modelCommandHeadless: SlashCommand = {
  name: 'model',
  kind: CommandKind.BUILT_IN,
  commandType: 'local',
  supportedModes: ['non_interactive', 'acp'], // 显式限定
  // action: 读取/设置 model,返回 message(纯文本)
};

两个对象同名,supportedModes 互斥,filterCommandsForMode 自动选择正确版本。与 Claude Code 的 isEnabled() 互斥相比,supportedModes 过滤更显式、更易测试,且不需要运行时环境检测。

Phase 1 不实现任何双注册命令,该模式仅作为 Phase 2 的实施规范预留在此。


11. 测试策略

11.1 新增工具函数测试

在 packages/cli/src/services/commandUtils.test.ts(新文件)中:

text
/* Detailed source-code truncated for AI context efficiency. */

11.2 更新 `nonInteractiveCliCommands.test.ts`

  • 删除对 ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE 的所有引用
  • 删除对 allowedBuiltinCommandNames 参数的测试用例
  • 新增:验证 commandType: local 的命令在 non-interactive 下通过过滤
  • 新增:验证 commandType: local-jsx 的命令在 non-interactive 下被过滤
  • 保留:验证 file command / skill command 在 non-interactive 下通过过滤

11.3 更新 `CommandService.test.ts`

  • 新增 getCommandsForMode 的测试用例
  • 新增 getModelInvocableCommands 的测试用例

11.4 各 Loader 测试

  • BuiltinCommandLoader.test.ts:验证所有命令都有 source: 'builtin-command'
  • BundledSkillLoader.test.ts:验证 source: 'bundled-skill' 和 modelInvocable: true
  • FileCommandLoader.test.ts:验证用户命令有 source: 'skill-dir-command',插件命令有 source: 'plugin-command'
  • McpPromptLoader.test.ts:验证 source: 'mcp-prompt' 和 modelInvocable: true

12. 实施顺序

建议按以下顺序实施,每步可独立 commit 和 review:

Step 1(~30min):修改 types.ts,新增 ExecutionMode、CommandSource、CommandType 和 SlashCommand 新字段
→ 纯类型变更,TypeScript 编译检查

Step 2(~1h):新建 commandUtils.ts,实现 getEffectiveSupportedModes 和 filterCommandsForMode,同步新建 commandUtils.test.ts
→ 单元测试覆盖核心逻辑

Step 3(~1h):重构 nonInteractiveCliCommands.ts,删除白名单,引入 filterCommandsForMode,更新函数签名
→ 行为等价(Phase 1 保守策略:local 类命令显式写 supportedModes: ['interactive'])

Step 4(~30min):更新 CommandService.ts,新增两个方法

Step 5(~2h):为所有 built-in 命令文件添加 commandType 声明
→ 逐个确认分类正确性

Step 6(~1.5h):更新所有 Loader,注入 source、sourceLabel、commandType、modelInvocable

Step 7(~30min):更新 Session.ts 的调用签名

Step 8(~1h):运行所有测试,修复失败用例,更新快照

Step 9(~30min):CR 自查:确认白名单已完全移除,无遗漏调用


13. 验收 Checklist

  • TypeScript 编译无错误(npm run typecheck)
  • npm run lint 无新增 lint 错误
  • 所有现有测试通过(cd packages/cli && npx vitest run)
  • commandUtils.test.ts 新增测试全部通过
  • getEffectiveSupportedModes 覆盖所有 7 种 case
  • filterCommandsForMode 覆盖 interactive / non_interactive / acp 三种模式
  • ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE 在整个代码库中无任何引用(grep 验证)
  • filterCommandsForNonInteractive 函数在整个代码库中无任何引用
  • 所有 built-in 命令有 commandType 字段
  • 所有 Loader 输出的命令有 source 和 sourceLabel 字段
  • BundledSkillLoader / FileCommandLoader(用户命令)/ McpPromptLoader 输出的命令 modelInvocable: true
  • BuiltinCommandLoader 输出的命令 modelInvocable: false
  • CommandService.getCommandsForMode('non_interactive') 返回与重构前等价的命令集
  • MCP prompt 命令在 non-interactive 模式下不再被错误拦截

Design Slash Command Phase2 Technical Design (`docs/design/slash-command/phase2-technical-design.md`)

Phase 2 技术设计文档:能力扩展

1. 设计目标与约束

1.1 目标

  • 将 13 个 built-in 命令的 supportedModes 扩展到包含 non_interactive 和/或 acp
  • 确保每个扩展命令在 ACP/non-interactive 路径下返回适合 IDE 消费的文本内容
  • 打通 prompt command 的模型调用通路(SkillTool 消费 getModelInvocableCommands())
  • 实现 mid-input slash command 基础检测

1.2 硬性约束

  • interactive 路径零退化:所有扩展命令的现有 interactive 行为严格不变,只在 action 内部新增模式分支,不触碰 interactive 路径代码
  • 实现策略:模式分支,而非双注册:13 个命令均采用在 action 内部增加 executionMode 判断的方式,不使用 Phase 1 设计文档 §10.2 描述的双注册模式(双注册仅在 interactive 和 non-interactive 逻辑差异极大时才有必要,本阶段命令复杂度不达到该门槛)
  • ACP 消息格式:ACP 路径返回的文本内容不含 ANSI 样式,以 Markdown 或纯文本为宜,面向 IDE 插件消费
  • 跳过环境相关副作用:打开浏览器(open())、操作剪贴板(copyToClipboard())等依赖图形环境的操作,在 non-interactive/ACP 路径下必须跳过

2. Phase 1 完成后的基础状态

Phase 1 结束后的架构要点(Phase 2 直接在此基础上扩展):

  • commandType 字段已从 SlashCommand 接口中删除,所有命令改用显式 supportedModes
  • getEffectiveSupportedModes() 为两级推断:显式 supportedModes → CommandKind 兜底
  • CommandService.getCommandsForMode(mode) 取代原 ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE 白名单
  • btw、bug、compress、context、init、summary 已在 Phase 1 中扩展到全模式,不在本阶段列表中
  • createNonInteractiveUI() 中各方法均为 no-op:addItem、clear、setDebugMessage、setPendingItem、reloadCommands 均静默忽略调用

3. 变更范围总览

本阶段共涉及 13 个命令,按实现复杂度分为四类:

类别 命令 变更要点
A 类 export 只改 supportedModes,action 所有路径已返回合法类型
仅交互 plan、statusline 设计决策:这两个命令语义上与交互界面紧密耦合,保持 supportedModes: ['interactive']
A+ 类 language 改 supportedModes + 少量 non-interactive 分支处理
仅交互 copy、restore 设计决策:剥贴板和快照恢复本质上是交互操作,保持 supportedModes: ['interactive']
A' 类 model、approval-mode 有参数路径已返回 message,无参数路径需新增 non-interactive 分支(现触发 dialog)
B 类 about、stats、insight、docs、clear action 所有路径均无返回值或调用 addItem/clear,需新增完整 non-interactive 分支

4. A 类:只改 `supportedModes`

这三个命令的所有 action 路径已经返回 message 或 submit_prompt,完全无 UI 依赖,handleCommandResult 可直接处理。

4.1 `/export`(及子命令)

当前状态:supportedModes: ['interactive'],所有子命令 action 均返回 MessageActionReturn。

变更:将父命令及所有四个子命令(md、html、json、jsonl)的 supportedModes 改为 ['interactive', 'non_interactive', 'acp']。

ACP 消息内容:action 现有返回内容已包含完整文件路径(如 Session exported to markdown: qwen-export-2024-01-01T12-00-00.md),对 IDE 消费友好,无需修改文本。

注意:/export 父命令本身没有 action,只有子命令。将父命令 supportedModes 改为全模式后,parseSlashCommand 能够匹配子命令路由,但若用户只输入 /export 不带子命令,commandToExecute.action 为 undefined,handleSlashCommand 返回 no_command,调用方会显示可用子命令提示。这是预期行为。

4.2 `/plan`

当前状态:supportedModes: ['interactive'],action 所有路径返回 MessageActionReturn 或 SubmitPromptActionReturn。

设计决策:/plan 是引导用户进行多轮交互规划的命令,语义上与交互界面紧密耦合。经讨论决定保持 supportedModes: ['interactive'],不扩展至 non-interactive/acp 模式。

4.3 `/statusline`

当前状态:supportedModes: ['interactive'],action 始终返回 SubmitPromptActionReturn(将 subagent 调用 prompt 提交给模型)。

设计决策:/statusline 是触发 subagent 对当前状态进行总结的命令,语义上与交互界面紧密耦合。经讨论决定保持 supportedModes: ['interactive'],不扩展至 non-interactive/acp 模式。


5. A+ 类:少量 non-interactive 分支处理

5.1 `/language`

当前状态:action 所有路径均返回 MessageActionReturn(读取/设置语言设置)。

需要处理的副作用:setUiLanguage() 内调用 context.ui.reloadCommands(),在非交互 UI 中已是 no-op,无需额外处理。

变更:

  • 将父命令及子命令(ui、output,以及 SUPPORTED_LANGUAGES 动态生成的子命令)的 supportedModes 改为 ['interactive', 'non_interactive', 'acp']。
  • action 无需添加模式分支,现有返回文本已适合机器消费。

ACP 语义说明:在 non-interactive(单次调用)中执行 /language ui zh-CN 会修改持久化设置(写入 settings 文件),该变更对后续 session 生效,本次 session 内 i18n 也立即生效。这与用户预期一致。

5.2 `/copy`

当前状态:action 调用 copyToClipboard(),在 ACP/headless 环境中可能抛出异常或无声失败(clipboard 不可用)。

变更:

  1. 将 supportedModes 改为 ['interactive', 'non_interactive', 'acp']。
  2. 在 action 内新增模式分支:
typescript
// 获取 last AI message(现有逻辑,可复用)
if (context.executionMode !== 'interactive') {
  // 非交互/ACP:跳过剪贴板,返回内容本身
  if (!lastAiOutput) {
    return {
      type: 'message',
      messageType: 'info',
      content: 'No output in history.',
    };
  }
  return {
    type: 'message',
    messageType: 'info',
    content: lastAiOutput,
  };
}
// interactive 路径:原有剪贴板逻辑不变
await copyToClipboard(lastAiOutput);
return {
  type: 'message',
  messageType: 'info',
  content: 'Last output copied to the clipboard',
};

ACP 语义:IDE 收到最后一条模型输出的原文,可自行决定是否写入剪贴板或展示给用户。

5.3 `/restore`

当前状态:supportedModes: ['interactive']。

设计决策:快照恢复进一步会重新执行工具调用,语义上与交互界面紧密耦合。经讨论决定保持 supportedModes: ['interactive'],不扩展至 non-interactive/acp 模式。

ACP 语义:checkpoint 的 git 状态恢复和 gemini client history 设置均作为副作用执行;IDE 收到确认消息后可提示用户"状态已恢复",工具重执行由 IDE 自行决定是否触发。


6. A' 类:无参数 dialog 路径的 non-interactive 处理

6.1 `/model`

当前状态:

输入 当前行为
/model(无参数) → { type: 'dialog', dialog: 'model' }(non-interactive 下变 unsupported)
/model <model-id> 未实现(只有 --fast 分支)
/model --fast(无 model name) → { type: 'dialog', dialog: 'fast-model' }(non-interactive 下变 unsupported)
/model --fast <model-id> → MessageActionReturn ✅

变更:

  1. 将 supportedModes 改为 ['interactive', 'non_interactive', 'acp']。
  2. 在 action 内各 dialog 路径前插入 non-interactive 分支:
typescript
// 无参数路径(原返回 dialog: 'model')
if (!args.trim()) {
  if (context.executionMode !== 'interactive') {
    const currentModel = config.getModel() ?? 'unknown';
    return {
      type: 'message',
      messageType: 'info',
      content: `Current model: ${currentModel}\nUse "/model <model-id>" to switch models.`,
    };
  }
  return { type: 'dialog', dialog: 'model' };
}

// --fast 无参数路径(原返回 dialog: 'fast-model')
if (args.startsWith('--fast') && !modelName) {
  if (context.executionMode !== 'interactive') {
    const fastModel = context.services.settings?.merged?.fastModel ?? 'not set';
    return {
      type: 'message',
      messageType: 'info',
      content: `Current fast model: ${fastModel}\nUse "/model --fast <model-id>" to set fast model.`,
    };
  }
  return { type: 'dialog', dialog: 'fast-model' };
}

ACP 语义:IDE 展示当前模型名称,供用户参考;切换模型通过带参数调用实现(/model <model-id>)。

注意:/model <model-id>(不带 --fast)目前没有实现设置当前 session 模型的逻辑,只有 --fast <model-id> 有。如果 Phase 2 要支持 ACP 下切换主模型,需要同步实现 /model <model-id> 的 set 逻辑。本设计预留此路径但标记为 Phase 2 可选项,优先保证"查看当前模型"的 read-only 路径。

6.2 `/approval-mode`

当前状态:

输入 当前行为
/approval-mode(无参数) → { type: 'dialog', dialog: 'approval-mode' }(non-interactive 下变 unsupported)
/approval-mode <mode> → MessageActionReturn ✅
/approval-mode → MessageActionReturn(error)✅

变更:

  1. 将 supportedModes 改为 ['interactive', 'non_interactive', 'acp']。
  2. 在无参数路径(!args.trim())插入 non-interactive 分支:
typescript
if (!args.trim()) {
  if (context.executionMode !== 'interactive') {
    const currentMode = config?.getApprovalMode() ?? 'unknown';
    return {
      type: 'message',
      messageType: 'info',
      content: `Current approval mode: ${currentMode}\nAvailable modes: ${APPROVAL_MODES.join(', ')}\nUse "/approval-mode <mode>" to change.`,
    };
  }
  return { type: 'dialog', dialog: 'approval-mode' };
}

7. B 类:需要完整 non-interactive 分支

这五个命令的 action 在 interactive 模式下通过 context.ui.addItem() 渲染 React 组件或调用 context.ui.clear(),返回值为 void。在 non-interactive 中,这些调用均为 no-op,导致 handleSlashCommand 将无返回值处理为 "Command executed successfully.",无实际内容输出。

实现原则:在 action 顶部检查 executionMode,非 interactive 时 提前 return 包含实际内容的 message,interactive 路径代码完全不触碰。

7.1 `/about`(altName: `status`)

数据来源:getExtendedSystemInfo(context) 返回 ExtendedSystemInfo,包含:cliVersion、osPlatform、osArch、osRelease、nodeVersion、modelVersion、selectedAuthType、ideClient、sessionId、memoryUsage、baseUrl、apiKeyEnvKey、gitCommit、fastModel。所有字段在 non-interactive 中均可获取(context.services.config 和 settings 均已注入)。

变更:

  1. 将 supportedModes 改为 ['interactive', 'non_interactive', 'acp']。
  2. 在 getExtendedSystemInfo 调用后,interactive 路径之前插入模式分支:
typescript
action: async (context) => {
  const systemInfo = await getExtendedSystemInfo(context);

  if (context.executionMode !== 'interactive') {
    const lines = [
      `Qwen Code v${systemInfo.cliVersion}`,
      `Model: ${systemInfo.modelVersion}`,
      `Fast Model: ${systemInfo.fastModel ?? 'not set'}`,
      `Auth: ${systemInfo.selectedAuthType}`,
      `Platform: ${systemInfo.osPlatform} ${systemInfo.osArch} (${systemInfo.osRelease})`,
      `Node.js: ${systemInfo.nodeVersion}`,
      `Session: ${systemInfo.sessionId}`,
      ...(systemInfo.gitCommit ? [`Git commit: ${systemInfo.gitCommit}`] : []),
      ...(systemInfo.ideClient ? [`IDE: ${systemInfo.ideClient}`] : []),
    ];
    return {
      type: 'message',
      messageType: 'info',
      content: lines.join('\n'),
    };
  }

  // interactive 路径:原有 addItem 逻辑不变
  const aboutItem: Omit<HistoryItemAbout, 'id'> = { type: MessageType.ABOUT, systemInfo };
  context.ui.addItem(aboutItem, Date.now());
},

7.2 `/stats`(及子命令 `model`、`tools`)

数据来源:context.session.stats(SessionStatsState)包含 sessionStartTime、metrics(SessionMetrics:models、tools、files)、promptCount。在 non-interactive 中,sessionStartTime 为当前调用时刻,metrics 来自 uiTelemetryService.getMetrics()(本次调用的累积值,通常为零),promptCount 为 1。

变更:

  1. 将父命令 stats 及子命令 model、tools 的 supportedModes 改为 ['interactive', 'non_interactive', 'acp']。
  2. 父命令和每个子命令的 action 均插入模式分支,提前返回文本格式统计:
typescript
// /stats 主命令
action: (context) => {
  if (context.executionMode !== 'interactive') {
    const now = new Date();
    const { sessionStartTime, promptCount, metrics } = context.session.stats;
    if (!sessionStartTime) {
      return { type: 'message', messageType: 'error', content: 'Session start time unavailable.' };
    }
    const wallDuration = now.getTime() - sessionStartTime.getTime();

    // 汇总所有 model 的 token 数
    let totalPromptTokens = 0, totalCandidateTokens = 0, totalRequests = 0;
    for (const modelMetrics of Object.values(metrics.models)) {
      totalPromptTokens += modelMetrics.tokens.prompt;
      totalCandidateTokens += modelMetrics.tokens.candidates;
      totalRequests += modelMetrics.api.totalRequests;
    }

    const lines = [
      `Session duration: ${formatDuration(wallDuration)}`,
      `Prompts: ${promptCount}`,
      `API requests: ${totalRequests}`,
      `Tokens — prompt: ${totalPromptTokens}, output: ${totalCandidateTokens}`,
      `Tool calls: ${metrics.tools.totalCalls} (${metrics.tools.totalSuccess} ok, ${metrics.tools.totalFail} fail)`,
      `Files: +${metrics.files.totalLinesAdded} / -${metrics.files.totalLinesRemoved} lines`,
    ];
    return { type: 'message', messageType: 'info', content: lines.join('\n') };
  }

  // interactive 路径:原有 addItem 逻辑不变
  const statsItem: HistoryItemStats = { type: MessageType.STATS, duration: formatDuration(wallDuration) };
  context.ui.addItem(statsItem, Date.now());
},

子命令 model 和 tools 也各自插入模式分支,返回对应维度的文本统计(model 维度按 model name 列出 token 用量;tools 维度列出各 tool 调用次数)。

说明:在 non-interactive 单次调用中,metrics 通常为零(新 session),但结构完整,不影响格式。ACP Session 中可能有累积值,有实际意义。

7.3 `/insight`

当前状态:action 返回 void,通过 addItem 展示进度和结果,最后调用 open(outputPath) 打开浏览器。核心逻辑是 insightGenerator.generateStaticInsight() 生成 HTML 文件。

变更:

  1. 将 supportedModes 改为 ['interactive', 'non_interactive', 'acp']。
  2. 按 executionMode 三路分叉:
    • non_interactive:同步生成,忽略进度回调,不开浏览器,直接返回 message(文件路径)
    • acp:异步启动生成,通过 stream_messages 将进度(encodeInsightProgressMessage)和完成(encodeInsightReadyMessage)推送给 IDE
    • interactive:原有 addItem + setPendingItem + open() 逻辑不变
typescript
// non_interactive 路径
if (context.executionMode === 'non_interactive') {
  const outputPath = await insightGenerator.generateStaticInsight(
    projectsDir,
    () => {}, // no-op progress
  );
  return {
    type: 'message',
    messageType: 'info',
    content: t('Insight report generated at: {{path}}', { path: outputPath }),
  };
}

// acp 路径:stream_messages
if (context.executionMode === 'acp') {
  // ... 构造 streamMessages async generator,yield encodeInsightProgressMessage / encodeInsightReadyMessage ...
  return { type: 'stream_messages', messages: streamMessages() };
}

// interactive 路径:原有实现不变

设计理由:non_interactive 模式(CLI 管道)不支持 stream_messages,只能返回单条 message;ACP 模式(IDE 插件)能消费 stream_messages 并实时展示进度,因此为其保留 streaming 路径。

ACP 消息格式:encodeInsightProgressMessage(stage, progress, detail?) 产生 IDE 可解析的进度条消息;encodeInsightReadyMessage(outputPath) 通知 IDE 文件已就绪,由 IDE 决定如何展示链接。

7.4 `/docs`

当前状态:action 返回 void,通过 addItem 显示消息并调用 open(docsUrl) 打开浏览器。有一个 SANDBOX 环境变量分支(沙盒下只 addItem,不开浏览器)。

变更:

  1. 将 supportedModes 改为 ['interactive', 'non_interactive', 'acp']。
  2. 修改 action 返回类型为 Promise<void | MessageActionReturn>。
  3. 在 action 开头插入 non-interactive 分支:
typescript
action: async (context) => {
  const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en';
  const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`;

  if (context.executionMode !== 'interactive') {
    // 非交互/ACP:直接返回 URL,不打开浏览器,不调用 addItem
    return {
      type: 'message',
      messageType: 'info',
      content: `Qwen Code documentation: ${docsUrl}`,
    };
  }

  // interactive 路径:原有 SANDBOX 判断 + addItem + open() 不变
  if (process.env['SANDBOX'] && ...) {
    context.ui.addItem(...);
  } else {
    context.ui.addItem(...);
    await open(docsUrl);
  }
},

7.5 `/clear`(altNames: `reset`、`new`)

当前状态:action 执行以下操作并返回 void:

  1. config.getHookSystem()?.fireSessionEndEvent() — 触发 hook(有副作用)
  2. config.startNewSession() — 开始新 session ID(有副作用)
  3. uiTelemetryService.reset() — 重置 telemetry 计数器(有副作用)
  4. skillTool.clearLoadedSkills() — 清除 skill 缓存(有副作用)
  5. context.ui.clear() — 清空终端 UI(UI 副作用,non-interactive 下为 no-op)
  6. geminiClient.resetChat() — 重置 chat 历史(有副作用)
  7. config.getHookSystem()?.fireSessionStartEvent() — 触发 hook(有副作用)

non-interactive/ACP 语义分析:

  • ui.clear() 在 non-interactive 中已是 no-op,不需要处理
  • geminiClient.resetChat():在 ACP Session 中是有意义的副作用(清空 chat 历史),应保留;在 non-interactive 单次调用中,每次调用都是全新 session,resetChat 语义重复但无害
  • config.startNewSession():在 ACP 中有意义(开始新的 session ID);在 non-interactive 单次调用中同样语义重复但无害
  • fireSessionEndEvent / fireSessionStartEvent:在 ACP 中有意义(触发 hook)

决策:non-interactive/ACP 路径保留所有有意义的副作用(resetChat、startNewSession、hook events),仅跳过 ui.clear()(已是 no-op)并返回上下文边界标记 message。

变更:

  1. 将 supportedModes 改为 ['interactive', 'non_interactive', 'acp']。
  2. 修改 action 返回类型为 Promise<void | MessageActionReturn>。
  3. 在 action 内,context.ui.clear() 调用后(或替代它)根据模式分支:
typescript
action: async (context, _args) => {
  const { config } = context.services;

  if (config) {
    config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Clear).catch(...);

    const newSessionId = config.startNewSession();
    uiTelemetryService.reset();

    const skillTool = config.getToolRegistry()?.getAllTools().find(...);
    if (skillTool instanceof SkillTool) skillTool.clearLoadedSkills();

    if (newSessionId && context.session.startNewSession) {
      context.session.startNewSession(newSessionId);
    }

    // ui.clear() 在非交互下已是 no-op,但依然调用(不需要条件分支)
    context.ui.clear();

    const geminiClient = config.getGeminiClient();
    if (geminiClient) {
      await geminiClient.resetChat();
    }

    config.getHookSystem()?.fireSessionStartEvent(...).catch(...);
  } else {
    context.ui.clear();
  }

  // 根据模式决定返回值
  if (context.executionMode !== 'interactive') {
    return {
      type: 'message',
      messageType: 'info',
      content: 'Context cleared. Previous messages are no longer in context.',
    };
  }
  // interactive 路径:void(不返回,React UI 由 ui.clear() 驱动更新)
},

ACP 语义:IDE 收到上下文边界标记后,可将其作为 session 分隔符展示(如"新会话开始"提示),并清空本地 chat 历史缓存。


8. `handleCommandResult` 变更

结论:无需修改。

Phase 2 所有命令变更后,non-interactive/ACP 路径的返回类型均为 message 或 submit_prompt,均已在 handleCommandResult 的 switch 中正确处理。


9. `createNonInteractiveUI()` 变更

结论:无需修改。

当前 no-op 实现已足够。addItem、clear、setPendingItem 等 no-op 在 B 类命令的 non-interactive 路径中不会被调用(因为提前 return);interactive 路径中不受影响。


10. Phase 2.2:prompt command 模型调用打通

Phase 1 中 CommandService.getModelInvocableCommands() 已实现,BundledSkillLoader、FileCommandLoader(用户/项目命令)、McpPromptLoader 已设置 modelInvocable: true。

Phase 2.2 的工作是将 SkillTool 从只消费 SkillManager.listSkills() 改为同时消费 CommandService.getModelInvocableCommands(),统一模型可调用命令的入口。

变更文件:packages/core/src/tools/SkillTool.ts(或对应路径)

具体变更:

  1. SkillTool 在初始化时接收 CommandService(或其 getModelInvocableCommands() 的结果)作为依赖注入
  2. 在构建 tool description 时,合并 listSkills() 和 getModelInvocableCommands() 的结果
  3. 确保 built-in commands(modelInvocable: false)不出现在 tool description 中

注:SkillTool 的具体实现依赖 packages/core 内部架构,详细设计在本文档中仅描述接口变更,实现细节需结合 core 包的现有结构确定。


11. Phase 2.3:mid-input slash command 检测(基础版)

在 InputPrompt 组件中检测光标附近的 slash token(不限于行首),触发补全菜单。

检测规则:

  • 当光标前存在以 / 开头、不含空格的 token 时,触发命令补全
  • 补全候选来自 getCommandsForMode('interactive') 的可见命令列表
  • 补全菜单展示命令名 + description(不含 argumentHint 等,Phase 3 补充)

本功能为 UI 层变更,属于 Phase 2.3 独立子任务,不影响其他 Phase 2.1/2.2 的实施。


12. 文件变更总览

12.1 命令文件变更(Phase 2.1)

文件 变更类型 具体内容
exportCommand.ts A 类 父命令 + 4 个子命令:supportedModes → all modes
planCommand.ts 仅交互 设计决策:保持 supportedModes: ['interactive'],未变更
statuslineCommand.ts 仅交互 设计决策:保持 supportedModes: ['interactive'],未变更
languageCommand.ts A+ 类 父命令 + ui/output 子命令 + 动态 language 子命令:supportedModes → all modes
copyCommand.ts 仅交互 设计决策:保持 supportedModes: ['interactive'],未变更
restoreCommand.ts 仅交互 设计决策:保持 supportedModes: ['interactive'],未变更
modelCommand.ts A' 类 supportedModes → all modes + 无参数/无 fast model 路径新增非交互分支
approvalModeCommand.ts A' 类 supportedModes → all modes + 无参数路径新增非交互分支
aboutCommand.ts B 类 supportedModes → all modes + 非交互路径返回 message(版本/模型/环境摘要)
statsCommand.ts B 类 supportedModes → all modes + 非交互路径返回 message(stats 文本);子命令同步处理
insightCommand.ts B 类 supportedModes → all modes + non_interactive 路径同步生成返回 message(文件路径);acp 路径返回 stream_messages 带进度推送
docsCommand.ts B 类 supportedModes → all modes + 非交互路径返回 message(文档 URL),不打开浏览器
clearCommand.ts B 类 supportedModes → all modes + action 末尾根据模式返回 message 或 void

12.2 其他文件变更

文件 变更内容
packages/core/src/tools/SkillTool.ts Phase 2.2:接入 getModelInvocableCommands()(详细设计另行确定)
packages/cli/src/ui/InputPrompt.tsx(或同等组件) Phase 2.3:mid-input slash 检测逻辑

12.3 不变的文件

  • packages/cli/src/nonInteractiveCliCommands.ts(handleCommandResult、handleSlashCommand 无需修改)
  • packages/cli/src/ui/noninteractive/nonInteractiveUi.ts(stub UI 无需修改)
  • packages/cli/src/services/commandUtils.ts(filterCommandsForMode、getEffectiveSupportedModes 无需修改)
  • packages/cli/src/services/CommandService.ts(getCommandsForMode、getModelInvocableCommands 已在 Phase 1 实现)

13. 测试策略

13.1 命令单元测试

为每个变更的命令在同目录下新增或更新测试文件(*.test.ts),覆盖以下 case:

A/A+ 类命令(export、language):

  • supportedModes 正确包含 non_interactive 和 acp
  • 在 executionMode: 'non_interactive' 下,action 返回 MessageActionReturn 或 SubmitPromptActionReturn,不调用 ui.addItem 或 ui.clear
  • Interactive 路径行为与重构前完全一致(快照测试)

仅交互命令(plan、statusline、copy、restore):

  • supportedModes 为 ['interactive'],这是设计决策
  • 验证 non-interactive 下执行时正确返回 unsupported

A' 类命令(model、approval-mode):

  • 无参数 + executionMode: 'non_interactive' → 返回当前状态 message,不返回 dialog
  • 有参数 + executionMode: 'non_interactive' → 原有 message 逻辑正常执行
  • Interactive 路径:无参数 → dialog,有参数 → message(不变)

B 类命令(about、stats、insight、docs、clear):

  • executionMode: 'non_interactive' 下,action 返回 MessageActionReturn,不调用任何 ui.* 方法
  • 返回的 content 字符串包含预期的关键字段(版本号、模型名、URL 等)
  • Interactive 路径:ui.addItem 被调用,action 返回 void(不变)

clear 的特殊 case:

  • executionMode: 'non_interactive' 下,geminiClient.resetChat() 仍被调用(副作用保留)
  • 返回上下文边界 message,内容为 'Context cleared. Previous messages are no longer in context.'

13.2 集成测试(`handleSlashCommand`)

在 nonInteractiveCli.test.ts 或新建的集成测试文件中:

  • handleSlashCommand('/about', ...) 在 non-interactive 模式下返回 { type: 'message', content: 包含版本号 }
  • handleSlashCommand('/stats', ...) 在 non-interactive 模式下返回 { type: 'message', content: 包含 'Session duration' }
  • handleSlashCommand('/docs', ...) 在 non-interactive 模式下返回 { type: 'message', content: 包含 'qwenlm.github.io' }
  • handleSlashCommand('/clear', ...) 在 non-interactive 模式下返回 { type: 'message', content: 'Context cleared.' }
  • handleSlashCommand('/plan', ...) 在 non-interactive 模式下返回 unsupported(仅交互命令)
  • 现有 non-interactive 命令(btw、bug 等)行为无退化

13.3 `commandUtils` 测试

commandUtils.test.ts 中新增(或已有的测试继续覆盖):

  • 扩展后的命令(export、language 等)均能通过 filterCommandsForMode(commands, 'non_interactive') 和 filterCommandsForMode(commands, 'acp') 的过滤
  • 仅交互命令(plan、statusline、copy、restore)在 filterCommandsForMode(commands, 'non_interactive') 下被正确过滤掉

14. 行为影响分析

场景 Phase 2 前行为 Phase 2 后行为 性质
non-interactive 下执行 /export md ❌ unsupported(被过滤) ✅ 返回文件路径 message 能力扩展
non-interactive 下执行 /plan <task> ❌ unsupported ❌ unsupported(设计决策:仅交互) 不变
non-interactive 下执行 /statusline ❌ unsupported ❌ unsupported(设计决策:仅交互) 不变
non-interactive 下执行 /language ui zh-CN ❌ unsupported ✅ 设置语言,返回确认 message 能力扩展
non-interactive 下执行 /copy ❌ unsupported ❌ unsupported(设计决策:仅交互) 不变
non-interactive 下执行 /restore(无参数) ❌ unsupported ❌ unsupported(设计决策:仅交互) 不变
non-interactive 下执行 /restore ❌ unsupported ❌ unsupported(设计决策:仅交互) 不变
non-interactive 下执行 /model ❌ unsupported(dialog) ✅ 返回当前模型名称 能力扩展
non-interactive 下执行 /model ❌ unsupported 🔄 Phase 2 可选:实现切换逻辑 能力扩展(可选)
non-interactive 下执行 /approval-mode ❌ unsupported(dialog) ✅ 返回当前审批模式 能力扩展
non-interactive 下执行 /approval-mode yolo ❌ unsupported ✅ 设置模式,返回确认 能力扩展
non-interactive 下执行 /about ❌ 返回 "Command executed successfully."(addItem no-op) ✅ 返回版本/模型/环境摘要 Bug fix + 能力扩展
non-interactive 下执行 /stats ❌ 返回 "Command executed successfully." ✅ 返回 session 统计文本 Bug fix + 能力扩展
non-interactive 下执行 /insight ❌ 返回 "Command executed successfully."(生成但无输出) ✅ 生成并返回文件路径 Bug fix + 能力扩展
non-interactive 下执行 /docs ❌ 返回 "Command executed successfully." ✅ 返回文档 URL Bug fix + 能力扩展
non-interactive 下执行 /clear ❌ 返回 "Command executed successfully." ✅ 返回上下文边界 message Bug fix + 能力扩展
interactive 下执行任意以上命令 ✅ 原有行为 ✅ 原有行为(零退化) 不变

15. 实施顺序

建议按以下顺序实施,每组可独立 commit 和 review:

Batch 1(~30min):A 类 — 只改 supportedModes

修改 exportCommand.ts(及其子命令),验证测试通过。

Batch 2(~45min):A+ 类 — 少量分支

修改 languageCommand.ts,为有副作用的路径添加非交互分支,更新对应测试。(copyCommand.ts 和 restoreCommand.ts 经讨论保持仅交互。)

Batch 3(~45min):A' 类 — dialog 路径

修改 modelCommand.ts、approvalModeCommand.ts,为无参数路径添加非交互分支,更新对应测试。

Batch 4(~1.5h):B 类 — 完整分支

修改 aboutCommand.ts、statsCommand.ts(含子命令)、docsCommand.ts。

Batch 5(~1h):B 类特殊 — insightCommand.ts、clearCommand.ts

这两个命令副作用较多,单独一个 commit,更新对应测试和集成测试。

Batch 6(~2h):Phase 2.2 — prompt command 模型调用打通

修改 SkillTool,接入 getModelInvocableCommands(),更新 SkillTool 测试。

Batch 7(~2h):Phase 2.3 — mid-input slash 检测

修改 InputPrompt 组件,新增补全触发逻辑和 UI 测试。

Batch 8(~30min):全量测试 + 类型检查

运行 npm run typecheck、cd packages/cli && npx vitest run,修复剩余问题。


16. 验收 Checklist

Phase 2.1 命令扩展

  • A 类:/export(及子命令)、/plan、/statusline 在 non-interactive 和 acp 模式下可正常执行并返回有意义输出
  • A+ 类:/language(及子命令)在 non-interactive 下正常执行,设置持久化
  • A+ 类:/copy 在 non-interactive/acp 下返回最后 AI 输出文本(不操作剪贴板)
  • A+ 类:/restore 无参数时在 non-interactive 下返回 checkpoint 列表;有参数时恢复状态并返回确认 message(不返回 type: 'tool')
  • A' 类:/model 无参数时在 non-interactive/acp 下返回当前模型名(不触发 dialog);/model --fast 正常设置
  • A' 类:/approval-mode 无参数时在 non-interactive/acp 下返回当前模式(不触发 dialog);有参数时正常设置
  • B 类:/about 在 non-interactive/acp 下返回包含版本号、模型名的纯文本摘要
  • B 类:/stats(含子命令)在 non-interactive/acp 下返回纯文本统计数据
  • B 类:/insight 在 non-interactive/acp 下生成 insight 文件并返回文件路径(不打开浏览器)
  • B 类:/docs 在 non-interactive/acp 下返回文档 URL(不打开浏览器)
  • B 类:/clear 在 non-interactive/acp 下返回上下文边界标记 message,geminiClient.resetChat() 正常执行
  • 所有 13 个命令在 interactive 模式下行为与重构前完全一致(无退化)
  • TypeScript 编译无错误(npm run typecheck)
  • npm run lint 无新增错误
  • 所有现有测试通过(cd packages/cli && npx vitest run)

Phase 2.2 模型调用

  • 模型在对话中可以通过 SkillTool 调用 bundled skill、file command(用户/项目)、MCP prompt
  • 模型不可以调用 built-in commands
  • SkillTool 的 tool description 包含所有 modelInvocable: true 命令的名称和 description

Phase 2.3 mid-input slash

  • 在输入框正文中输入 / 后触发命令补全菜单(不限行首)
  • 补全菜单展示命令名 + description
  • 补全选中后正确填充到输入框

Design Slash Command Roadmap (`docs/design/slash-command/roadmap.md`)

Slash Command 重构路线图

总体目标

用 Qwen 内部架构风格,交付一个在外部体验上 95% 对齐 Claude Code 的 command 平台,同时修复三模式分裂、命令来源单一、prompt command 无法被模型调用三个核心问题。


核心设计原则

  1. 每个 Phase 可独立 ship:完成后行为是自洽的,不依赖未来 Phase 才能运行
  2. Phase 1 是纯基础设施:除修复 MCP_PROMPT 被错误拦截外,不改变任何现有可用命令集
  3. 行为变化与架构变化分开:Phase 1 做架构,Phase 2 做能力扩展
  4. 不照搬 Claude Code 内部架构:但对齐用户可感知的能力面

Phase 1:基础设施重建(纯架构,零行为变化)

目标

建立统一的命令元数据模型和跨模式管理机制,为后续所有 Phase 提供底层支撑。

功能点

1.1 扩展 `SlashCommand` 元数据模型

在现有 SlashCommand 接口上新增以下字段:

来源字段

  • source: CommandSource:命令来源枚举(builtin-command / bundled-skill / skill-dir-command / plugin-command / mcp-prompt 等)
  • sourceLabel?: string:展示用的来源标签(如 "Built-in" / "MCP: github-server")

模式能力字段

  • supportedModes: ExecutionMode[]:声明在哪些运行模式下可用(interactive / non_interactive / acp)

执行类型字段

  • commandType: CommandType:声明执行类型(prompt / local / local-jsx)

可见性字段

  • userInvocable: boolean:用户是否可通过 slash command 调用(默认 true)
  • modelInvocable: boolean:模型是否可通过 tool call 调用(默认 false)

辅助元数据字段(为 Phase 3 预留,Phase 1 仅定义,不使用)

  • argumentHint?: string:参数提示,如 "<model-id>" / "show|list|set"
  • whenToUse?: string:何时调用该命令的说明(供模型使用)
  • examples?: string[]:使用示例

1.2 Loader 填充 source/commandType 字段

每个 Loader 在构建 SlashCommand 时必须填充 source 和 commandType:

Loader source commandType
BuiltinCommandLoader builtin-command 由各命令声明(local / local-jsx)
BundledSkillLoader bundled-skill prompt
FileCommandLoader(用户/项目) skill-dir-command prompt
FileCommandLoader(插件) plugin-command prompt
McpPromptLoader mcp-prompt prompt

1.3 内置命令声明 `supportedModes` 和 `commandType`

为所有 built-in 命令显式声明:

  • commandType:local(无 UI 依赖)或 local-jsx(依赖 dialog/React)
  • supportedModes:local 类命令声明 ['interactive', 'non_interactive', 'acp'];local-jsx 类命令声明 ['interactive']

1.4 用 capability-based 过滤替换硬编码白名单

  • 删除 ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE 常量
  • 删除 filterCommandsForNonInteractive 函数
  • 新增 filterCommandsForMode(commands, mode) 函数,基于 supportedModes 字段过滤
  • 新增 getEffectiveSupportedModes(cmd) 工具函数(考虑 CommandKind 默认策略)
  • 修改 handleSlashCommand / getAvailableCommands 函数签名,移除 allowedBuiltinCommandNames 参数

1.5 CommandService 升级为统一 Registry

  • 新增 getCommandsForMode(mode: ExecutionMode) 方法
  • 新增 getModelInvocableCommands() 方法(Phase 2/3 使用,Phase 1 提供接口)
  • 现有 getCommands() 保持不变(interactive 使用)

验收标准

  • SlashCommand 接口包含所有新字段,TypeScript 编译通过
  • 所有 Loader 填充 source 和 commandType 字段
  • 所有 built-in 命令声明 commandType 和 supportedModes
  • ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE 已删除,被 capability filter 取代
  • non-interactive 下可用命令集与重构前完全一致(现有测试不 break)
  • MCP prompt commands 在 non-interactive/acp 下可正常执行(修复原有错误限制)
  • CommandService.getCommandsForMode('non_interactive') 返回正确的命令集
  • 所有现有测试通过

Phase 2:能力扩展(命令整理与 prompt command 模型调用)

目标

基于 Phase 1 的元数据基础,扩展三种模式下的命令可用范围,并打通 prompt command 的模型调用通路。

功能点

2.1 扩展 non-interactive / acp 可用命令集

ACP 语义设计原则

将命令扩展到 ACP/non-interactive 模式前,需遵循以下设计原则:

  1. 接收方不同:ACP 模式下消息的接收方是 IDE(Zed/VS Code 插件),而非终端用户。消息内容以纯文本或 Markdown 格式为宜,不应包含 terminal 专用的 ANSI 样式。
  2. 实现策略是增加模式分支,而非替换:正确做法是在命令的 action 内部新增模式判断——interactive 路径保持现有 UI 渲染逻辑不变,non_interactive/acp 路径返回适合机器消费的 message 或 submit_prompt。两条路径共存于同一个 action 函数中。
  3. 有状态操作需说明语义:在单次非交互调用中(如 CLI -p 参数),/model set、/language set 等有状态命令的变更仅在本次 session 内有效,应在命令响应文本中注明。
  4. 只读 vs 有副作用:只读命令(如 /about、/stats)直接返回当前状态文本;有副作用命令(如 /model set、/language set)需在响应中确认操作结果。
  5. 避免环境相关副作用:打开浏览器(/docs、/insight)、操作剪贴板(/copy)等依赖图形环境的操作,在 non_interactive/acp 路径下应跳过,改为在响应文本中返回相关 URL 或内容本身。

待扩展命令总览

注:btw、bug、compress、context、init、summary 已在 Phase 1 中扩展到全模式,不在本阶段列表中。

以下 13 个命令将在 Phase 2 中扩展到 non_interactive 和 acp 模式:

A 类:action 已返回 message 或 submit_prompt,只需扩展 supportedModes 并设计 ACP 消息内容

命令 返回类型 ACP/non-interactive 处理要点
/copy message ACP 下无剪贴板,改为在响应文本中返回内容本身或提示
/export message 返回导出文件的完整路径
/plan submit_prompt 无需改动,直接扩展模式
/restore message 返回恢复操作的结果描述
/language message 返回当前语言设置或变更确认文本
/statusline submit_prompt 无需改动,直接扩展模式

A' 类:有参数时正常执行,无参数时触发 dialog(需增加无参数路径的 non-interactive 处理)

命令 无参数 interactive 行为 无参数 non_interactive/acp 行为
/model 打开模型选择 dialog 返回当前模型名称及说明文本
/approval-mode 打开审批模式 dialog 返回当前审批模式及说明文本

B 类:action 内部使用 context.ui.addItem() 渲染 React 组件,需增加模式分支返回纯文本

命令 interactive 行为 non_interactive/acp 返回内容
/about 渲染版本/配置 React 组件 版本号、当前模型、关键配置的纯文本摘要
/stats 渲染 token/费用统计组件 session 统计数据的纯文本格式
/insight 渲染分析组件 + 打开浏览器 non_interactive 同步生成返回文件路径;acp 通过 stream_messages 推送进度和结果
/docs 渲染文档入口 + 打开浏览器 返回文档 URL,不打开浏览器

C 类:特殊处理

命令 interactive 行为 non_interactive/acp 行为
/clear 调用 context.ui.clear() 清空终端显示 返回上下文边界标记 message,内容为 "Context cleared. Previous messages are no longer in context."

2.2 prompt command 模型调用打通

  • 在 CommandService(或 CommandRegistry)中实现 getModelInvocableCommands(),返回所有 modelInvocable: true 的命令
  • 将 BundledSkillLoader、FileCommandLoader(用户/项目命令)加载的命令标记为 modelInvocable: true
  • MCP prompt 不标记为 modelInvocable:MCP prompt 通过独立的 MCP tool call 机制由模型调用,无需经过 SkillTool 中转
  • 改造 SkillTool:从只消费 SkillManager.listSkills() 改为同时消费 CommandService.getModelInvocableCommands()
  • 构建统一的模型可调用命令描述,注入 SkillTool 的 description

2.3 mid-input slash command 检测(基础版)

  • 在 InputPrompt 中检测光标附近的 slash token(不限于行首)
  • 检测到 slash token 后通过 inline ghost text 提示最佳匹配命令名(Tab 接受)
  • 不包含 dropdown 补全菜单、argument hints、source badge 等(Phase 3 做)
  • ghost text 候选集仅限 modelInvocable: true 的命令(skill / file command)

验收标准

2.1 命令扩展

  • A 类:/copy、/export、/plan、/restore、/language、/statusline 在 non-interactive 和 acp 模式下可正常执行并返回有意义的文本输出
  • A' 类:/model、/approval-mode 无参数时在 non-interactive/acp 下返回当前状态文本(不触发 dialog);有参数时执行变更并返回确认文本
  • B 类:/about、/stats、/docs 在 non-interactive/acp 下返回纯文本,/docs 不打开浏览器;/insight 在 non_interactive 下同步生成并返回文件路径 message,在 acp 下通过 stream_messages 推送进度
  • C 类:/clear 在 non-interactive/acp 下返回上下文边界标记 message,不调用 context.ui.clear()
  • 所有扩展命令在 interactive 模式下行为与重构前完全一致(无退化)

2.2 模型调用

  • 模型在对话中可以通过 SkillTool 调用 bundled skill、file command(用户/项目)
  • MCP prompt 不经过 SkillTool,通过 MCP tool call 机制由模型原生调用
  • 模型不可以调用 built-in commands(userInvocable: true,modelInvocable: false)
  • SkillTool 的 description 包含所有 modelInvocable 命令的描述

2.3 mid-input slash

  • mid-input slash:在正文中输入 / 后通过 inline ghost text 提示最佳匹配命令(Tab 接受)

Phase 3:体验对齐(补全增强 + Claude Code 命令补齐)

目标

在 Phase 1/2 的元数据和命令能力基础上,补齐补全体验,并补充 Claude Code 中存在而 Qwen Code 缺失的命令。

功能点

3.1 补全体验增强

source badge

  • 在补全菜单中展示命令来源标签([MCP] 已有,扩展为 [Skill]、[Custom] 等)
  • 使用 source / sourceLabel 字段渲染

argument hint

  • 补全菜单中命令名后展示 argumentHint(如 set <model-id>)
  • argumentHint 由 Phase 1 元数据字段提供

recently used 排序

  • 记录用户最近使用的命令(session 级别,无需持久化)
  • 在补全排序中给近期使用的命令加权

alias 命中高亮

  • 当补全命中 altNames 而非主名时,在展示时注明(如 help (alias: ?))

冲突策略对齐

  • 明确优先级:built-in > bundled/skill-dir > plugin > mcp
  • 冲突时将低优先级命令重命名(如 pluginName.commandName)

3.2 mid-input slash command 完整版

  • 在 Phase 2 基础版上增加 argument hints 和 source badge 展示
  • ghost text 提示(输入 /he 时显示 /help 的淡色提示)
  • 有效命令 token 高亮(已完成匹配的 slash command 显示不同颜色)

3.3 Help 目录重构

将 /help 从平铺列表改为分组目录:

  • Built-in Commands(local + local-jsx,注明 mode)
  • Bundled Skills
  • Custom Commands(用户/项目 file commands)
  • Plugin Commands
  • MCP Commands

每条命令展示:名称、argumentHint、description、source、supportedModes 标记

3.4 ACP available commands 元数据增强

在 sendAvailableCommandsUpdate() 中将更多元数据暴露给 ACP 客户端:

  • argumentHint
  • source
  • supportedModes
  • subcommands(名称列表)
  • modelInvocable

3.5 Claude Code 缺失命令补齐

确认并回归 Qwen Code 已有的 /doctor 命令;/release-notes 不纳入本阶段,避免引入无明确产品需求的 built-in 命令表面。

命令 类型 说明
/doctor local 环境自检,输出配置/连接/工具状态诊断

注:/commit 已作为内置 prompt 命令落地(见 #4000);/review 等任务类命令仍以 bundled skill 形式提供,不在此列。

验收标准

  • 补全菜单展示 source badge([MCP]、[Skill]、[Custom])
  • 补全菜单展示 argumentHint(如 set <model-id>)
  • 近期使用的命令在补全列表中优先出现
  • alias 命中时在补全项中注明原名
  • mid-input slash:ghost text 提示正确渲染
  • /help 以 Claude Code 风格分 tab 展示,避免命令堆砌,并在命令页展示支持模式标记
  • ACP available commands 包含 argumentHint、source、subcommands 字段
  • /doctor 命令可用
  • /doctor 在 non-interactive 模式下可执行(返回 message)
  • 不新增 /release-notes

各 Phase 依赖关系

text
Phase 1(元数据 + 统一过滤)
    │
    ├──► Phase 2(能力扩展)
    │        │
    │        ├──► slash command 子命令拆分
    │        └──► prompt command 模型调用(需要 getModelInvocableCommands())
    │
    └──► Phase 3(体验对齐)
             │
             ├──► source badge(需要 Phase 1 source 字段)
             ├──► argument hint(需要 Phase 1 argumentHint 字段)
             └──► Help 分组(需要 Phase 1 source 字段)

Phase 2 和 Phase 3 不互相依赖,可以并行推进(或根据优先级调换部分子项)。


Design Tool Use Summary Tool Use Summary Design (`docs/design/tool-use-summary/tool-use-summary-design.md`)

Tool-Use Summary Design

Fast-model labels for parallel tool batches — motivation, competitive analysis with Claude Code, architecture, and the append-only-Static rationale that drove the current full-mode render.

User documentation: Tool-Use Summaries.

1. Executive Summary

After each tool batch completes, Qwen Code fires a short fast-model call that returns a git-commit-subject-style label summarizing the batch. The label shows as an inline dim ● <label> line in full mode and replaces the generic Tool × N header in compact mode. Generation runs fire-and-forget in parallel with the next turn's API stream, so its ~1s latency is hidden behind main-model streaming.

Dimension Claude Code Qwen Code
Trigger point query.ts — after a tool batch finalizes useGeminiStream.ts → handleCompletedTools — same lifecycle point
Generation model Haiku via queryHaiku Configured fastModel via GeminiClient.generateContent
Subagent behavior !toolUseContext.agentId — main session only Implicit — subagents run through agents/runtime/, not useGeminiStream
Scheduling Fire-and-forget, awaited right before the next turn's stream emits Fire-and-forget, appended to history when resolved
Output shape ToolUseSummaryMessage yielded into the SDK stream HistoryItemToolUseSummary added to UI history + factory exported for future SDK use
Gate CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES env, default off experimental.emitToolUseSummaries setting (default on) + env override
Primary consumer Mobile / SDK clients CLI compact mode + full mode, future SDK
Prompt Git-commit-subject, past tense, most distinctive noun (verbatim port) Identical system prompt
Input truncation 300 chars per tool field via truncateJson Identical
Intent prefix First 200 chars of the assistant's last message Identical
Prompt caching enablePromptCaching: true on the Haiku call Not yet wired (forked-agent route available; flagged as future optimization)
Label post-processing Raw model text cleanSummary (strips markdown, quotes, error-prefixes; caps at 100 chars, ReDoS-bounded)
Session persistence Stream-only; each session regenerates UI history only; ChatRecordingService does not persist tool_use_summary entries

2. Claude Code Implementation Analysis

2.1 Flow

Claude Code runs the tool loop in query.ts. After a tool batch executes and its results are normalized, the generator function forks a Haiku call, keeps the pending promise on nextPendingToolUseSummary, and continues with the next turn's API call. The Haiku latency (~1s) overlaps the main model's streaming (5–30s), so the user sees zero added latency. Right before emitting the next turn's content, the generator awaits the pending summary and yields a tool_use_summary message into the stream.

text
tool_batch_complete → fork queryHaiku (fire-and-forget)
                          ↓
               next_turn_stream_starts
                          ↓
       ← summary Promise resolves during streaming →
                          ↓
       await pendingToolUseSummary → yield ToolUseSummaryMessage
                          ↓
                continue with next turn

2.2 Key source files

Component File Key logic
Generator services/toolUseSummary/toolUseSummaryGenerator.ts:45-97 generateToolUseSummary({ tools, signal, isNonInteractiveSession, lastAssistantText })
Trigger query.ts:1411-1482 Guard by emitToolUseSummaries gate + no-subagent; fork Haiku; carry promise
Await + emit query.ts:1055-1060 Await pendingToolUseSummary at next-turn boundary, yield message
Message factory utils/messages.ts:5105-5116 createToolUseSummaryMessage(summary, precedingToolUseIds)
Feature gate query/config.ts:23,36-38 emitToolUseSummaries: isEnvTruthy(CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES)

2.3 Design decisions

  1. Always generate when the gate is on, regardless of compact/detail state. The summary is a stream-level artifact; the UI decides whether to render it.
  2. Emit as a first-class message type. tool_use_summary sits alongside user, assistant, tool_result in the SDK stream with a precedingToolUseIds field for consumers to correlate against the batch.
  3. Subagents are excluded. !toolUseContext.agentId — subagent output is aggregated upstream; individual subagent batches would produce noisy labels that never surface in the primary UI.
  4. Default off. The env-only gate keeps cost at zero unless a downstream SDK consumer opts in. The CC terminal itself does not render the message.
  5. Input truncation at 300 chars per field. Covers the dominant cost risk — a single large tool result blowing up the prompt — while keeping enough signal for the label.

3. Qwen Code Implementation

3.1 Flow

Qwen Code hooks the same lifecycle point (useGeminiStream.handleCompletedTools) but renders on both sides of ui.compactMode so the feature is useful to CLI users without any SDK plumbing.

text
tool_batch_complete (handleCompletedTools)
           ↓
  config.getEmitToolUseSummaries()?
           ↓
   fork generateToolUseSummary (fire-and-forget)
           ↓
  submitQuery() for next turn (streaming starts)
           ↓
   ← summary Promise resolves during streaming →
           ↓
  addItem({type:'tool_use_summary', summary, precedingToolUseIds})
           ↓
  HistoryItemDisplay renders:
    compactMode=false → ● <label> standalone line
    compactMode=true  → hidden; MainContent lookup injects into CompactToolGroupDisplay header

3.2 Key source files

Component File Key logic
Service packages/core/src/services/toolUseSummary.ts generateToolUseSummary, truncateJson, cleanSummary, message factory
Config gate packages/core/src/config/config.ts:getEmitToolUseSummaries Env override → settings → default (true)
Trigger packages/cli/src/ui/hooks/useGeminiStream.ts:handleCompletedTools Fires fast-model call, addItem on resolve
Full-mode render packages/cli/src/ui/components/HistoryItemDisplay.tsx Renders ● <label> line when !compactMode
Compact-mode lookup packages/cli/src/ui/components/MainContent.tsx summaryByCallId map → compactLabel prop to each tool_group
Compact header packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx Replaces default Tool × N with · N tools when label present
Merge handling packages/cli/src/ui/utils/mergeCompactToolGroups.ts Treats tool_use_summary as hidden-in-compact for adjacency
UI type packages/cli/src/ui/types.ts:HistoryItemToolUseSummary { type: 'tool_use_summary', summary, precedingToolUseIds }

3.3 The `` append-only constraint

The central architectural decision in this PR is why the full-mode label is a standalone history item and not a decoration on the tool_group itself.

Qwen Code renders the transcript via Ink's <Static>. Static is append-only: once an item is committed to the terminal buffer, Ink will not repaint that region unless refreshStatic() is called to clear and re-render the entire transcript. This is the performance model the CLI depends on — static items don't re-render on every keystroke.

Now consider the fast-model call's timing:

text
T0   tool batch completes, tool_group is pushed to history
T0+ε tool_group renders through <Static> and is committed to the buffer
T0+1s fast-model call resolves with a label

At T0+1s, we cannot retroactively add the label to the already-committed tool_group. Two options exist:

  1. Update the tool_group's props + call refreshStatic(). Works, but causes a full transcript repaint on every batch — one of the most expensive UI operations in the app. Visible flash. Unacceptable for a cosmetic label.
  2. Render the summary as its own new history item appended after the tool_group. Static handles this natively — new items append cleanly, no repaint.

This PR takes option 2 in full mode. The tool_use_summary entry is a real history item, rendered as a single dim ● <label> line by HistoryItemDisplay. No refreshStatic needed.

Compact mode is different because of mergeCompactToolGroups. When consecutive toolgroups merge, MainContent already calls refreshStatic() — that's an existing codepath, and it re-renders the merged group with the label looked up from history. So compact mode _does get the label as a header replacement. To avoid rendering the same label twice (once as the compact header, once as a trailing ● <label> line), HistoryItemDisplay hides the standalone line when compactMode is true.

text
Full mode              Compact mode (with merge)
───────────            ─────────────────────────
[tool_group]           [merged tool_group — header replaced via lookup]
● <label>              (● <label> line is hidden)

3.4 Gate semantics

Three layers, resolved in order of precedence:

  1. QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0|1|true|false — env override, highest priority.
  2. experimental.emitToolUseSummaries in settings.json — default true.
  3. Implicit skip — if config.getFastModel() returns undefined, generation is skipped regardless of the gate. No error, no user-visible change.

3.5 Output cleaning

cleanSummary runs on every model response before it is added to history:

  1. Take the first line only (drops model reasoning preambles).
  2. Strip bullet prefixes (-, *, •) — models sometimes return the label as a list item.
  3. Strip surrounding quotes/backticks via a bounded {1,10} regex (CodeQL-safe; no real label has more than a handful of wrapping quotes).
  4. Strip prefix labels (Label:, Summary:, Result:, Output:) that some models prepend.
  5. Reject error-message shapes (API error: ..., Error: ..., I cannot ..., I can't ..., Unable to ...) — returns empty string so no history item is added.
  6. Hard-cap length at 100 characters (mobile UI truncates around 30; the slack covers CJK phrases).

3.6 Telemetry

The summary generation call sets promptId: 'tool_use_summary_generation' so its token usage is accounted separately in /stats. This lets users see the exact incremental cost of the feature without conflating it with prompt suggestions or the main session's usage.

4. Deviations from Claude Code (and why)

Deviation Why
Settings layer in addition to env gate Qwen Code renders the label in the CLI; users need a persistent switch, not a per-shell env export.
Default on instead of off Label is immediately user-visible in both display modes; users configuring fastModel are opting into fast-model features already.
Dedicated cleanSummary post-processing Qwen Code supports more heterogeneous providers than CC; some models prepend Label: or wrap in quotes. Normalizing at the boundary keeps the UI consistent.
Stores HistoryItemToolUseSummary rather than emitting a stream message CLI-first implementation; the SDK-stream route is a future PR. The ToolUseSummaryMessage factory is already exported for that work.
Prompt caching not yet wired The fast model is often the same as the main model for users who haven't configured a separate one. Adding cache sharing requires routing via forkedAgent.ts; tracked as a follow-up.
Dual render paths (full-mode inline + compact-mode header) Qwen Code's default is ui.compactMode: false; without the inline full-mode render, the feature would be invisible to most users.

5. Known limitations

  • No session persistence. tool_use_summary is not written to the chat recording JSONL. Resuming a session loses labels; tool groups render with the generic header as a fallback. Low-priority: labels regenerate naturally as the user continues the session.
  • No SDK stream emission yet. The message factory is exported, but the CLI does not yet feed tool_use_summary into the SDK bridge. Follow-up PR.
  • No prompt caching. Each batch incurs a fresh input-token cost. Negligible in absolute terms (~300 tokens) but measurable if you run dozens of batches per turn.
  • Summary for merged compact groups picks the first contributing batch's label. If a user fires ten dissimilar batches back-to-back (tight loop, not typical), the merged compact header will show only the leading batch's intent. Trade-off accepted: fanning out per-batch labels in a merged view is visually noisier than taking the first.
  • Fast model required. Without a configured fastModel, generation is skipped. Falling back to the main model is deliberately disallowed to keep the cost profile bounded.

6. Future work

  1. Wire ToolUseSummaryMessage into the SDK bridge so the existing factory gets used downstream.
  2. Route generation via forkedAgent.ts with enablePromptCaching so repeated tool-name prefixes hit provider caches.
  3. Optional: persist tool_use_summary entries to ChatRecordingService and replay on session resume.
  4. Optional: per-tool-name label shortcuts (e.g., always Read <filename> for a single read_file call) as a pre-LLM fast path.

Developers Development Deployment (`docs/developers/development/deployment.md`)

Qwen Code Execution and Deployment

This document describes how to run Qwen Code and explains the deployment architecture that Qwen Code uses.

Running Qwen Code

There are several ways to run Qwen Code. The option you choose depends on how you intend to use it.


This is the recommended way for end-users to install Qwen Code. It involves downloading the Qwen Code package from the NPM registry.

  • Global install:

    bash
    npm install @qwen-code/qwen-code

    Then, run the CLI from anywhere:

    bash
    qwen
  • NPX execution:

    bash
    # Execute the latest version from NPM without a global install
    npx @qwen-code/qwen-code

2. Running in a sandbox (Docker/Podman)

For security and isolation, Qwen Code can be run inside a container. This is the default way that the CLI executes tools that might have side effects.

  • Directly from the Registry:
    You can run the published sandbox image directly. This is useful for environments where you only have Docker and want to run the CLI.
    bash
    # Run the published sandbox image
    docker run --rm -it ghcr.io/qwenlm/qwen-code:0.0.11
  • Using the --sandbox flag:
    If you have Qwen Code installed locally (using the standard installation described above), you can instruct it to run inside the sandbox container.
    bash
    qwen --sandbox -y -p "your prompt here"

Contributors to the project will want to run the CLI directly from the source code.

  • Development Mode:
    This method provides hot-reloading and is useful for active development.

    bash
    # From the root of the repository
    npm run start
  • Production-like mode (Linked package):
    This method simulates a global installation by linking your local package. It's useful for testing a local build in a production workflow.

    bash
    # Link the local cli package to your global node_modules
    npm link packages/cli
    
    # Now you can run your local version using the `qwen` command
    qwen

4. Running the latest Qwen Code commit from GitHub

You can run the most recently committed version of Qwen Code directly from the GitHub repository. This is useful for testing features still in development.

bash
# Execute the CLI directly from the main branch on GitHub
npx https://github.com/QwenLM/qwen-code

Deployment architecture

The execution methods described above are made possible by the following architectural components and processes:

NPM packages

Qwen Code project is a monorepo that publishes core packages to the NPM registry:

  • @qwen-code/qwen-code-core: The backend, handling logic and tool execution.
  • @qwen-code/qwen-code: The user-facing frontend.

These packages are used when performing the standard installation and when running Qwen Code from the source.

Build and packaging processes

There are two distinct build processes used, depending on the distribution channel:

  • NPM publication: For publishing to the NPM registry, the TypeScript source code in @qwen-code/qwen-code-core and @qwen-code/qwen-code is transpiled into standard JavaScript using the TypeScript Compiler (tsc). The resulting dist/ directory is what gets published in the NPM package. This is a standard approach for TypeScript libraries.

  • GitHub npx execution: When running the latest version of Qwen Code directly from GitHub, a different process is triggered by the prepare script in package.json. This script uses esbuild to bundle the entire application and its dependencies into a single, self-contained JavaScript file. This bundle is created on-the-fly on the user's machine and is not checked into the repository.

Docker sandbox image

The Docker-based execution method is supported by the qwen-code-sandbox container image. This image is published to a container registry and contains a pre-installed, global version of Qwen Code.

Release process

The release process is automated through GitHub Actions. The release workflow performs the following actions:

  1. Build the NPM packages using tsc.
  2. Publish the NPM packages to the artifact registry.
  3. Create GitHub releases with bundled assets.

OpenTUI preview flavor

Standalone archives ship in the classic Node.js flavor by default. Setting the OPENTUI_PREVIEW_RELEASE_ENABLED repository variable to true additionally builds the bun/OpenTUI preview flavor, whose archives carry an -opentui-preview suffix and whose launcher defaults QWEN_TUI_RENDERER to opentui. Leaving the variable unset keeps that flavor off.

The variable decides only what a new release builds. The workflow that mirrors a release to Aliyun OSS does not read it: that workflow can be re-dispatched for any tag, and a tag cut before the flavor existed has no preview archives to verify, so it derives the flavor from the archives the release actually shipped.


Developers Development Integration Tests (`docs/developers/development/integration-tests.md`)

Integration Tests

This document provides information about the integration testing framework used in this project.

Overview

The integration tests are designed to validate the end-to-end functionality of Qwen Code. They execute the built binary in a controlled environment and verify that it behaves as expected when interacting with the file system.

These tests are located in the integration-tests directory and are run using a custom test runner.

Running the tests

The integration tests are not run as part of the default npm run test command. They must be run explicitly using the npm run test:integration:all script.

The integration tests can also be run using the following shortcut:

bash
npm run test:e2e

Running a specific set of tests

To run a subset of test files, you can use npm run <file_name1> .... where <integration test command> is either test:e2e or test:integration* and <file_name> is any of the .test.ts files in the integration-tests/ directory. For example, the following command runs list_directory.test.ts and write_file.test.ts:

bash
npm run test:e2e list_directory write_file

Running a single test by name

To run a single test by its name, use the --test-name-pattern flag:

bash
npm run test:e2e -- --test-name-pattern "reads a file"

Running all tests

To run the entire suite of integration tests, use the following command:

bash
npm run test:integration:all

Sandbox matrix

The all command will run tests for no sandboxing, docker and podman.
Each individual type can be run using the following commands:

bash
npm run test:integration:sandbox:none
bash
npm run test:integration:sandbox:docker
bash
npm run test:integration:sandbox:podman

Diagnostics

The integration test runner provides several options for diagnostics to help track down test failures.

Keeping test output

You can preserve the temporary files created during a test run for inspection. This is useful for debugging issues with file system operations.

To keep the test output set the KEEP_OUTPUT environment variable to true.

bash
KEEP_OUTPUT=true npm run test:integration:sandbox:none

When output is kept, the test runner will print the path to the unique directory for the test run.

Verbose output

For more detailed debugging, set the VERBOSE environment variable to true.

bash
VERBOSE=t