From 98d0ae5fb75b76d846909271ed94a421754ac48a Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Sun, 14 Jun 2026 01:14:55 +0800 Subject: [PATCH 1/2] feat: configurable default swarm mode --- .changeset/default-swarm-mode.md | 7 ++ apps/kimi-code/src/cli/commands.ts | 5 +- apps/kimi-code/src/cli/options.ts | 4 + apps/kimi-code/src/cli/run-shell.ts | 1 + apps/kimi-code/src/main.ts | 1 + apps/kimi-code/src/tui/kimi-tui.ts | 75 +++++++++++- apps/kimi-code/src/tui/types.ts | 1 + apps/kimi-code/test/cli/main.test.ts | 1 + apps/kimi-code/test/cli/options.test.ts | 20 ++++ apps/kimi-code/test/cli/run-prompt.test.ts | 1 + apps/kimi-code/test/cli/run-shell.test.ts | 11 ++ apps/kimi-code/test/tui/activity-pane.test.ts | 1 + .../test/tui/kimi-tui-message-flow.test.ts | 1 + .../test/tui/kimi-tui-startup.test.ts | 113 ++++++++++++++++++ .../kimi-code/test/tui/message-replay.test.ts | 1 + .../test/tui/signal-handlers.test.ts | 1 + docs/en/configuration/config-files.md | 2 + docs/en/reference/kimi-command.md | 5 +- docs/zh/configuration/config-files.md | 2 + docs/zh/reference/kimi-command.md | 5 +- packages/agent-core/src/config/schema.ts | 2 + packages/agent-core/src/rpc/core-api.ts | 1 + packages/agent-core/src/rpc/core-impl.ts | 7 ++ .../test/harness/swarm-mode-session.test.ts | 108 +++++++++++++++++ packages/node-sdk/src/types.ts | 1 + 25 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 .changeset/default-swarm-mode.md create mode 100644 packages/agent-core/test/harness/swarm-mode-session.test.ts diff --git a/.changeset/default-swarm-mode.md b/.changeset/default-swarm-mode.md new file mode 100644 index 000000000..076594a75 --- /dev/null +++ b/.changeset/default-swarm-mode.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Add configurable default swarm mode via `default_swarm_mode` in config.toml and `--swarm` / `--no-swarm` CLI flags. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index faf1e1da8..8fb459a08 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -73,7 +73,9 @@ export function createProgram( ) .addOption(new Option('--yes').hideHelp().default(false)) .addOption(new Option('--auto-approve').hideHelp().default(false)) - .option('--plan', 'Start in plan mode.', false); + .option('--plan', 'Start in plan mode.', false) + .option('--swarm', 'Start in swarm mode.') + .option('--no-swarm', 'Do not start in swarm mode.'); registerExportCommand(program); registerProviderCommand(program); @@ -115,6 +117,7 @@ export function createProgram( yolo: yoloValue, auto: autoValue, plan: raw['plan'] as boolean, + swarm: raw['swarm'] as boolean | undefined, model: raw['model'] as string | undefined, outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'], prompt: raw['prompt'] as string | undefined, diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 98f4cb196..bf2c2fedf 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -7,6 +7,7 @@ export interface CLIOptions { yolo: boolean; auto: boolean; plan: boolean; + swarm: boolean | undefined; model: string | undefined; outputFormat: PromptOutputFormat | undefined; prompt: string | undefined; @@ -46,6 +47,9 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions { if (promptMode && opts.plan) { throw new OptionConflictError('Cannot combine --prompt with --plan.'); } + if (promptMode && opts.swarm) { + throw new OptionConflictError('Cannot combine --prompt with --swarm.'); + } if (promptMode && opts.session === '') { throw new OptionConflictError('Cannot use --session without an id in prompt mode.'); } diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index e5bdfef24..04eafbdb2 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -104,6 +104,7 @@ export async function runShell( startupNotice: configWarning, migrationPlan, migrateOnly: runOptions.migrateOnly, + defaultSwarmMode: config.defaultSwarmMode, }); initializeCliTelemetry({ diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index e94472590..ef6c963a2 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -109,6 +109,7 @@ const MIGRATE_CLI_OPTIONS: CLIOptions = { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 0337785f0..f7238b0b7 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -57,6 +57,7 @@ import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent } from './components/dialogs/session-picker'; +import { SwarmStartPermissionPromptComponent } from './components/dialogs/swarm-start-permission-prompt'; import { FileMentionProvider, type SlashAutocompleteCommand, @@ -70,6 +71,7 @@ import { GoalSetMessageComponent, } from './components/messages/goal-panel'; import { SkillActivationComponent } from './components/messages/skill-activation'; +import { SwarmModeMarkerComponent } from './components/messages/swarm-markers'; import { NoticeMessageComponent, StatusMessageComponent, @@ -150,6 +152,8 @@ export interface KimiTUIStartupInput { readonly migrationPlan?: MigrationPlan | null; /** When true, run only the migration screen, then exit (the `kimi migrate` command). */ readonly migrateOnly?: boolean; + /** Default swarm mode from config.toml; CLI flags override this. */ + readonly defaultSwarmMode?: boolean; } type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; @@ -160,13 +164,14 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { : input.cliOptions.yolo ? 'yolo' : 'manual'; + const startupSwarm = input.cliOptions.swarm ?? input.defaultSwarmMode ?? false; return { model: '', workDir: input.workDir, sessionId: '', permissionMode: startupPermission, planMode: input.cliOptions.plan, - swarmMode: false, + swarmMode: startupSwarm, thinking: false, contextUsage: 0, contextTokens: 0, @@ -260,6 +265,7 @@ export class KimiTUI { yolo: startupInput.cliOptions.yolo, auto: startupInput.cliOptions.auto, plan: startupInput.cliOptions.plan, + swarm: startupInput.cliOptions.swarm, model: startupInput.cliOptions.model, startupNotice: startupInput.startupNotice, }, @@ -514,6 +520,58 @@ export class KimiTUI { this.updateTerminalTitle(); } void this.refreshSkillCommands(this.session); + if (!shouldReplayHistory) { + void this.promptForSwarmPermissionIfNeeded(); + } + } + + private async promptForSwarmPermissionIfNeeded(): Promise { + if (!this.state.appState.swarmMode || this.state.appState.permissionMode !== 'manual') { + return; + } + const session = this.session; + if (session === undefined) return; + + this.deferUserMessages = true; + const restore = (): void => { + this.deferUserMessages = false; + this.restoreEditor(); + }; + + this.mountEditorReplacement( + new SwarmStartPermissionPromptComponent({ + onSelect: (choice) => { + restore(); + if (choice === 'auto' || choice === 'yolo') { + void (async () => { + try { + await session.setPermission(choice); + } catch (error) { + this.showError(`Failed to set permission mode: ${formatErrorMessage(error)}`); + await this.disableStartupSwarmMode(session); + return; + } + this.setAppState({ permissionMode: choice }); + })(); + } + }, + onCancel: () => { + restore(); + void this.disableStartupSwarmMode(session); + }, + }), + ); + } + + private async disableStartupSwarmMode(session: Session): Promise { + try { + await session.setSwarmMode(false, 'manual'); + } catch (error) { + this.showError(`Failed to disable swarm mode: ${formatErrorMessage(error)}`); + } + this.setAppState({ swarmMode: false }); + this.state.transcriptContainer.addChild(new SwarmModeMarkerComponent('inactive')); + this.state.ui.requestRender(); } private async showTmuxKeyboardWarningIfNeeded(): Promise { @@ -537,6 +595,7 @@ export class KimiTUI { model: startup.model, permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined, planMode: startup.plan ? true : undefined, + swarmMode: this.state.appState.swarmMode ? true : undefined, }; try { @@ -1090,10 +1149,10 @@ export class KimiTUI { }); } - // Apply --auto/--yolo/--plan startup flags to a resumed session. The resumed - // session may already be in plan mode from its persisted records, and - // re-entering plan mode throws, so only enable it when it is not active yet. - // setPermission is idempotent and needs no such guard. + // Apply --auto/--yolo/--plan/--swarm startup flags to a resumed session. The + // resumed session may already be in plan/swarm mode from its persisted + // records, and re-entering plan mode throws, so only enable it when it is not + // active yet. setPermission is idempotent and needs no such guard. private async applyStartupModesToResumedSession(session: Session): Promise { const { startup } = this.options; if (startup.auto) { @@ -1107,6 +1166,12 @@ export class KimiTUI { await session.setPlanMode(true); } } + if (startup.swarm) { + const status = await session.getStatus(); + if (!status.swarmMode) { + await session.setSwarmMode(true, 'manual'); + } + } } // Re-apply startup flags that the user explicitly passed on the command line. diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index bbf047073..80f3ff3d2 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -192,6 +192,7 @@ export interface TUIStartupOptions { readonly yolo: boolean; readonly auto: boolean; readonly plan: boolean; + readonly swarm?: boolean; readonly model?: string; readonly startupNotice?: string; } diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index 52aba94b1..0168d673e 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -140,6 +140,7 @@ function defaultOpts(): CLIOptions { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index e14629e01..d0d645c06 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -167,6 +167,26 @@ describe('CLI options parsing', () => { }); }); + describe('--swarm / --no-swarm', () => { + it('sets swarm mode flag with --swarm', () => { + expect(parse(['--swarm']).swarm).toBe(true); + }); + + it('clears swarm mode flag with --no-swarm', () => { + expect(parse(['--no-swarm']).swarm).toBe(false); + }); + + it('leaves swarm mode unspecified when the flag is absent', () => { + expect(parse([]).swarm).toBeUndefined(); + }); + + it('rejects prompt mode with --swarm', () => { + const opts = parse(['-p', 'run this', '--swarm']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Cannot combine --prompt with --swarm.'); + }); + }); + describe('--auto / --yolo / --plan with --session / --continue', () => { it('allows --auto with --continue', () => { const opts = parse(['--auto', '--continue']); diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index a3620aa35..c419bd816 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -131,6 +131,7 @@ function opts(overrides: Partial[0]> = {}) { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: 'say hello', diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index bab4fb152..f690a0d78 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -177,6 +177,7 @@ describe('runShell', () => { yolo: true, auto: false, plan: true, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, @@ -265,6 +266,7 @@ describe('runShell', () => { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, @@ -305,6 +307,7 @@ describe('runShell', () => { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, @@ -343,6 +346,7 @@ describe('runShell', () => { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, @@ -381,6 +385,7 @@ describe('runShell', () => { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, @@ -414,6 +419,7 @@ describe('runShell', () => { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, @@ -465,6 +471,7 @@ describe('runShell', () => { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, @@ -503,6 +510,7 @@ describe('runShell', () => { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, @@ -533,6 +541,7 @@ describe('runShell', () => { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, @@ -570,6 +579,7 @@ describe('runShell', () => { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, @@ -623,6 +633,7 @@ describe('runShell', () => { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index b719da163..01eec96f3 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -22,6 +22,7 @@ function makeStartupInput(): KimiTUIStartupInput { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 3868f8fd2..ab45e07a0 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -95,6 +95,7 @@ function makeStartupInput(): KimiTUIStartupInput { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index bed844af8..b112d16f2 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -23,6 +23,7 @@ vi.mock('#/tui/commands/prompts', async (importOriginal) => { interface StartupDriver { state: TUIState; + deferUserMessages: boolean; init(): Promise; handleLoginCommand(): Promise; handleLogoutCommand(): Promise; @@ -60,6 +61,7 @@ const MIGRATION_PLAN: MigrationPlan = { function makeStartupInput( cliOptions: Partial = {}, tuiConfig: Partial = {}, + overrides: Partial> = {}, ): KimiTUIStartupInput { return { cliOptions: { @@ -68,6 +70,7 @@ function makeStartupInput( yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, @@ -83,6 +86,7 @@ function makeStartupInput( }, version: '0.0.0-test', workDir: '/tmp/proj-a', + ...overrides, }; } @@ -106,6 +110,7 @@ function makeSession(overrides: Record = {}) { setThinking: vi.fn(async () => {}), setPermission: vi.fn(async () => {}), setPlanMode: vi.fn(async () => {}), + setSwarmMode: vi.fn(async () => {}), getGoal: vi.fn(async () => ({ goal: null })), onEvent: vi.fn(() => () => {}), getResumeState: vi.fn(() => null), @@ -264,6 +269,114 @@ describe('KimiTUI startup', () => { }); }); + it('passes --swarm to createSession on fresh startup', async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'auto', + planMode: false, + swarmMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + }); + const harness = makeHarness(session); + const driver = makeDriver(harness, makeStartupInput({ swarm: true })); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + permission: undefined, + planMode: undefined, + swarmMode: true, + }); + expect(driver.state.appState.swarmMode).toBe(true); + }); + + it('applies config.defaultSwarmMode when CLI does not override', async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'auto', + planMode: false, + swarmMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + }); + const harness = makeHarness(session); + const driver = makeDriver(harness, makeStartupInput({}, {}, { defaultSwarmMode: true })); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + permission: undefined, + planMode: undefined, + swarmMode: true, + }); + expect(driver.state.appState.swarmMode).toBe(true); + }); + + it('--no-swarm suppresses config.defaultSwarmMode', async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'auto', + planMode: false, + swarmMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + }); + const harness = makeHarness(session); + const driver = makeDriver(harness, makeStartupInput({ swarm: false }, {}, { defaultSwarmMode: true })); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + permission: undefined, + planMode: undefined, + }); + expect(driver.state.appState.swarmMode).toBe(false); + }); + + it('shows swarm permission prompt when default swarm is enabled with manual permission', async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode: false, + swarmMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + }); + const harness = makeHarness(session); + const driver = makeDriver(harness, makeStartupInput({ swarm: true })); + + await expect(driver.init()).resolves.toBe(false); + await ( + driver as unknown as { + finishStartup(shouldReplayHistory: boolean): Promise; + } + ).finishStartup(false); + await new Promise((resolve) => setImmediate(resolve)); + + expect(driver.deferUserMessages).toBe(true); + expect(driver.state.editorContainer.children.length).toBeGreaterThan(0); + }); + it('resumes the latest session for --continue and marks history for replay', async () => { const session = makeSession({ id: 'ses-latest' }); const harness = makeHarness(session, { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index f54bac27b..54c12ce73 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -45,6 +45,7 @@ function makeStartupInput(): KimiTUIStartupInput { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index 9d630a26d..8488c7f33 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -18,6 +18,7 @@ function makeStartupInput(): KimiTUIStartupInput { yolo: false, auto: false, plan: false, + swarm: undefined, model: undefined, outputFormat: undefined, prompt: undefined, diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 0d64f5044..6f0fcf2c7 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -27,6 +27,7 @@ default_model = "kimi-code/kimi-for-coding" default_thinking = true default_permission_mode = "manual" default_plan_mode = false +default_swarm_mode = false merge_all_available_skills = true telemetry = true @@ -79,6 +80,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `default_thinking` | `boolean` | `false` | Whether new sessions enable Thinking (deep reasoning) mode by default; can be toggled from the model menu inside a session. Even when set to `true`, `[thinking].mode = "off"` will still force Thinking off | | `default_permission_mode` | `string` | `manual` | Default permission mode for new sessions; one of `manual` (prompt each time), `auto` (auto-approve read operations), or `yolo` (auto-approve everything) | | `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default | +| `default_swarm_mode` | `boolean` | `false` | Whether new sessions start in Swarm mode (parallel subagent delegation) by default. When permission mode is `manual`, a startup prompt asks whether to switch to `auto`/`yolo` or keep manual approvals | | `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | | `extra_skill_dirs` | `array` | — | Extra skill search directories, layered on top of the default directories | | `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; disabled only when explicitly set to `false` | diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index a0623445b..da2869a0f 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -23,6 +23,7 @@ All flags are optional — run `kimi` directly to enter an interactive session: | `--yolo` | `-y` | Auto-approve regular tool calls, skipping approval requests | | `--auto` | | Start with auto permission mode; tool approvals are handled automatically and the Agent will not ask the user questions | | `--plan` | | Start a new session in Plan mode — the AI will prioritize read-only tools for exploration and planning | +| `--swarm` | | Start a new session in Swarm mode. `--no-swarm` explicitly disables it, overriding `default_swarm_mode` in the config file | | `--skills-dir ` | | Load Skills from the specified directory, replacing the automatically discovered user and project directories. Can be repeated | `-r` / `--resume` is a hidden alias for `--session`; `--yes` and `--auto-approve` are hidden aliases for `--yolo` and are not shown in help output. @@ -37,10 +38,10 @@ The following combinations are rejected at startup: - `--continue` and `--session` are mutually exclusive — both mean "resume a previous session" - `--yolo` and `--auto` are mutually exclusive — the two permission modes cannot be combined -- `--prompt` cannot be used with `--yolo`, `--auto`, or `--plan` — non-interactive mode uses `auto` permission by default +- `--prompt` cannot be used with `--yolo`, `--auto`, `--plan`, or `--swarm` — non-interactive mode uses `auto` permission by default - `--output-format` can only be used together with `--prompt` -When resuming a session, you can override its saved permission or plan mode by adding `--auto`, `--yolo`, or `--plan`. For example, `kimi --continue --auto` resumes the latest session and switches it to auto permission mode. +When resuming a session, you can override its saved permission, plan mode, or swarm mode by adding `--auto`, `--yolo`, `--plan`, or `--swarm`. For example, `kimi --continue --auto` resumes the latest session and switches it to auto permission mode. ## Common Usage diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index e0a215f56..f4d422a6c 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -27,6 +27,7 @@ default_model = "kimi-code/kimi-for-coding" default_thinking = true default_permission_mode = "manual" default_plan_mode = false +default_swarm_mode = false merge_all_available_skills = true telemetry = true @@ -79,6 +80,7 @@ timeout = 5 | `default_thinking` | `boolean` | `false` | 新会话是否默认开启 Thinking(深度推理)模式;可在会话内从模型菜单切换。即使设为 `true`,`[thinking].mode = "off"` 也会强制关闭 | | `default_permission_mode` | `string` | `manual` | 新会话的默认权限模式,可选 `manual`(逐次询问)、`auto`(自动批准读操作)、`yolo`(全部自动批准) | | `default_plan_mode` | `boolean` | `false` | 新会话是否默认以 Plan 模式(先出计划再执行)启动 | +| `default_swarm_mode` | `boolean` | `false` | 新会话是否默认以 Swarm 模式(并行子 Agent 委托)启动。当权限模式为 `manual` 时,启动会弹出提示,询问是否切换到 `auto`/`yolo` 或保留手动审批 | | `merge_all_available_skills` | `boolean` | `true` | 是否合并所有目录中的 Agent Skills | | `extra_skill_dirs` | `array` | — | 额外 Skill 搜索目录,叠加到默认目录之上 | | `telemetry` | `boolean` | `true` | 是否启用匿名遥测;显式设为 `false` 时关闭 | diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 9e8c9180b..199b56882 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -23,6 +23,7 @@ kimi [options] | `--yolo` | `-y` | 自动批准普通工具调用,跳过审批请求 | | `--auto` | | 以 auto 权限模式启动;工具审批自动处理,Agent 不会向用户提问 | | `--plan` | | 以 Plan 模式启动新会话,AI 会优先使用只读工具进行探索和规划 | +| `--swarm` | | 以 Swarm 模式启动新会话。`--no-swarm` 可显式关闭,覆盖配置文件中的 `default_swarm_mode` | | `--skills-dir ` | | 从指定目录加载 Skills,替换自动发现的用户和项目目录。可重复传入 | `-r` / `--resume` 是 `--session` 的隐藏别名;`--yes` 和 `--auto-approve` 是 `--yolo` 的隐藏别名,在帮助信息中不显示。 @@ -37,10 +38,10 @@ kimi [options] - `--continue` 与 `--session` 互斥——两者都表示"恢复历史会话" - `--yolo` 和 `--auto` 互斥——两种权限模式互斥 -- `--prompt` 不能与 `--yolo`、`--auto` 或 `--plan` 同时使用——非交互模式固定使用 `auto` 权限 +- `--prompt` 不能与 `--yolo`、`--auto`、`--plan` 或 `--swarm` 同时使用——非交互模式固定使用 `auto` 权限 - `--output-format` 只能与 `--prompt` 一起使用 -恢复会话时,可以通过 `--auto`、`--yolo` 或 `--plan` 覆盖原会话保存的权限或计划模式。例如,`kimi --continue --auto` 会恢复最近会话并切换到 auto 权限模式。 +恢复会话时,可以通过 `--auto`、`--yolo`、`--plan` 或 `--swarm` 覆盖原会话保存的权限、计划模式或 Swarm 模式。例如,`kimi --continue --auto` 会恢复最近会话并切换到 auto 权限模式。 ## 典型用法 diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 094239b73..0466eab0d 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -196,6 +196,7 @@ export const KimiConfigSchema = z.object({ defaultThinking: z.boolean().optional(), defaultPermissionMode: PermissionModeSchema.optional(), defaultPlanMode: z.boolean().optional(), + defaultSwarmMode: z.boolean().optional(), permission: PermissionConfigSchema.optional(), hooks: z.array(HookDefSchema).optional(), services: ServicesConfigSchema.optional(), @@ -235,6 +236,7 @@ export const KimiConfigPatchSchema = z defaultThinking: z.boolean().optional(), defaultPermissionMode: PermissionModeSchema.optional(), defaultPlanMode: z.boolean().optional(), + defaultSwarmMode: z.boolean().optional(), permission: PermissionConfigPatchSchema.optional(), hooks: z.array(HookDefSchema).optional(), services: ServicesConfigPatchSchema.optional(), diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index b080802ee..8b0207db7 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -46,6 +46,7 @@ export interface CreateSessionPayload { readonly model?: string | undefined; readonly thinking?: string | undefined; readonly permission?: PermissionMode | undefined; + readonly swarmMode?: boolean | undefined; readonly metadata?: JsonObject | undefined; readonly mcpServers?: Readonly>; } diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 204715da6..2f5616b6a 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -207,6 +207,7 @@ export class KimiCore implements PromisableMethods { const id = options.id ?? createSessionId(); const thinkingLevel = resolveThinkingLevel(options.thinking, config); const permissionMode = options.permission ?? config.defaultPermissionMode; + const swarmMode = options.swarmMode ?? config.defaultSwarmMode; const baseMcpConfig = await resolveSessionMcpConfig({ cwd: workDir, homeDir: this.homeDir, @@ -276,6 +277,12 @@ export class KimiCore implements PromisableMethods { if (config.defaultPlanMode === true) { await mainAgent.planMode.enter(); } + // Honor createSession swarmMode option or config.defaultSwarmMode for fresh + // sessions. Resumed sessions restore their own swarm state from records + // and never re-apply this. + if (swarmMode === true) { + mainAgent.swarmMode.enter('manual'); + } await session.writeMetadata(); await session.flushMetadata(); } catch (error) { diff --git a/packages/agent-core/test/harness/swarm-mode-session.test.ts b/packages/agent-core/test/harness/swarm-mode-session.test.ts new file mode 100644 index 000000000..b0713f190 --- /dev/null +++ b/packages/agent-core/test/harness/swarm-mode-session.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createRPC, KimiCore, type CoreAPI, type SDKAPI } from '../../src'; + +const BASE_CONFIG = ` +default_model = "kimi-code/kimi-for-coding" + +[providers."managed:kimi-code"] +type = "kimi" +api_key = "test-key" +base_url = "https://api.example/v1" + +[models."kimi-code/kimi-for-coding"] +provider = "managed:kimi-code" +model = "kimi-for-coding" +max_context_size = 1000000 +`; + +describe('swarm-mode bootstrap from config.defaultSwarmMode', () => { + let tmp: string; + let homeDir: string; + let workDir: string; + let configPath: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-swarm-mode-')); + homeDir = join(tmp, 'home'); + workDir = join(tmp, 'work'); + configPath = join(tmp, 'config.toml'); + await mkdir(workDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('activates swarm mode on a new session when config.defaultSwarmMode is true', async () => { + await writeFile(configPath, `default_swarm_mode = true\n${BASE_CONFIG}`); + const rpc = await createTestRpc(); + const created = await rpc.createSession({ workDir }); + await rpc.closeSession({ sessionId: created.id }); + + expect(await countSwarmModeEnters()).toBe(1); + }); + + it('leaves swarm mode inactive when config.defaultSwarmMode is absent', async () => { + await writeFile(configPath, BASE_CONFIG); + const rpc = await createTestRpc(); + const created = await rpc.createSession({ workDir }); + await rpc.closeSession({ sessionId: created.id }); + + expect(await countSwarmModeEnters()).toBe(0); + }); + + it('does not apply config.defaultSwarmMode when resuming an existing session', async () => { + await writeFile(configPath, BASE_CONFIG); + const rpc = await createTestRpc(); + const created = await rpc.createSession({ workDir }); + await rpc.closeSession({ sessionId: created.id }); + + // Turning the default on after the session already exists must not + // retroactively push a resumed session into swarm mode. + await writeFile(configPath, `default_swarm_mode = true\n${BASE_CONFIG}`); + const freshRpc = await createTestRpc(); + await freshRpc.resumeSession({ sessionId: created.id }); + await freshRpc.closeSession({ sessionId: created.id }); + + expect(await countSwarmModeEnters()).toBe(0); + }); + + it('lets createSession swarmMode override config.defaultSwarmMode', async () => { + await writeFile(configPath, `default_swarm_mode = false\n${BASE_CONFIG}`); + const rpc = await createTestRpc(); + const created = await rpc.createSession({ workDir, swarmMode: true }); + await rpc.closeSession({ sessionId: created.id }); + + expect(await countSwarmModeEnters()).toBe(1); + }); + + async function countSwarmModeEnters(): Promise { + const suffix = join('agents', 'main', 'wire.jsonl'); + const entries = await readdir(homeDir, { recursive: true }); + const match = entries.find((entry) => entry.endsWith(suffix)); + if (match === undefined) { + throw new Error('wire.jsonl not found under session home'); + } + const lines = (await readFile(join(homeDir, match), 'utf-8')) + .split('\n') + .filter((line) => line.trim().length > 0); + return lines.filter((line) => (JSON.parse(line) as { type?: string }).type === 'swarm_mode.enter') + .length; + } + + async function createTestRpc() { + const [coreRpc, sdkRpc] = createRPC(); + void new KimiCore(coreRpc, { homeDir, configPath }); + return sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async () => ({ decision: 'rejected' as const })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + } +}); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 041d78495..f8aeb3cc7 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -97,6 +97,7 @@ export interface CreateSessionOptions { readonly thinking?: string | undefined; readonly permission?: PermissionMode | undefined; readonly planMode?: boolean; + readonly swarmMode?: boolean; readonly metadata?: JsonObject | undefined; readonly kaos?: Kaos | undefined; readonly persistenceKaos?: Kaos | undefined; From 71d96532a9812d72a66d8b3a3c21c921d0ff3cd8 Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Sun, 14 Jun 2026 02:52:33 +0800 Subject: [PATCH 2/2] fix(tui,config): address review comments for default swarm mode - Preserve explicit --no-swarm when creating sessions - Reapply --swarm to UI state after session replay hydration - Serialize defaultSwarmMode when writing config TOML - Add regression tests for the above --- .changeset/fix-swarm-mode-cli-config.md | 6 +++++ apps/kimi-code/src/tui/kimi-tui.ts | 13 ++++++---- .../test/tui/kimi-tui-startup.test.ts | 25 +++++++++++++++++++ packages/agent-core/src/config/toml.ts | 1 + .../agent-core/test/config/configs.test.ts | 15 +++++++++++ 5 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-swarm-mode-cli-config.md diff --git a/.changeset/fix-swarm-mode-cli-config.md b/.changeset/fix-swarm-mode-cli-config.md new file mode 100644 index 000000000..e0dfb8ca2 --- /dev/null +++ b/.changeset/fix-swarm-mode-cli-config.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix default swarm mode handling: preserve explicit `--no-swarm`, reapply `--swarm` after session replay, and persist `default_swarm_mode` to the config file. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f7238b0b7..a771cae74 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -506,7 +506,7 @@ export class KimiTUI { } if (shouldReplayHistory) { await this.sessionReplay.hydrateFromReplay(this.requireSession()); - this.applyStartupPermissionAndPlanToAppState(); + this.applyStartupPermissionPlanAndSwarmToAppState(); } const resumeState = this.session?.getResumeState(); if (resumeState?.warning !== undefined) { @@ -595,7 +595,7 @@ export class KimiTUI { model: startup.model, permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined, planMode: startup.plan ? true : undefined, - swarmMode: this.state.appState.swarmMode ? true : undefined, + swarmMode: this.state.appState.swarmMode, }; try { @@ -663,7 +663,7 @@ export class KimiTUI { } await this.setSession(session); await this.syncRuntimeState(session); - this.applyStartupPermissionAndPlanToAppState(); + this.applyStartupPermissionPlanAndSwarmToAppState(); this.state.startupState = 'ready'; return shouldReplayHistory; } @@ -1177,7 +1177,7 @@ export class KimiTUI { // Re-apply startup flags that the user explicitly passed on the command line. // syncRuntimeState and session-replay hydration can both read stale persisted // values, so this guarantees the footer reflects the CLI intent. - private applyStartupPermissionAndPlanToAppState(): void { + private applyStartupPermissionPlanAndSwarmToAppState(): void { const { startup } = this.options; if (startup.auto) { this.setAppState({ permissionMode: 'auto' }); @@ -1187,6 +1187,9 @@ export class KimiTUI { if (startup.plan) { this.setAppState({ planMode: true }); } + if (startup.swarm) { + this.setAppState({ swarmMode: true }); + } } // Plan mode is set by createSession — do not re-enter it here. @@ -1960,7 +1963,7 @@ export class KimiTUI { } if (options.applyStartupModes === true) { await this.applyStartupModesToResumedSession(this.requireSession()); - this.applyStartupPermissionAndPlanToAppState(); + this.applyStartupPermissionPlanAndSwarmToAppState(); } this.hideSessionPicker(); }) diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index b112d16f2..a5cd92ef8 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -251,6 +251,7 @@ describe('KimiTUI startup', () => { workDir: '/tmp/proj-a', permission: 'yolo', planMode: true, + swarmMode: false, }); expect(session.setApprovalHandler).toHaveBeenCalledOnce(); expect(session.setQuestionHandler).toHaveBeenCalledOnce(); @@ -345,6 +346,7 @@ describe('KimiTUI startup', () => { workDir: '/tmp/proj-a', permission: undefined, planMode: undefined, + swarmMode: false, }); expect(driver.state.appState.swarmMode).toBe(false); }); @@ -593,6 +595,27 @@ describe('KimiTUI startup', () => { expect(driver.state.appState.planMode).toBe(true); }); + it('keeps --swarm in the footer after session replay hydration', async () => { + const session = makeSession({ + id: 'ses-latest', + getResumeState: vi.fn(() => createResumeState({ permissionMode: 'manual', planMode: false })), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-latest' }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true, swarm: true })); + + await expect(driver.init()).resolves.toBe(true); + await ( + driver as unknown as { + finishStartup(shouldReplayHistory: boolean): Promise; + } + ).finishStartup(true); + + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'manual'); + expect(driver.state.appState.swarmMode).toBe(true); + }); + it('applies --auto permission when resuming an explicit session', async () => { let permission = 'manual'; const session = makeSession({ @@ -682,6 +705,7 @@ describe('KimiTUI startup', () => { model: 'kimi-code/k2.5', permission: undefined, planMode: undefined, + swarmMode: false, }); }); @@ -995,6 +1019,7 @@ describe('KimiTUI startup', () => { workDir: '/tmp/proj-a', permission: 'yolo', planMode: true, + swarmMode: false, }); expect(createSession).toHaveBeenNthCalledWith(2, { workDir: '/tmp/proj-a', diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 172e97cfc..5c9be6f18 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -470,6 +470,7 @@ export function configToTomlData(config: KimiConfig): Record { 'defaultThinking', 'defaultPermissionMode', 'defaultPlanMode', + 'defaultSwarmMode', 'mergeAllAvailableSkills', 'extraSkillDirs', 'telemetry', diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 091eee384..4c7b0e8fe 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -389,6 +389,21 @@ removed_flag = true expect(text).not.toContain('default_permission_mode'); }); + it('serializes defaultSwarmMode when writing config', async () => { + const dir = makeTempDir(); + const configPath = join(dir, 'config.toml'); + const config = parseConfigString('default_swarm_mode = true\n', configPath); + + expect(config.defaultSwarmMode).toBe(true); + + await writeConfigFile(configPath, config); + + const text = await readFile(configPath, 'utf-8'); + expect(text).toContain('default_swarm_mode = true'); + const roundTripped = parseConfigString(text, configPath); + expect(roundTripped.defaultSwarmMode).toBe(true); + }); + it('rejects invalid TOML and invalid schema with KimiError(config.invalid)', () => { expectKimiErrorCode( () => parseConfigString('[[[', 'broken.toml'),