Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/add-goal-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---

Add persistent goal mode with `/goal` controls and automatic continuation until the agent completes or blocks the objective.
56 changes: 56 additions & 0 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,62 @@ export async function handlePlanCommand(host: SlashCommandHost, args: string): P
await applyPlanMode(host, session, enabled);
}

export async function handleGoalCommand(host: SlashCommandHost, args: string): Promise<void> {
const session = host.session;
if (session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}

const objective = args.trim();
const subcmd = objective.toLowerCase();
if (objective.length === 0) {
const goal = await session.getGoal();
if (goal === null) {
host.showNotice('No active goal');
return;
}
host.showNotice(`Goal ${goal.status}`, goal.objective);
return;
}
if (subcmd === 'pause') {
if ((await session.getGoal()) === null) {
host.showNotice('No active goal');
return;
}
const goal = await session.pauseGoal();
host.showNotice('Goal paused', goal.objective);
return;
}
if (subcmd === 'resume') {
if ((await session.getGoal()) === null) {
host.showNotice('No active goal');
return;
}
const goal = await session.resumeGoal();
host.showNotice('Goal resumed', goal.objective);
return;
}
if (subcmd === 'clear') {
if ((await session.getGoal()) === null) {
host.showNotice('No goal to clear');
return;
}
await session.clearGoal();
host.showNotice('Goal cleared');
return;
}

const existing = await session.getGoal();
if (existing !== null && existing.status !== 'complete') {
host.showError('A goal is already set. Run /goal clear before replacing it.');
return;
}
const goal = await session.setGoal(objective);
host.showNotice('Goal active', goal.objective);
host.sendNormalUserInput(goal.objective);
}

