Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/users/features/_meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export default {
'tool-use-summaries': 'Tool-Use Summaries',
'markdown-rendering': 'Markdown Rendering',
'sub-agents': 'SubAgents',
'fleet-preview': 'Fleet Preview',
arena: 'Agent Arena',
skills: 'Skills',
memory: 'Memory',
Expand Down
17 changes: 9 additions & 8 deletions docs/users/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,15 @@ Commands for managing AI tools and models.

These commands invoke bundled skills that provide specialized workflows.

| Command | Description | Usage Examples |
| ------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------- |
| `/review` | Multi-agent code review (12 parallel agents at high effort) | `/review`, `/review 123`, `/review 123 --comment`, `/review --effort low` |
| `/loop` | Run a prompt on a recurring schedule | `/loop 5m check the build` |
| `/simplify` | Review recent changes and apply safe cleanup edits directly | `/simplify`, `/simplify focus on duplication` |
| `/qc-helper` | Answer questions about Qwen Code usage and configuration | `/qc-helper how do I configure MCP?` |

See [Code Review](./code-review.md) for full `/review` documentation.
| Command | Description | Usage Examples |
| ------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------- |
| `/review` | Multi-agent code review (12 parallel agents at high effort) | `/review`, `/review 123`, `/review 123 --comment`, `/review --effort low` |
| `/coordinate` | Coordinate bounded read-only Fleet teammates | `/coordinate investigate the authentication regression` |
| `/loop` | Run a prompt on a recurring schedule | `/loop 5m check the build` |
| `/simplify` | Review recent changes and apply safe cleanup edits directly | `/simplify`, `/simplify focus on duplication` |
| `/qc-helper` | Answer questions about Qwen Code usage and configuration | `/qc-helper how do I configure MCP?` |

See [Code Review](./code-review.md) for `/review` documentation and [Fleet Preview](./fleet-preview.md) for `/coordinate`.

### 1.6 Side Question (`/btw`)

Expand Down
19 changes: 19 additions & 0 deletions docs/users/features/fleet-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Fleet Preview

Fleet Preview coordinates a bounded group of Qwen Code teammates inside the leader process. Teammates share tasks, exchange messages, and appear in the existing Agent View tabs. Investigation teammates run with a runtime-enforced read-only tool set.

## Enable Fleet Preview

Set `experimental.fleet` to `true` in Qwen Code settings and restart. Fleet automatically enables the underlying Agent Team collaboration tools; you do not need to enable `experimental.agentTeam` separately.

## Coordinate an investigation

Run the bundled workflow with a goal:

```text
/coordinate investigate the authentication regression and recommend the smallest fix
```

The leader creates up to three read-only teammates, assigns separate workstreams, reconciles their evidence, and returns a consolidated result. Read-only teammates can inspect files and use team coordination tools, but cannot execute shell commands, modify files, save memory, schedule work, or spawn agents. If code changes are required, the leader performs them after accepting the investigation results.

This preview is intentionally in-process. Independent subprocess teammates, supervisor transport, persistence, recovery, and terminal attach are not included yet.
22 changes: 22 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,28 @@ describe('loadCliConfig', () => {
);
});

it('should keep Fleet disabled by default', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();

await loadCliConfig({}, argv);

expect(mockConfigConstructorParams).toHaveBeenCalledWith(
expect.objectContaining({ fleetEnabled: false }),
);
});

it('should propagate the Fleet opt-in', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();

await loadCliConfig({ experimental: { fleet: true } }, argv);

expect(mockConfigConstructorParams).toHaveBeenCalledWith(
expect.objectContaining({ fleetEnabled: true }),
);
});