async function applyPlanMode(host: SlashCommandHost, session: Session, enabled: boolean): Promise<void> {
try {
await session.setPlanMode(enabled);
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
handleAutoCommand,
handleCompactCommand,
handleEditorCommand,
handleGoalCommand,
handleModelCommand,
handlePlanCommand,
handleThemeCommand,
Expand Down Expand Up @@ -56,6 +57,7 @@ export {
handleAutoCommand,
handleCompactCommand,
handleEditorCommand,
handleGoalCommand,
handleModelCommand,
handlePlanCommand,
handleThemeCommand,
Expand Down Expand Up @@ -255,6 +257,9 @@ async function handleBuiltInSlashCommand(
case 'plan':
await handlePlanCommand(host, args);
return;
case 'goal':
await handleGoalCommand(host, args);
return;
case 'compact':
await handleCompactCommand(host, args);
return;
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
export {
handleCompactCommand,
handleEditorCommand,
handleGoalCommand,
handleModelCommand,
handlePlanCommand,
handleThemeCommand,
Expand Down
13 changes: 13 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 100,
availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'),
},
{
name: 'goal',
aliases: [],
description: 'Set, pause, resume, view, or clear a task goal',
priority: 100,
availability: (args) => (isGoalManagementCommand(args) ? 'always' : 'idle-only'),
experimentalFlag: 'goal-mode',
},
{
name: 'model',
aliases: [],
Expand Down Expand Up @@ -209,3 +217,8 @@ export function sortSlashCommands(commands: readonly KimiSlashCommand[]): KimiSl
(a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.name.localeCompare(b.name),
);
}

function isGoalManagementCommand(args: string): boolean {
const subcmd = args.trim().toLowerCase();
return subcmd.length === 0 || subcmd === 'pause' || subcmd === 'resume' || subcmd === 'clear';
}
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/components/messages/status-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] {
{ label: 'Plan mode', value: planMode ? 'on' : 'off' },
{ label: 'Session', value: sessionId },
];
const goal = options.status?.goal;
if (goal !== undefined && goal !== null) {
rows.push({ label: 'Goal', value: goal.status });
}
const title = options.sessionTitle?.trim();
if (title !== undefined && title.length > 0) rows.push({ label: 'Title', value: title });
if (options.statusError !== undefined) {
Expand Down
37 changes: 37 additions & 0 deletions apps/kimi-code/test/tui/commands/goal-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { handleGoalCommand, type SlashCommandHost } from '#/tui/commands/index';
import { describe, expect, it, vi } from 'vitest';

function makeHost(overrides: Partial<SlashCommandHost> = {}): SlashCommandHost {
return {
session: {
getGoal: vi.fn().mockResolvedValue(null),
pauseGoal: vi.fn().mockRejectedValue(new Error('pause should not be called')),
resumeGoal: vi.fn().mockRejectedValue(new Error('resume should not be called')),
},
showNotice: vi.fn(),
showError: vi.fn(),
...overrides,
} as unknown as SlashCommandHost;
}

describe('handleGoalCommand', () => {
it('shows a friendly notice for /goal pause without an active goal', async () => {
const host = makeHost();

await handleGoalCommand(host, 'pause');

expect(host.session?.getGoal).toHaveBeenCalledOnce();
expect(host.session?.pauseGoal).not.toHaveBeenCalled();
expect(host.showNotice).toHaveBeenCalledWith('No active goal');
});

it('shows a friendly notice for /goal resume without an active goal', async () => {
const host = makeHost();

await handleGoalCommand(host, 'resume');

expect(host.session?.getGoal).toHaveBeenCalledOnce();
expect(host.session?.resumeGoal).not.toHaveBeenCalled();
expect(host.showNotice).toHaveBeenCalledWith('No active goal');
});
});
58 changes: 57 additions & 1 deletion apps/kimi-code/test/tui/commands/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import {
resolveSkillCommand,
resolveSlashCommandInput,
setExperimentalFlags,
slashBusyMessage,
slashCommandBusyReason,
} from '#/tui/commands/index';
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

beforeEach(() => {
setExperimentalFlags({ 'goal-mode': true });
});

afterEach(() => {
setExperimentalFlags({});
});

function resolve(
input: string,
Expand Down Expand Up @@ -35,6 +44,11 @@ describe('resolveSlashCommandInput', () => {
args: 'New title',
});
expect(resolve('/init')).toMatchObject({ kind: 'builtin', name: 'init', args: '' });
expect(resolve('/goal Finish migration')).toMatchObject({
kind: 'builtin',
name: 'goal',
args: 'Finish migration',
});
});

it('blocks idle-only built-ins while streaming', () => {
Expand Down Expand Up @@ -99,6 +113,26 @@ describe('resolveSlashCommandInput', () => {
name: 'mcp',
args: '',
});
expect(resolve('/goal', { isStreaming: true })).toMatchObject({
kind: 'builtin',
name: 'goal',
args: '',
});
expect(resolve('/goal pause', { isStreaming: true })).toMatchObject({
kind: 'builtin',
name: 'goal',
args: 'pause',
});
expect(resolve('/goal resume', { isStreaming: true })).toMatchObject({
kind: 'builtin',
name: 'goal',
args: 'resume',
});
expect(resolve('/goal clear', { isStreaming: true })).toMatchObject({
kind: 'builtin',
name: 'goal',
args: 'clear',
});
});

it('blocks plan clear while compacting because it is idle-only', () => {
Expand All @@ -109,6 +143,19 @@ describe('resolveSlashCommandInput', () => {
});
});

it('blocks goal creation while busy', () => {
expect(resolve('/goal Finish migration', { isStreaming: true })).toEqual({
kind: 'blocked',
commandName: 'goal',
reason: 'streaming',
});
expect(resolve('/goal Finish migration', { isCompacting: true })).toEqual({
kind: 'blocked',
commandName: 'goal',
reason: 'compacting',
});
});

it('resolves skill commands and blocks them while busy', () => {
const skillCommandMap = new Map([['skill:review', 'review']]);

Expand All @@ -132,6 +179,15 @@ describe('resolveSlashCommandInput', () => {
});
});

it('hides experimental commands while their flag is disabled', () => {
setExperimentalFlags({});

expect(resolve('/goal Finish migration')).toEqual({
kind: 'message',
input: '/goal Finish migration',
});
});

});

describe('slash command busy helpers', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ describe('status panel report lines', () => {
thinkingLevel: 'high',
permission: 'auto',
planMode: true,
goal: {
objective: 'Finish migration',
status: 'active',
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
},
contextTokens: 3000,
maxContextTokens: 12000,
contextUsage: 0.25,
Expand All @@ -58,6 +66,7 @@ describe('status panel report lines', () => {
expect(output).toContain('Directory /tmp/project');
expect(output).toContain('Permissions auto');
expect(output).toContain('Plan mode on');
expect(output).toContain('Goal active');
expect(output).toContain('Session ses-1');
expect(output).toContain('Title Implement status');
expect(output).toContain('Context window');
Expand Down
6 changes: 6 additions & 0 deletions apps/vis/web/src/components/wire/typeMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export const TYPE_TONE: Record<RecordType, PillTone> = {
'plan_mode.enter': 'lifecycle',
'plan_mode.cancel': 'warning',
'plan_mode.exit': 'success',
'goal.set': 'lifecycle',
'goal.status': 'meta',
'goal.clear': 'warning',
'background.stop': 'warning',
};

Expand Down Expand Up @@ -54,5 +57,8 @@ export const TYPE_LABEL: Record<RecordType, string> = {
'plan_mode.enter': 'plan↻',
'plan_mode.cancel': 'plan×',
'plan_mode.exit': 'plan✓',
'goal.set': 'goal+',
'goal.status': 'goal',
'goal.clear': 'goal×',
'background.stop': 'bg-stop',
};
9 changes: 9 additions & 0 deletions docs/en/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ Some commands are only available in the idle state. Running them while the sessi
| `/auto [on\|off]` | — | Toggle auto permission mode. Without arguments, flip the current state; pass `on`/`off` explicitly to force the corresponding state. When enabled, tool approvals are handled automatically and the agent will not ask questions. | Yes |
| `/plan [on\|off]` | — | Toggle Plan mode. Without arguments, flip the current state; pass `on`/`off` explicitly to force the corresponding state. Toggling alone does not create an empty plan file. | Yes |
| `/plan clear` | — | Clear the current plan. | No |
| `/goal` | — | Show the current goal. | Yes |
| `/goal <objective>` | — | Set a persistent goal and start working toward it. | No |
| `/goal pause` | — | Pause automatic goal continuation. | Yes |
| `/goal resume` | — | Resume automatic goal continuation. | Yes |
| `/goal clear` | — | Clear the current goal. | Yes |

While a goal is active, the agent continues working after each model stop until it marks the objective complete or blocked. Goal state is preserved when the session is resumed.

Goal mode is experimental and disabled by default. Enable it with `KIMI_CODE_EXPERIMENTAL_GOAL_MODE=1`.

::: warning Note
`/yolo` skips approval confirmation for ordinary tool calls. Make sure you understand the potential risks before enabling it. It does not skip the approval required to leave Plan mode; in Plan mode, `Bash` follows the same ordinary allow rules as `/yolo`.
Expand Down
5 changes: 5 additions & 0 deletions docs/en/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,14 @@ Plan mode is a constrained working state: once entered, `Write` and `Edit` are t
| Tool | Default approval | Description |
| --- | --- | --- |
| `TodoList` | Auto-approved | Manage the task to-do list |
| `get_goal` | Auto-approved | Read the current persistent goal |
| `create_goal` | Auto-approved | Create a persistent goal |
| `update_goal` | Auto-approved | Mark the current goal complete or blocked |

**`TodoList`** maintains a visible subtask list across multi-step operations; state is stored within the agent session. The `todos` parameter accepts an array where each item has a `title` and a `status` (`pending` / `in_progress` / `done`). Omitting `todos` queries the current list; passing an empty array clears it.

**`get_goal`** returns the current goal and its status, token usage, elapsed active time, and optional token budget. **`create_goal`** accepts an `objective` and optional positive integer `token_budget`; it is reserved for explicitly requested goal mode and rejects creation while a goal already exists. **`update_goal`** accepts `complete` only after the objective is fully achieved. It accepts `blocked` only when the same blocker repeats for at least three consecutive goal turns and meaningful progress requires user input or an external-state change.

## Collaboration tools

Collaboration tools handle inter-agent coordination, user interaction, and skill invocation.
Expand Down
9 changes: 9 additions & 0 deletions docs/zh/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@
| `/auto [on\|off]` | — | 切换 auto 权限模式。不带参数时按当前状态翻转;显式传 `on`/`off` 时强制设为对应状态。开启后工具审批自动处理,Agent 不会向用户提问。 | 是 |
| `/plan [on\|off]` | — | 切换 Plan 模式。不带参数时按当前状态翻转;显式传 `on`/`off` 时强制设为对应状态。单纯切换不会创建空计划文件。 | 是 |
| `/plan clear` | — | 清除当前 plan 方案。 | 否 |
| `/goal` | — | 显示当前目标。 | 是 |
| `/goal <objective>` | — | 设置持久化 goal,并开始围绕目标执行任务。 | 否 |
| `/goal pause` | — | 暂停 goal 的自动续跑。 | 是 |
| `/goal resume` | — | 恢复 goal 的自动续跑。 | 是 |
| `/goal clear` | — | 清除当前 goal。 | 是 |

goal 处于 active 状态时,Agent 会在每次模型停止后继续执行,直到将目标标记为完成或阻塞。恢复会话时会保留 goal 状态。

goal 模式是实验性功能,默认关闭。使用 `KIMI_CODE_EXPERIMENTAL_GOAL_MODE=1` 启用。

::: warning 注意
`/yolo` 会跳过普通工具调用的审批确认,使用前请确保了解可能的风险。Plan 模式的退出审批不会被 `/yolo` 跳过;Plan 模式下的 `Bash` 也按 `/yolo` 的普通放行规则处理。
Expand Down
5 changes: 5 additions & 0 deletions docs/zh/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,14 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 工
| 工具 | 默认审批 | 说明 |
| --- | --- | --- |
| `TodoList` | 自动放行 | 管理任务待办列表 |
| `get_goal` | 自动放行 | 读取当前持久化 goal |
| `create_goal` | 自动放行 | 创建持久化 goal |
| `update_goal` | 自动放行 | 将当前 goal 标记为完成或阻塞 |

**`TodoList`** 用于在多步骤操作中维护一份可见的子任务列表,状态存储在 Agent 会话内。`todos` 参数接受一个数组,每项含 `title`(标题)和 `status`(`pending` / `in_progress` / `done`);省略 `todos` 则仅查询当前列表,传入空数组则清空列表。

**`get_goal`** 返回当前 goal 及其状态、token 用量、活跃耗时和可选 token 预算。**`create_goal`** 接受 `objective` 和可选的正整数 `token_budget`;它只应用于用户明确请求的 goal 模式,并会在已有 goal 时拒绝创建。**`update_goal`** 仅在目标完全完成后接受 `complete`。仅当同一阻塞条件连续出现至少三个 goal turn,且继续取得实质进展需要用户输入或外部状态变化时,才接受 `blocked`。

## 协作类

协作类工具负责 Agent 间协作、用户交互和 Skill 调用。
Expand Down
Loading