it('should keep the session writer lease disabled by default', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2226,6 +2226,7 @@ export async function loadCliConfig(
settings.experimental?.sessionWriterLease === true,
cronEnabled: settings.experimental?.cron ?? true,
cronRecurringMaxAgeDays: settings.experimental?.cronRecurringMaxAgeDays,
fleetEnabled: settings.experimental?.fleet === true,
agentTeamEnabled: settings.experimental?.agentTeam ?? false,
artifactEnabled: settings.experimental?.artifact ?? true,
artifactAutoOpen: settings.artifact?.autoOpen ?? true,
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3652,7 +3652,17 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: false,
description:
'Enable agent team collaboration tools (experimental). When enabled, the model can create agent teams and coordinate work using team_create, team_delete, send_message, task_create, task_update, and task_list tools. Can also be enabled via QWEN_CODE_ENABLE_AGENT_TEAM=1 environment variable.',
'Enable the low-level agent team collaboration tools (experimental). Fleet enables these tools automatically, so Fleet users do not need to enable this setting separately. Can also be enabled via QWEN_CODE_ENABLE_AGENT_TEAM=1 environment variable.',
showInDialog: true,
},
fleet: {
type: 'boolean',
label: 'Enable Fleet Preview',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable the in-process Fleet preview and /coordinate workflow. Fleet coordinates bounded read-only teammates through shared tasks, messages, and existing Agent View tabs. Teammates remain in the leader process until the supervised runtime lands.',
showInDialog: true,
},
artifact: {
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/services/BundledSkillLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ describe('BundledSkillLoader', () => {
mockConfig = {
getSkillManager: vi.fn().mockReturnValue(mockSkillManager),
isCronEnabled: vi.fn().mockReturnValue(false),
isFleetEnabled: vi.fn().mockReturnValue(false),
getModel: vi.fn().mockReturnValue(undefined),
getCliVersion: vi.fn().mockReturnValue('0.21.2'),
getPermissionManager: vi
Expand Down Expand Up @@ -475,6 +476,26 @@ describe('BundledSkillLoader', () => {
expect(commands[0].name).toBe('review');
});

it('shows coordinate only when Fleet is enabled', async () => {
mockSkillManager.listSkills.mockResolvedValue([
makeSkill({ name: 'review' }),
makeSkill({ name: 'coordinate' }),
]);
const loader = new BundledSkillLoader(mockConfig);

expect((await loader.loadCommands(signal)).map((c) => c.name)).toEqual([
'review',
]);

(mockConfig.isFleetEnabled as ReturnType<typeof vi.fn>).mockReturnValue(
true,
);
expect((await loader.loadCommands(signal)).map((c) => c.name)).toEqual([
'review',
'coordinate',
]);
});

describe('skills.disabled filter', () => {
it('omits disabled bundled skills (case-insensitive)', async () => {
mockSkillManager.listSkills.mockResolvedValue([
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/src/services/BundledSkillLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,29 @@ export class BundledSkillLoader implements ICommandLoader {
return true;
});

const fleetEnabled = this.config?.isFleetEnabled?.() ?? false;
const featureVisible = cronVisible.filter((skill) => {
if (skill.name === 'coordinate' && !fleetEnabled) {
debugLogger.debug(
'Hiding skill "coordinate" because Fleet is not enabled',
);
return false;
}
return true;
});

// Apply user-controlled `skills.disabled` filter HERE so disabling a
// bundled skill cannot accidentally hide a same-named built-in
// command or MCP prompt (which would happen if we routed this
// through `CommandService`'s global denylist instead).
const disabled =
this.config?.getDisabledSkillNames() ?? new Set<string>();
const skills = cronVisible.filter(
const skills = featureVisible.filter(
(skill) => !disabled.has(skill.name.toLowerCase()),
);

debugLogger.debug(
`Loaded ${skills.length} bundled skill(s) as slash commands; ${cronVisible.length - skills.length} hidden by skills.disabled`,
`Loaded ${skills.length} bundled skill(s) as slash commands; ${allSkills.length - featureVisible.length} hidden by feature flags; ${featureVisible.length - skills.length} hidden by skills.disabled`,
);

return skills.map((skill) => ({
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2444,7 +2444,7 @@ export const AppContainer = (props: AppContainerProps) => {
if (agentViewState.activeView !== 'main') {
const agent = agentViewState.agents.get(agentViewState.activeView);
if (agent) {
agent.interactiveAgent.enqueueMessage(submittedValue.trim());
void agent.session.send(submittedValue.trim());
return;
}
}
Expand Down
96 changes: 33 additions & 63 deletions packages/cli/src/ui/components/agent-view/AgentChatContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,19 @@
*/

/**
* Presentational transcript renderer for a single AgentCore. Subscribes
* to the core's event emitter internally and force-renders on updates,
* Presentational transcript renderer for an AgentSessionView. Subscribes
* to the view internally and force-renders on updates,
* so consumers only pass state props and don't wire their own listeners.
*/

import { Box, Text, Static } from 'ink';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import {
AgentStatus,
AgentEventType,
getGitBranch,
type AgentCore,
type AgentInteractive,
type AgentStatusChangeEvent,
type AgentSessionView,
type ApprovalDecision,
type ToolCallConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
Expand All @@ -33,30 +32,20 @@ import { AgentHeader } from './AgentHeader.js';
import { buildThoughtHeadIdMap } from '../../utils/historyUtils.js';

export interface AgentChatContentProps {
/** The agent's AgentCore — the source of truth for transcript state. */
core: AgentCore;
/**
* The InteractiveAgent wrapper, if any. Present for live arena tabs;
* omit for read-only transcript surfaces. When provided, drives the
* spinner and the embedded-shell affordance — all reads happen inside
* this component, which re-renders on the relevant events, so state
* stays fresh without plumbing props from an ancestor that doesn't
* subscribe.
*/
interactiveAgent?: AgentInteractive | null;
view: AgentSessionView;
answerApproval(decision: ApprovalDecision): Promise<void>;
/** Stable identifier used for memo keys and the Static remount key. */
instanceKey: string;
/** Optional display name shown in the header. */
modelName?: string;
}

export const AgentChatContent = ({
core,
interactiveAgent,
view,
answerApproval,
instanceKey,
modelName,
}: AgentChatContentProps) => {
const readonly = !interactiveAgent;
const uiState = useUIState();
const { historyRemountKey, availableTerminalHeight, constrainHeight } =
uiState;
Expand All @@ -73,46 +62,29 @@ export const AgentChatContent = ({
setRenderTick(tickRef.current);
}, []);

useEffect(() => {
const emitter = core.getEventEmitter();

const onStatusChange = (_event: AgentStatusChangeEvent) => forceRender();
const onToolCall = () => forceRender();
const onToolResult = () => forceRender();
const onRoundEnd = () => forceRender();
const onApproval = () => forceRender();
const onOutputUpdate = () => forceRender();
const onFinish = () => forceRender();

emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange);
emitter.on(AgentEventType.TOOL_CALL, onToolCall);
emitter.on(AgentEventType.TOOL_RESULT, onToolResult);
emitter.on(AgentEventType.ROUND_END, onRoundEnd);
emitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
emitter.on(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
emitter.on(AgentEventType.FINISH, onFinish);

return () => {
emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange);
emitter.off(AgentEventType.TOOL_CALL, onToolCall);
emitter.off(AgentEventType.TOOL_RESULT, onToolResult);
emitter.off(AgentEventType.ROUND_END, onRoundEnd);
emitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
emitter.off(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
emitter.off(AgentEventType.FINISH, onFinish);
};
}, [core, forceRender]);

const messages = core.getMessages();
const pendingApprovals = core.getPendingApprovals();
const liveOutputs = core.getLiveOutputs();
const shellPids = core.getShellPids();
useEffect(() => view.onChange(forceRender), [view, forceRender]);

const messages = view.getMessages();
const pendingApprovals = new Map(
[...view.getPendingApprovals()].map(([callId, details]) => [
callId,
{
...details,
onConfirm: async (
outcome: Parameters<ToolCallConfirmationDetails['onConfirm']>[0],
payload?: Parameters<ToolCallConfirmationDetails['onConfirm']>[1],
) => answerApproval({ callId, outcome, payload }),
} as ToolCallConfirmationDetails,
]),
);
const liveOutputs = view.getLiveOutputs();
const shellPids = view.getShellPids();

// Read status/PTY/timing state fresh on every render — this component
// re-renders on STATUS_CHANGE/TOOL_CALL/TOOL_OUTPUT_UPDATE so the reads
// stay current without prop plumbing from a non-subscribed ancestor.
const status = interactiveAgent?.getStatus() ?? AgentStatus.COMPLETED;
const executionStartTimes = interactiveAgent?.getExecutionStartTimes();
const status = view.getStatus();
const executionStartTimes = view.getExecutionStartTimes();
const activePtyId =
shellPids.size > 0
? ((shellPids.values().next().value as number | undefined) ?? null)
Expand All @@ -128,28 +100,26 @@ export const AgentChatContent = ({
const { setAgentShellFocused } = useAgentViewActions();

useEffect(() => {
if (readonly) return;
setAgentShellFocused(embeddedShellFocused);
// Intentionally not resetting on unmount: calling setState on a parent
// context provider during effect cleanup triggers React error #185
// ("Cannot update a component while rendering a different component")
// when both child and provider unmount in the same commit phase.
}, [embeddedShellFocused, readonly, setAgentShellFocused]);
}, [embeddedShellFocused, setAgentShellFocused]);

useEffect(() => {
if (!activePtyId) setEmbeddedShellFocused(false);
}, [activePtyId]);

useKeypress(
(key) => {
if (readonly) return;
if (key.ctrl && key.name === 'f') {
if (activePtyId || embeddedShellFocused) {
setEmbeddedShellFocused((prev) => !prev);
}
}
},
{ isActive: !readonly },
{ isActive: true },
);

// tickRef.current in deps ensures we rebuild when events fire even if
Expand Down Expand Up @@ -204,7 +174,7 @@ export const AgentChatContent = ({
[allItems],
);

const agentWorkingDir = core.runtimeContext.getTargetDir() ?? '';
const agentWorkingDir = view.workingDir;
// Cache the branch — it won't change during the agent's lifetime and
// getGitBranch uses synchronous execSync which blocks the render loop.
const agentGitBranch = useMemo(
Expand All @@ -213,7 +183,7 @@ export const AgentChatContent = ({
[instanceKey],
);

const agentModelId = core.modelConfig.model ?? '';
const agentModelId = view.modelId;

return (
<Box flexDirection="column">
Expand Down Expand Up @@ -258,7 +228,7 @@ export const AgentChatContent = ({
availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
isFocused={!readonly}
isFocused={true}
activeShellPtyId={activePtyId}
embeddedShellFocused={embeddedShellFocused}
/>
Expand Down
9 changes: 3 additions & 6 deletions packages/cli/src/ui/components/agent-view/AgentChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,14 @@ export const AgentChatView = ({ agentId }: AgentChatViewProps) => {
const { agents } = useAgentViewState();
const agent = agents.get(agentId);

const interactiveAgent = agent?.interactiveAgent;
const core = interactiveAgent?.getCore();

if (!agent || !interactiveAgent || !core) {
if (!agent) {
return <AgentChatMissing label={`Agent "${agentId}" not found.`} />;
}

return (
<AgentChatContent
core={core}
interactiveAgent={interactiveAgent}
view={agent.view}
answerApproval={agent.answerApproval}
instanceKey={agentId}
modelName={agent.modelName}
/>
Expand Down
Loading
Loading