diff --git a/.changeset/drop-forked-goals.md b/.changeset/drop-forked-goals.md new file mode 100644 index 0000000000..8ba094962c --- /dev/null +++ b/.changeset/drop-forked-goals.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Stop carrying active and queued goals into forked sessions. diff --git a/.changeset/upcoming-goal-queue.md b/.changeset/upcoming-goal-queue.md new file mode 100644 index 0000000000..23162c0adc --- /dev/null +++ b/.changeset/upcoming-goal-queue.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Users now can prepare several goals for the agent to work on sequentially. The agent will pick up the next goal from the queue once the current goal is completed. Use `/goal next ` to queue a goal and `/goal next manage` to review and change the queue interactively. diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index ff5c770f6e..9f59204b1f 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -4,17 +4,51 @@ import { GoalStartPermissionPromptComponent, type GoalStartPermissionChoice, } from '../components/dialogs/goal-start-permission-prompt'; +import { + GoalQueueEditDialogComponent, + GoalQueueManagerComponent, + type GoalQueueEditResult, + type GoalQueueManagerAction, +} from '../components/dialogs/goal-queue-manager'; import { GoalSetMessageComponent, GoalStatusMessageComponent, } from '../components/messages/goal-panel'; import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; +import { + appendGoalQueueItem, + moveGoalQueueItem, + readGoalQueue, + removeGoalQueueItem, + updateGoalQueueItem, + type GoalQueueSnapshot, +} from '../goal-queue-store'; import { formatErrorMessage } from '../utils/event-payload'; import type { SlashCommandHost } from './dispatch'; const MAX_GOAL_OBJECTIVE_LENGTH = 4000; const RESUME_GOAL_INPUT = 'Resume the active goal.'; +type GoalCommandHost = Pick< + SlashCommandHost, + | 'state' + | 'session' + | 'requireSession' + | 'setAppState' + | 'showError' + | 'showStatus' + | 'track' + | 'mountEditorReplacement' + | 'restoreEditor' + | 'restoreInputText' + | 'sendNormalUserInput' +>; + +export interface GoalStartOptions { + readonly beforeSend?: () => boolean | Promise; + readonly sendInput?: (objective: string) => void; +} + export type ParsedGoalCommand = | { readonly kind: 'status' } | { readonly kind: 'pause' } @@ -25,6 +59,8 @@ export type ParsedGoalCommand = readonly objective: string; readonly replace: boolean; } + | { readonly kind: 'next-add'; readonly objective: string } + | { readonly kind: 'next-manage' } | { readonly kind: 'error'; readonly message: string; readonly severity?: 'error' | 'hint' }; const CONTROL_SUBCOMMANDS = new Set(['pause', 'resume', 'cancel']); @@ -44,6 +80,9 @@ export function parseGoalCommand(rawArgs: string): ParsedGoalCommand { const tokens = args.split(/\s+/); const first = tokens[0]; + if (first === 'next') { + return parseNextGoalCommand(tokens); + } if (first !== undefined && CONTROL_SUBCOMMANDS.has(first) && tokens.length === 1) { return { kind: first as 'pause' | 'resume' | 'cancel' }; } @@ -98,35 +137,186 @@ export async function handleGoalCommand(host: SlashCommandHost, args: string): P case 'cancel': await cancelGoal(host); return; + case 'next-add': + await queueNextGoal(host, parsed); + return; + case 'next-manage': + await showGoalQueueManager(host); + return; case 'create': await createGoal(host, parsed, args); return; } } -async function createGoal( +function parseNextGoalCommand(tokens: readonly string[]): ParsedGoalCommand { + if (tokens.length === 2 && tokens[1] === 'manage') return { kind: 'next-manage' }; + let index = 1; + if (tokens[index] === '--') index += 1; + const objective = tokens.slice(index).join(' ').trim(); + if (objective.length === 0) { + return { + kind: 'error', + severity: 'hint', + message: + 'Provide an upcoming goal objective, e.g. `/goal next Ship feature X`, or use `/goal next manage`.', + }; + } + if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { + return { + kind: 'error', + message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Reference long details by file path.`, + }; + } + return { kind: 'next-add', objective }; +} + +async function queueNextGoal( + host: SlashCommandHost, + parsed: Extract, +): Promise { + try { + await appendGoalQueueItem(host.requireSession(), { objective: parsed.objective }); + } catch (error) { + host.showError(formatErrorMessage(error)); + return; + } + host.track('goal_queue_append'); + host.showStatus('Upcoming goal added. It will start after the current goal is complete.'); +} + +async function showGoalQueueManager( host: SlashCommandHost, + selectedGoalId?: string, +): Promise { + let snapshot: GoalQueueSnapshot; + try { + snapshot = await readGoalQueue(host.requireSession()); + } catch (error) { + host.showError(`Failed to load upcoming goals: ${formatErrorMessage(error)}`); + return; + } + + host.track('goal_queue_manage'); + host.mountEditorReplacement( + new GoalQueueManagerComponent({ + goals: snapshot.goals, + selectedGoalId, + colors: host.state.theme.colors, + onAction: async (action) => { + try { + return await handleGoalQueueManagerAction(host, action); + } catch (error) { + host.showError(`Failed to update upcoming goals: ${formatErrorMessage(error)}`); + return undefined; + } + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function handleGoalQueueManagerAction( + host: SlashCommandHost, + action: GoalQueueManagerAction, +): Promise { + const session = host.requireSession(); + switch (action.kind) { + case 'move': { + const snapshot = await moveGoalQueueItem(session, { + goalId: action.goalId, + direction: action.direction, + }); + host.track('goal_queue_move', { direction: action.direction }); + return snapshot; + } + case 'delete': { + const snapshot = await removeGoalQueueItem(session, { goalId: action.goalId }); + host.track('goal_queue_remove'); + return snapshot; + } + case 'edit': + await showGoalQueueEditDialog(host, action.goalId); + return; + } +} + +async function showGoalQueueEditDialog( + host: SlashCommandHost, + goalId: string, +): Promise { + let snapshot: GoalQueueSnapshot; + try { + snapshot = await readGoalQueue(host.requireSession()); + } catch (error) { + host.showError(`Failed to load upcoming goals: ${formatErrorMessage(error)}`); + return; + } + + const goal = snapshot.goals.find((item) => item.id === goalId); + if (goal === undefined) { + host.showStatus('Queued goal no longer exists.'); + await showGoalQueueManager(host); + return; + } + + host.mountEditorReplacement( + new GoalQueueEditDialogComponent({ + goal, + colors: host.state.theme.colors, + onDone: (result) => { + void handleGoalQueueEditResult(host, result).catch((error: unknown) => { + host.showError(`Failed to update upcoming goal: ${formatErrorMessage(error)}`); + }); + }, + }), + ); +} + +async function handleGoalQueueEditResult( + host: SlashCommandHost, + result: GoalQueueEditResult, +): Promise { + if (result.kind === 'cancel') { + await showGoalQueueManager(host, result.goalId); + return; + } + + await updateGoalQueueItem(host.requireSession(), { + goalId: result.goalId, + objective: result.objective, + }); + host.track('goal_queue_update'); + await showGoalQueueManager(host, result.goalId); +} + +export async function createGoal( + host: GoalCommandHost, parsed: Extract, rawArgs?: string, -): Promise { + options: GoalStartOptions = {}, +): Promise { // A goal must be able to start a model turn; refuse to create one otherwise. if (host.state.appState.model.trim().length === 0 || host.session === undefined) { host.showError(LLM_NOT_SET_MESSAGE); - return; + return false; } if (host.state.appState.permissionMode === 'manual') { - showGoalStartPermissionPrompt(host, parsed, rawArgs ?? parsed.objective); - return; + showGoalStartPermissionPrompt(host, parsed, rawArgs ?? parsed.objective, options); + return false; } - await startGoal(host, parsed); + return startGoal(host, parsed, options); } function showGoalStartPermissionPrompt( - host: SlashCommandHost, + host: GoalCommandHost, parsed: Extract, rawArgs: string, + options: GoalStartOptions, ): void { const commandText = `/goal ${rawArgs.trim()}`; const cancelStart = (): void => { @@ -142,7 +332,7 @@ function showGoalStartPermissionPrompt( return; } host.restoreEditor(); - void startGoalWithPermission(host, parsed, choice); + void startGoalWithPermission(host, parsed, choice, options); }, onCancel: cancelStart, }), @@ -150,17 +340,18 @@ function showGoalStartPermissionPrompt( } async function startGoalWithPermission( - host: SlashCommandHost, + host: GoalCommandHost, parsed: Extract, choice: GoalStartPermissionChoice, + options: GoalStartOptions, ): Promise { if (choice === 'auto' || choice === 'yolo') { if (!(await setPermissionForGoal(host, choice))) return; } - await startGoal(host, parsed); + await startGoal(host, parsed, options); } -async function setPermissionForGoal(host: SlashCommandHost, mode: PermissionMode): Promise { +async function setPermissionForGoal(host: GoalCommandHost, mode: PermissionMode): Promise { try { await host.requireSession().setPermission(mode); } catch (error) { @@ -172,9 +363,10 @@ async function setPermissionForGoal(host: SlashCommandHost, mode: PermissionMode } async function startGoal( - host: SlashCommandHost, + host: GoalCommandHost, parsed: Extract, -): Promise { + options: GoalStartOptions, +): Promise { try { await host.requireSession().createGoal({ objective: parsed.objective, @@ -185,15 +377,20 @@ async function startGoal( host.showError( 'A goal is already active. Use `/goal replace ` to replace it, or `/goal status` to inspect it.', ); - return; + return false; } host.showError(formatErrorMessage(error)); - return; + return false; + } + if (options.beforeSend !== undefined && !(await options.beforeSend())) { + return false; } host.track('goal_create', { replace: parsed.replace }); host.state.transcriptContainer.addChild(new GoalSetMessageComponent(host.state.theme.colors)); host.state.ui.requestRender(); - host.sendNormalUserInput(parsed.objective); + const sendInput = options.sendInput ?? host.sendNormalUserInput; + sendInput(parsed.objective); + return true; } async function pauseGoal(host: SlashCommandHost): Promise { diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index ef0abf5e48..78c2bfa1fd 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -10,10 +10,24 @@ const GOAL_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ { value: 'resume', description: 'Resume a paused goal' }, { value: 'cancel', description: 'Cancel and remove the current goal' }, { value: 'replace', description: 'Replace the current goal with a new objective' }, + { value: 'next', description: 'Queue an upcoming goal' }, +]; + +const GOAL_NEXT_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'manage', description: 'Manage upcoming goals' }, ]; /** Argument autocompletion for the `/goal` command (subcommands). */ export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i); + if (nextMatch !== null) { + return ( + completeLeadingArg(GOAL_NEXT_ARG_COMPLETIONS, nextMatch[1] ?? '')?.map((item) => ({ + ...item, + value: `next ${item.value}`, + })) ?? null + ); + } return completeLeadingArg(GOAL_ARG_COMPLETIONS, argumentPrefix); } @@ -156,6 +170,7 @@ export const BUILTIN_SLASH_COMMANDS = [ // resume start (or restart) a turn and so are idle-only. availability: (args) => { const trimmed = args.trim(); + if (trimmed === 'next' || trimmed.startsWith('next ')) return 'always'; return trimmed === '' || trimmed === 'status' || trimmed === 'pause' || trimmed === 'cancel' ? 'always' : 'idle-only'; diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts new file mode 100644 index 0000000000..1c37ca2074 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts @@ -0,0 +1,290 @@ +import { + Container, + Input, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import type { + GoalQueueMoveDirection, + GoalQueueSnapshot, + UpcomingGoal, +} from '#/tui/goal-queue-store'; +import type { ColorPalette } from '#/tui/theme/colors'; +import { printableChar } from '#/tui/utils/printable-key'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +const MAX_GOAL_OBJECTIVE_LENGTH = 4000; +const ELLIPSIS = '…'; +const END_KEY = '\u001B[F'; + +export type GoalQueueManagerAction = + | { + readonly kind: 'move'; + readonly goalId: string; + readonly direction: GoalQueueMoveDirection; + } + | { readonly kind: 'edit'; readonly goalId: string } + | { readonly kind: 'delete'; readonly goalId: string }; + +export interface GoalQueueManagerOptions { + readonly goals: readonly UpcomingGoal[]; + readonly selectedGoalId?: string; + readonly colors: ColorPalette; + readonly pageSize?: number; + readonly onAction: ( + action: GoalQueueManagerAction, + ) => GoalQueueSnapshot | void | Promise; + readonly onCancel: () => void; +} + +export type GoalQueueEditResult = + | { readonly kind: 'save'; readonly goalId: string; readonly objective: string } + | { readonly kind: 'cancel'; readonly goalId: string }; + +export interface GoalQueueEditDialogOptions { + readonly goal: UpcomingGoal; + readonly colors: ColorPalette; + readonly onDone: (result: GoalQueueEditResult) => void; +} + +export class GoalQueueManagerComponent extends Container implements Focusable { + focused = false; + + private readonly opts: GoalQueueManagerOptions; + private goals: readonly UpcomingGoal[]; + private list: SearchableList; + private movingGoalId: string | undefined; + private busy = false; + + constructor(opts: GoalQueueManagerOptions) { + super(); + this.opts = opts; + this.goals = opts.goals; + this.list = this.createList(opts.selectedGoalId); + } + + handleInput(data: string): void { + if (this.busy) return; + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + + const selected = this.selectedGoal(); + const decoded = printableChar(data); + if (matchesKey(data, Key.space) || decoded === ' ') { + this.movingGoalId = this.movingGoalId === selected?.id ? undefined : selected?.id; + return; + } + + if ((decoded === 'e' || decoded === 'E') && selected !== undefined) { + void this.opts.onAction({ kind: 'edit', goalId: selected.id }); + return; + } + + if ((decoded === 'd' || decoded === 'D') && selected !== undefined) { + void this.applyQueueAction({ kind: 'delete', goalId: selected.id }); + return; + } + + if (this.movingGoalId !== undefined) { + if (matchesKey(data, Key.up)) { + void this.applyQueueAction({ kind: 'move', goalId: this.movingGoalId, direction: 'up' }); + return; + } + if (matchesKey(data, Key.down)) { + void this.applyQueueAction({ kind: 'move', goalId: this.movingGoalId, direction: 'down' }); + return; + } + } + + if (this.list.handleKey(data)) return; + } + + override render(width: number): string[] { + const { colors } = this.opts; + const view = this.list.view(); + const hint = this.movingGoalId === undefined + ? '↑↓ navigate · Space select · E edit · D delete · Esc cancel' + : '↑↓ reorder · Space done · E edit · D delete · Esc cancel'; + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Upcoming goals'), + chalk.hex(colors.textMuted)(` ${hint}`), + '', + ]; + + if (this.goals.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' No upcoming goals.')); + } else { + for (let i = view.page.start; i < view.page.end; i++) { + const goal = view.items[i]; + if (goal === undefined) continue; + lines.push(this.renderGoal(goal, i, i === view.selectedIndex, width)); + } + + const below = view.items.length - view.page.end; + if (below > 0) { + lines.push(''); + lines.push(chalk.hex(colors.textMuted)(` ▼ ${String(below)} more`)); + } + } + + lines.push(''); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + private renderGoal(goal: UpcomingGoal, index: number, selected: boolean, width: number): string { + const { colors } = this.opts; + const moving = goal.id === this.movingGoalId; + const pointer = selected ? SELECT_POINTER : ' '; + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const labelPrefix = `${String(index + 1)}. `; + const stateLabel = moving ? ' selected' : ''; + const labelWidth = visibleWidth(labelPrefix); + const stateWidth = visibleWidth(stateLabel); + const objectiveWidth = Math.max(1, width - 5 - labelWidth - stateWidth); + const objective = truncateToWidth(goal.objective, objectiveWidth, ELLIPSIS); + const textStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + let line = prefix + textStyle(labelPrefix + objective); + if (moving) line += chalk.hex(colors.success)(stateLabel); + return line; + } + + private selectedGoal(): UpcomingGoal | undefined { + return this.list.selected(); + } + + private async applyQueueAction(action: Exclude) { + this.busy = true; + try { + const result = await this.opts.onAction(action); + if (result !== undefined) { + const selectedGoalId = action.kind === 'delete' ? undefined : action.goalId; + this.goals = result.goals; + if (!this.goals.some((goal) => goal.id === this.movingGoalId)) { + this.movingGoalId = undefined; + } + this.list = this.createList(selectedGoalId ?? this.movingGoalId); + } + } finally { + this.busy = false; + this.invalidate(); + } + } + + private createList(selectedGoalId?: string): SearchableList { + const initialIndex = this.goals.findIndex((goal) => goal.id === selectedGoalId); + return new SearchableList({ + items: this.goals, + toSearchText: (goal) => goal.objective, + pageSize: this.opts.pageSize, + initialIndex: initialIndex === -1 ? 0 : initialIndex, + searchable: false, + }); + } +} + +export class GoalQueueEditDialogComponent extends Container implements Focusable { + focused = false; + + private readonly input = new Input(); + private readonly opts: GoalQueueEditDialogOptions; + private done = false; + private error: string | undefined; + + constructor(opts: GoalQueueEditDialogOptions) { + super(); + this.opts = opts; + this.input.setValue(opts.goal.objective); + this.input.handleInput(END_KEY); + this.input.onSubmit = (value) => { + this.submit(value); + }; + } + + handleInput(data: string): void { + if (this.done) return; + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) + ) { + this.done = true; + this.opts.onDone({ kind: 'cancel', goalId: this.opts.goal.id }); + return; + } + this.error = undefined; + this.input.handleInput(data); + } + + override invalidate(): void { + super.invalidate(); + this.input.invalidate(); + } + + override render(width: number): string[] { + this.input.focused = this.focused && !this.done; + + const safeWidth = Math.max(28, width); + const innerWidth = Math.max(10, safeWidth - 4); + const pad = ' '; + const { colors } = this.opts; + const border = (s: string): string => chalk.hex(colors.primary)(s); + const title = truncateToWidth( + chalk.hex(colors.textStrong).bold('Edit upcoming goal'), + innerWidth, + ELLIPSIS, + ); + const subtitle = truncateToWidth( + chalk.hex(this.error === undefined ? colors.textDim : colors.warning)( + this.error ?? 'Update the queued objective.', + ), + innerWidth, + ELLIPSIS, + ); + const inputLine = this.input.render(innerWidth)[0] ?? '> '; + const footer = truncateToWidth( + chalk.hex(colors.textDim)('Enter submit · Esc cancel'), + innerWidth, + ELLIPSIS, + ); + const contentLines = [title, '', subtitle, '', inputLine, '', footer]; + const lines = [ + '', + border('╭' + '─'.repeat(safeWidth - 2) + '╮'), + border('│') + ' '.repeat(safeWidth - 2) + border('│'), + ]; + + for (const content of contentLines) { + const rightPad = Math.max(0, innerWidth - visibleWidth(content)); + lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│')); + } + + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines; + } + + private submit(value: string): void { + const objective = value.trim(); + if (objective.length === 0) { + this.error = 'Goal objective cannot be empty.'; + return; + } + if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { + this.error = `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters.`; + return; + } + this.opts.onDone({ kind: 'save', goalId: this.opts.goal.id, objective }); + } +} diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 4f0d3efd40..76b46599f0 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import type { Component, Focusable } from '@earendil-works/pi-tui'; import type { AgentStatusUpdatedEvent, AssistantDeltaEvent, @@ -43,10 +44,12 @@ import { } from '../constant/kimi-tui'; import { argsRecord, + formatErrorMessage, isTodoItemShape, serializeToolResultOutput, stringValue, } from '../utils/event-payload'; +import { readGoalQueue, removeGoalQueueItem, restoreGoalQueueItem } from '../goal-queue-store'; import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; import { formatHookResultMarkdown, formatHookResultPlain } from '../utils/hook-result-format'; @@ -74,6 +77,7 @@ import type { TranscriptEntry, } from '../types'; import type { TUIState } from '../tui-state'; +import { createGoal as startGoalCommand } from '../commands/goal'; export interface SessionEventHost { state: TUIState; @@ -89,7 +93,12 @@ export interface SessionEventHost { showError(msg: string): void; showStatus(msg: string, color?: string): void; showNotice(title: string, detail?: string): void; + track(event: string, props?: Record): void; + mountEditorReplacement(panel: Component & Focusable): void; + restoreEditor(): void; + restoreInputText(text: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; + sendNormalUserInput(text: string): void; updateTerminalTitle(): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; shiftQueuedMessage(): QueuedMessage | undefined; @@ -109,6 +118,10 @@ export class SessionEventHandler { renderedMcpServerStatusKeys: Map = new Map(); mcpServerStatusSpinners: Map = new Map(); mcpServers: Map = new Map(); + private goalCompletionAwaitingClear = false; + private goalCompletionTurnEnded = false; + private queuedGoalPromotionPending = false; + private queuedGoalPromotionTimer: ReturnType | undefined; resetRuntimeState(): void { this.backgroundAgentMetadata.clear(); @@ -118,6 +131,10 @@ export class SessionEventHandler { this.renderedSkillActivationIds.clear(); this.renderedMcpServerStatusKeys.clear(); this.mcpServers.clear(); + this.goalCompletionAwaitingClear = false; + this.goalCompletionTurnEnded = false; + this.queuedGoalPromotionPending = false; + this.clearQueuedGoalPromotionTimer(); this.stopAllMcpServerStatusSpinners(); } @@ -359,6 +376,8 @@ export class SessionEventHandler { } this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeTurn(sendQueued); + this.goalCompletionTurnEnded = true; + this.scheduleQueuedGoalPromotion(); } private handleStepBegin(event: TurnStepStartedEvent): void { @@ -561,6 +580,11 @@ export class SessionEventHandler { private handleGoalUpdated(event: GoalUpdatedEvent): void { this.host.setAppState({ goal: event.snapshot }); + if (event.snapshot === null && this.goalCompletionAwaitingClear) { + this.goalCompletionAwaitingClear = false; + this.queuedGoalPromotionPending = true; + this.scheduleQueuedGoalPromotion(); + } const change = event.change; if (change === undefined) return; const { state } = this.host; @@ -570,6 +594,8 @@ export class SessionEventHandler { // The same text is appended to the conversation by the continuation // controller, so it persists and renders identically on resume. if (change.kind === 'completion' && event.snapshot !== null) { + this.goalCompletionAwaitingClear = true; + this.goalCompletionTurnEnded = false; this.host.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'assistant', @@ -582,6 +608,9 @@ export class SessionEventHandler { // Lifecycle change (pause / resume / blocked) -> a low-profile, // ctrl+o-expandable marker. + if (change.kind === 'lifecycle' && change.status === 'blocked') { + void this.notifyQueuedGoalWaitingOnBlocked(); + } const marker = buildGoalMarker(change, state.theme.colors, state.toolOutputExpanded); if (marker !== null) { state.transcriptContainer.addChild(marker); @@ -589,6 +618,102 @@ export class SessionEventHandler { } } + private scheduleQueuedGoalPromotion(): void { + if (!this.queuedGoalPromotionPending || !this.goalCompletionTurnEnded) return; + if (this.queuedGoalPromotionTimer !== undefined) return; + this.queuedGoalPromotionTimer = setTimeout(() => { + this.queuedGoalPromotionTimer = undefined; + if (!this.queuedGoalPromotionPending || !this.goalCompletionTurnEnded) return; + if ( + this.host.state.appState.streamingPhase !== 'idle' || + this.host.state.queuedMessages.length > 0 + ) { + return; + } + this.queuedGoalPromotionPending = false; + this.goalCompletionTurnEnded = false; + void this.promoteNextQueuedGoal(); + }, 0); + } + + private clearQueuedGoalPromotionTimer(): void { + if (this.queuedGoalPromotionTimer === undefined) return; + clearTimeout(this.queuedGoalPromotionTimer); + this.queuedGoalPromotionTimer = undefined; + } + + private async promoteNextQueuedGoal(): Promise { + const { host } = this; + const session = host.session; + if (session === undefined || host.aborted) return; + + let queue; + try { + queue = await readGoalQueue(session); + } catch (error) { + host.showError(`Failed to read upcoming goals: ${formatErrorMessage(error)}`); + return; + } + if (host.session !== session || host.aborted) return; + + const next = queue.goals[0]; + if (next === undefined) return; + + await startGoalCommand( + host, + { kind: 'create', objective: next.objective, replace: false }, + next.objective, + { + beforeSend: async () => { + if (host.session !== session || host.aborted) return false; + try { + await removeGoalQueueItem(session, { goalId: next.id }); + } catch (error) { + host.showError( + `Queued goal started, but could not be removed from the queue: ${formatErrorMessage(error)}`, + ); + return false; + } + if (host.session === session && !host.aborted) return true; + try { + await restoreGoalQueueItem(session, next); + } catch (error) { + host.showError(`Queued goal could not be restored: ${formatErrorMessage(error)}`); + } + try { + await session.cancelGoal(); + } catch (error) { + host.showError(`Queued goal could not be cancelled: ${formatErrorMessage(error)}`); + } + return false; + }, + sendInput: (objective) => { + host.sendQueuedMessage(session, { text: objective }); + }, + }, + ); + } + + private async notifyQueuedGoalWaitingOnBlocked(): Promise { + const { host } = this; + const session = host.session; + if (session === undefined || host.aborted) return; + + let hasQueuedGoal = false; + try { + const queue = await readGoalQueue(session); + hasQueuedGoal = queue.goals.length > 0; + } catch { + return; + } + if (!hasQueuedGoal || host.session !== session || host.aborted) return; + + host.showNotice( + 'Goal blocked.', + 'The next queued goal will start only after this goal is complete.', + ); + } + private handleSessionMetaChanged(event: SessionMetaUpdatedEvent): void { const title = event.title ?? stringValue(event.patch?.['title']); if (title !== undefined) { diff --git a/apps/kimi-code/src/tui/goal-queue-store.ts b/apps/kimi-code/src/tui/goal-queue-store.ts new file mode 100644 index 0000000000..3848632e65 --- /dev/null +++ b/apps/kimi-code/src/tui/goal-queue-store.ts @@ -0,0 +1,264 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { + ErrorCodes, + KimiError, +} from '@moonshot-ai/kimi-code-sdk'; + +const GOAL_QUEUE_FILE = 'upcoming-goals.json'; +const GOAL_QUEUE_VERSION = 1; +const MAX_GOAL_OBJECTIVE_LENGTH = 4000; + +export interface UpcomingGoal { + readonly id: string; + readonly objective: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface GoalQueueSnapshot { + readonly goals: readonly UpcomingGoal[]; +} + +export type GoalQueueMoveDirection = 'up' | 'down'; + +interface GoalQueueFile { + readonly version: typeof GOAL_QUEUE_VERSION; + readonly goals: readonly UpcomingGoal[]; +} + +interface GoalQueueSession { + readonly id: string; + readonly summary?: { + readonly sessionDir?: string; + }; +} + +const queueMutationLocks = new Map>(); + +export async function readGoalQueue(session: GoalQueueSession): Promise { + const state = await readQueueFile(session); + return toSnapshot(state); +} + +export async function appendGoalQueueItem( + session: GoalQueueSession, + input: { readonly objective: string }, +): Promise { + const objective = normalizeObjective(input.objective); + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + const now = new Date().toISOString(); + const goal: UpcomingGoal = { + id: randomUUID(), + objective, + createdAt: now, + updatedAt: now, + }; + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals: [...state.goals, goal] }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +export async function updateGoalQueueItem( + session: GoalQueueSession, + input: { readonly goalId: string; readonly objective: string }, +): Promise { + const objective = normalizeObjective(input.objective); + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + const index = findGoalIndex(state, input.goalId); + const current = state.goals[index]!; + const updatedAt = timestampAfter(current.updatedAt); + const goals = state.goals.map((goal, goalIndex) => + goalIndex === index ? { ...goal, objective, updatedAt } : goal, + ); + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +export async function removeGoalQueueItem( + session: GoalQueueSession, + input: { readonly goalId: string }, +): Promise { + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + const index = findGoalIndex(state, input.goalId); + const goals = state.goals.filter((_, goalIndex) => goalIndex !== index); + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +export async function restoreGoalQueueItem( + session: GoalQueueSession, + goal: UpcomingGoal, +): Promise { + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + if (state.goals.some((item) => item.id === goal.id)) { + return toSnapshot(state); + } + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals: [goal, ...state.goals] }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +export async function moveGoalQueueItem( + session: GoalQueueSession, + input: { readonly goalId: string; readonly direction: GoalQueueMoveDirection }, +): Promise { + return withQueueMutationLock(session, async () => { + const state = await readQueueFile(session); + const index = findGoalIndex(state, input.goalId); + const targetIndex = input.direction === 'up' ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= state.goals.length) { + return toSnapshot(state); + } + const goals = [...state.goals]; + const [goal] = goals.splice(index, 1); + goals.splice(targetIndex, 0, goal!); + const next: GoalQueueFile = { version: GOAL_QUEUE_VERSION, goals }; + await writeQueueFile(session, next); + return toSnapshot(next); + }); +} + +function goalQueuePath(session: GoalQueueSession): string { + const sessionDir = session.summary?.sessionDir; + if (sessionDir === undefined || sessionDir.trim().length === 0) { + throw new Error(`Session ${session.id} does not expose a session directory`); + } + return join(sessionDir, GOAL_QUEUE_FILE); +} + +async function readQueueFile(session: GoalQueueSession): Promise { + const filePath = goalQueuePath(session); + let raw: string; + try { + raw = await readFile(filePath, 'utf-8'); + } catch (error) { + if (isErrno(error, 'ENOENT')) return emptyQueueFile(); + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + const empty = emptyQueueFile(); + await writeQueueFile(session, empty); + return empty; + } + + if (!isGoalQueueFile(parsed)) { + const empty = emptyQueueFile(); + await writeQueueFile(session, empty); + return empty; + } + + return parsed; +} + +async function writeQueueFile(session: GoalQueueSession, file: GoalQueueFile): Promise { + const filePath = goalQueuePath(session); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(file, null, 2)}\n`, 'utf-8'); +} + +async function withQueueMutationLock( + session: GoalQueueSession, + work: () => Promise, +): Promise { + const filePath = goalQueuePath(session); + const previous = queueMutationLocks.get(filePath) ?? Promise.resolve(); + const run = previous.catch(() => undefined).then(work); + const lock = run.then( + () => undefined, + () => undefined, + ); + queueMutationLocks.set(filePath, lock); + try { + return await run; + } finally { + if (queueMutationLocks.get(filePath) === lock) { + queueMutationLocks.delete(filePath); + } + } +} + +function emptyQueueFile(): GoalQueueFile { + return { version: GOAL_QUEUE_VERSION, goals: [] }; +} + +function toSnapshot(file: GoalQueueFile): GoalQueueSnapshot { + return { goals: file.goals }; +} + +function normalizeObjective(value: string): string { + const objective = value.trim(); + if (objective.length === 0) { + throw new KimiError(ErrorCodes.GOAL_OBJECTIVE_EMPTY, 'Goal objective cannot be empty'); + } + if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { + throw new KimiError( + ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, + `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters`, + ); + } + return objective; +} + +function findGoalIndex(file: GoalQueueFile, goalId: string): number { + const index = file.goals.findIndex((goal) => goal.id === goalId); + if (index === -1) { + throw new KimiError(ErrorCodes.GOAL_NOT_FOUND, 'No queued goal found'); + } + return index; +} + +function isGoalQueueFile(value: unknown): value is GoalQueueFile { + if (!isRecord(value)) return false; + return ( + value['version'] === GOAL_QUEUE_VERSION && + Array.isArray(value['goals']) && + value['goals'].every(isUpcomingGoal) + ); +} + +function isUpcomingGoal(value: unknown): value is UpcomingGoal { + if (!isRecord(value)) return false; + return ( + isNonEmptyString(value['id']) && + isNonEmptyString(value['objective']) && + isNonEmptyString(value['createdAt']) && + isNonEmptyString(value['updatedAt']) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function timestampAfter(previous: string): string { + const now = new Date(); + const previousMs = Date.parse(previous); + if (Number.isFinite(previousMs) && now.getTime() <= previousMs) { + return new Date(previousMs + 1).toISOString(); + } + return now.toISOString(); +} + +function isErrno(error: unknown, code: string): boolean { + return isRecord(error) && error['code'] === code; +} diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index c285a352fd..e4d893384d 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -8,11 +8,46 @@ import { parseGoalCommand, setExperimentalFeatures, } from '#/tui/commands/index'; +import { + appendGoalQueueItem, + moveGoalQueueItem, + readGoalQueue, + removeGoalQueueItem, + updateGoalQueueItem, +} from '#/tui/goal-queue-store'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; import { getColorPalette } from '#/tui/theme/colors'; +vi.mock('#/tui/goal-queue-store', () => ({ + appendGoalQueueItem: vi.fn(async () => ({ + goals: [{ id: 'q1', objective: 'obj', createdAt: '', updatedAt: '' }], + })), + readGoalQueue: vi.fn(async () => ({ + goals: [ + { id: 'q1', objective: 'First queued goal', createdAt: '', updatedAt: '' }, + { id: 'q2', objective: 'Second queued goal', createdAt: '', updatedAt: '' }, + ], + })), + moveGoalQueueItem: vi.fn(async () => ({ + goals: [ + { id: 'q2', objective: 'Second queued goal', createdAt: '', updatedAt: '' }, + { id: 'q1', objective: 'First queued goal', createdAt: '', updatedAt: '' }, + ], + })), + removeGoalQueueItem: vi.fn(async () => ({ + goals: [{ id: 'q2', objective: 'Second queued goal', createdAt: '', updatedAt: '' }], + })), + updateGoalQueueItem: vi.fn(async () => ({ + goals: [ + { id: 'q1', objective: 'First queued goal updated', createdAt: '', updatedAt: '' }, + { id: 'q2', objective: 'Second queued goal', createdAt: '', updatedAt: '' }, + ], + })), +})); + const ENTER = '\r'; const ESCAPE = '\u001B'; +const UP = '\u001B[A'; const DOWN = '\u001B[B'; function fakeSnapshot() { @@ -104,6 +139,11 @@ function mountedPicker(host: SlashCommandHost): TestPicker { return mock.mock.calls[0]?.[0] as TestPicker; } +function latestMountedPicker(host: SlashCommandHost): TestPicker { + const mock = host.mountEditorReplacement as ReturnType; + return mock.mock.calls.at(-1)?.[0] as TestPicker; +} + describe('parseGoalCommand', () => { it('treats empty and status as status', () => { expect(parseGoalCommand('')).toEqual({ kind: 'status' }); @@ -153,6 +193,27 @@ describe('parseGoalCommand', () => { }); }); + it('parses next as an upcoming-goal command', () => { + expect(parseGoalCommand('next Ship release notes')).toEqual({ + kind: 'next-add', + objective: 'Ship release notes', + }); + expect(parseGoalCommand('next manage')).toEqual({ kind: 'next-manage' }); + expect(parseGoalCommand('next -- manage release notes')).toEqual({ + kind: 'next-add', + objective: 'manage release notes', + }); + }); + + it('shows a hint for /goal next without an objective', () => { + expect(parseGoalCommand('next')).toEqual({ + kind: 'error', + severity: 'hint', + message: + 'Provide an upcoming goal objective, e.g. `/goal next Ship feature X`, or use `/goal next manage`.', + }); + }); + it('rejects objectives longer than 4000 characters', () => { expect(parseGoalCommand('x'.repeat(4001))).toMatchObject({ kind: 'error' }); }); @@ -166,6 +227,11 @@ describe('handleGoalCommand', () => { const made = makeHost(); host = made.host; session = made.session; + vi.mocked(appendGoalQueueItem).mockClear(); + vi.mocked(readGoalQueue).mockClear(); + vi.mocked(moveGoalQueueItem).mockClear(); + vi.mocked(removeGoalQueueItem).mockClear(); + vi.mocked(updateGoalQueueItem).mockClear(); }); it('/goal calls getGoal and does not send input', async () => { @@ -302,6 +368,87 @@ describe('handleGoalCommand', () => { ); }); + it('/goal next queues an upcoming goal and does not send it to the agent', async () => { + await handleGoalCommand(host, 'next Ship release notes'); + expect(appendGoalQueueItem).toHaveBeenCalledWith(session, { + objective: 'Ship release notes', + }); + expect(host.track).toHaveBeenCalledWith('goal_queue_append'); + expect(host.showStatus).toHaveBeenCalledWith( + 'Upcoming goal added. It will start after the current goal is complete.', + ); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(session.createGoal).not.toHaveBeenCalled(); + }); + + it('/goal next does not require a configured model', async () => { + const { host: noModelHost, session: s } = makeHost({ model: '' }); + await handleGoalCommand(noModelHost, 'next Ship release notes'); + expect(appendGoalQueueItem).toHaveBeenCalledWith(s, { + objective: 'Ship release notes', + }); + expect(noModelHost.showError).not.toHaveBeenCalled(); + }); + + it('/goal next manage opens the upcoming goal manager without sending input', async () => { + await handleGoalCommand(host, 'next manage'); + + expect(readGoalQueue).toHaveBeenCalledWith(session); + expect(host.track).toHaveBeenCalledWith('goal_queue_manage'); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + const text = stripAnsi(mountedPicker(host).render(100).join('\n')); + expect(text).toContain('Upcoming goals'); + expect(text).toContain('First queued goal'); + expect(text).toContain('Second queued goal'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(session.createGoal).not.toHaveBeenCalled(); + }); + + it('/goal next manage reorders goals through the queue store', async () => { + await handleGoalCommand(host, 'next manage'); + const manager = mountedPicker(host); + + manager.handleInput(DOWN); + manager.handleInput(' '); + manager.handleInput(UP); + + await vi.waitFor(() => { + expect(moveGoalQueueItem).toHaveBeenCalledWith(session, { + goalId: 'q2', + direction: 'up', + }); + }); + }); + + it('/goal next manage removes goals through the queue store', async () => { + await handleGoalCommand(host, 'next manage'); + + mountedPicker(host).handleInput('d'); + + await vi.waitFor(() => { + expect(removeGoalQueueItem).toHaveBeenCalledWith(session, { goalId: 'q1' }); + }); + }); + + it('/goal next manage edits goals through the queue store', async () => { + await handleGoalCommand(host, 'next manage'); + + mountedPicker(host).handleInput('e'); + await vi.waitFor(() => { + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(2); + }); + const editDialog = latestMountedPicker(host); + editDialog.handleInput(' updated'); + editDialog.handleInput(ENTER); + + await vi.waitFor(() => { + expect(updateGoalQueueItem).toHaveBeenCalledWith(session, { + goalId: 'q1', + objective: 'First queued goal updated', + }); + }); + }); + it('surfaces duplicate-goal errors with replace guidance', async () => { session.createGoal.mockRejectedValueOnce( new KimiError(ErrorCodes.GOAL_ALREADY_EXISTS, 'exists'), @@ -440,8 +587,13 @@ describe('goalArgumentCompletions', () => { return items === null ? null : items.map((i) => i.value); } + function labels(prefix: string): string[] | null { + const items = goalArgumentCompletions(prefix); + return items === null ? null : items.map((i) => i.label); + } + it('offers every subcommand for an empty prefix', () => { - expect(values('')).toEqual(['status', 'pause', 'resume', 'cancel', 'replace']); + expect(values('')).toEqual(['status', 'pause', 'resume', 'cancel', 'replace', 'next']); }); it('prefix-filters subcommands case-insensitively', () => { @@ -469,9 +621,19 @@ describe('goalArgumentCompletions', () => { it('stops completing once past the first token (space typed)', () => { expect(values('pause ')).toBeNull(); expect(values('replace Ship feature')).toBeNull(); + expect(values('next Ship feature')).toBeNull(); + }); + + it('completes /goal next manage as the second token', () => { + expect(values('next ')).toEqual(['next manage']); + expect(values('next m')).toEqual(['next manage']); + expect(values('next MA')).toEqual(['next manage']); + expect(labels('next m')).toEqual(['manage']); + expect(values('next manage')).toBeNull(); }); it('returns null when nothing matches', () => { expect(values('zzz')).toBeNull(); + expect(values('next ship')).toBeNull(); }); }); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 39ab645c17..3b8f4946e5 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -81,6 +81,9 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(goal!, 'status')).toBe('always'); expect(resolveSlashCommandAvailability(goal!, 'pause')).toBe('always'); expect(resolveSlashCommandAvailability(goal!, 'cancel')).toBe('always'); + expect(resolveSlashCommandAvailability(goal!, 'next')).toBe('always'); + expect(resolveSlashCommandAvailability(goal!, 'next Ship feature Y')).toBe('always'); + expect(resolveSlashCommandAvailability(goal!, 'next manage')).toBe('always'); expect(resolveSlashCommandAvailability(goal!, 'status report')).toBe('idle-only'); expect(resolveSlashCommandAvailability(goal!, 'pause the rollout')).toBe('idle-only'); expect(resolveSlashCommandAvailability(goal!, 'cancel the migration')).toBe('idle-only'); diff --git a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts new file mode 100644 index 0000000000..1e075dc74e --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts @@ -0,0 +1,216 @@ +import { visibleWidth } from '@earendil-works/pi-tui'; +import { describe, expect, it, vi } from 'vitest'; + +import { + GoalQueueEditDialogComponent, + GoalQueueManagerComponent, + type GoalQueueManagerAction, +} from '#/tui/components/dialogs/goal-queue-manager'; +import { darkColors } from '#/tui/theme/colors'; +import type { GoalQueueSnapshot, UpcomingGoal } from '#/tui/goal-queue-store'; + +const ANSI = /\u001B\[[0-9;]*m/g; +const strip = (s: string): string => s.replaceAll(ANSI, ''); +const ESC = String.fromCodePoint(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; + +function goal(id: string, objective: string): UpcomingGoal { + return { + id, + objective, + createdAt: '2026-06-03T00:00:00.000Z', + updatedAt: '2026-06-03T00:00:00.000Z', + }; +} + +function snapshot(goals: readonly UpcomingGoal[]): GoalQueueSnapshot { + return { goals }; +} + +function text(component: GoalQueueManagerComponent | GoalQueueEditDialogComponent, width = 100) { + return component.render(width).map(strip).join('\n'); +} + +describe('GoalQueueManagerComponent', () => { + it('renders the upcoming goals and the management hint', () => { + const manager = new GoalQueueManagerComponent({ + goals: [goal('g1', 'Ship queued goal')], + colors: darkColors, + onAction: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(manager); + expect(out).toContain('Upcoming goals'); + expect(out).toContain('↑↓ navigate · Space select · E edit · D delete · Esc cancel'); + expect(out).toContain('❯ 1. Ship queued goal'); + }); + + it('uses Space to enter move mode and reorders with Up/Down', async () => { + const first = goal('g1', 'First queued goal'); + const second = goal('g2', 'Second queued goal'); + const onAction = vi.fn(async (action: GoalQueueManagerAction) => { + expect(action).toEqual({ kind: 'move', goalId: 'g2', direction: 'up' }); + return snapshot([second, first]); + }); + const manager = new GoalQueueManagerComponent({ + goals: [first, second], + colors: darkColors, + onAction, + onCancel: vi.fn(), + }); + + manager.handleInput(DOWN); + manager.handleInput(' '); + expect(text(manager)).toContain('↑↓ reorder · Space done · E edit · D delete · Esc cancel'); + manager.handleInput(UP); + + await vi.waitFor(() => { + expect(onAction).toHaveBeenCalledOnce(); + }); + const out = text(manager); + expect(out.indexOf('Second queued goal')).toBeLessThan(out.indexOf('First queued goal')); + }); + + it('deletes the selected goal and keeps the list open', async () => { + const first = goal('g1', 'First queued goal'); + const second = goal('g2', 'Second queued goal'); + const onAction = vi.fn(async (action: GoalQueueManagerAction) => { + expect(action).toEqual({ kind: 'delete', goalId: 'g1' }); + return snapshot([second]); + }); + const manager = new GoalQueueManagerComponent({ + goals: [first, second], + colors: darkColors, + onAction, + onCancel: vi.fn(), + }); + + manager.handleInput('d'); + + await vi.waitFor(() => { + expect(onAction).toHaveBeenCalledOnce(); + }); + const out = text(manager); + expect(out).not.toContain('First queued goal'); + expect(out).toContain('1. Second queued goal'); + }); + + it('invalidates after an async queue action updates the list', async () => { + const first = goal('g1', 'First queued goal'); + const second = goal('g2', 'Second queued goal'); + let resolveAction: (value: GoalQueueSnapshot) => void; + const onAction = vi.fn( + () => + new Promise((resolve) => { + resolveAction = resolve; + }), + ); + const manager = new GoalQueueManagerComponent({ + goals: [first, second], + colors: darkColors, + onAction, + onCancel: vi.fn(), + }); + const invalidate = vi.spyOn(manager, 'invalidate'); + + manager.handleInput('d'); + resolveAction!(snapshot([second])); + + await vi.waitFor(() => { + expect(invalidate).toHaveBeenCalled(); + }); + }); + + it('emits an edit action for the selected goal', () => { + const onAction = vi.fn(); + const manager = new GoalQueueManagerComponent({ + goals: [goal('g1', 'First queued goal')], + colors: darkColors, + onAction, + onCancel: vi.fn(), + }); + + manager.handleInput('e'); + + expect(onAction).toHaveBeenCalledWith({ kind: 'edit', goalId: 'g1' }); + }); + + it('cancels with Esc', () => { + const onCancel = vi.fn(); + const manager = new GoalQueueManagerComponent({ + goals: [], + colors: darkColors, + onAction: vi.fn(), + onCancel, + }); + + manager.handleInput(ESC); + + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('never renders a line wider than the terminal', () => { + const manager = new GoalQueueManagerComponent({ + goals: [goal('g1', 'A very long queued goal objective that should be truncated cleanly')], + colors: darkColors, + onAction: vi.fn(), + onCancel: vi.fn(), + }); + + for (const width of [24, 40, 80]) { + for (const line of manager.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); + +describe('GoalQueueEditDialogComponent', () => { + it('submits the edited objective', () => { + const onDone = vi.fn(); + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', 'Ship queued goal'), + colors: darkColors, + onDone, + }); + + dialog.handleInput(' safely'); + dialog.handleInput('\r'); + + expect(onDone).toHaveBeenCalledWith({ + kind: 'save', + goalId: 'g1', + objective: 'Ship queued goal safely', + }); + }); + + it('keeps accepting input after save returns control to the mounted dialog', () => { + const onDone = vi.fn(); + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', 'Ship queued goal'), + colors: darkColors, + onDone, + }); + + dialog.handleInput('\r'); + dialog.handleInput(ESC); + + expect(onDone).toHaveBeenLastCalledWith({ kind: 'cancel', goalId: 'g1' }); + }); + + it('shows an empty objective hint instead of submitting', () => { + const onDone = vi.fn(); + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', ''), + colors: darkColors, + onDone, + }); + + dialog.handleInput('\r'); + + expect(onDone).not.toHaveBeenCalled(); + expect(text(dialog)).toContain('Goal objective cannot be empty.'); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts new file mode 100644 index 0000000000..2b220d2419 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getColorPalette } from '#/tui/theme/colors'; +import { readGoalQueue, removeGoalQueueItem, restoreGoalQueueItem } from '#/tui/goal-queue-store'; + +vi.mock('#/tui/goal-queue-store', () => ({ + readGoalQueue: vi.fn(async () => ({ + goals: [{ id: 'q1', objective: 'Ship queued goal', createdAt: '', updatedAt: '' }], + })), + removeGoalQueueItem: vi.fn(async () => ({ goals: [] })), + restoreGoalQueueItem: vi.fn(async () => ({ + goals: [{ id: 'q1', objective: 'Ship queued goal', createdAt: '', updatedAt: '' }], + })), +})); + +function fakeGoalSnapshot(objective: string, status: 'active' | 'blocked' | 'paused' | 'complete') { + return { + goalId: 'g1', + objective, + status, + createdAt: '', + updatedAt: '', + startedBy: 'user' as const, + updatedBy: status === 'complete' || status === 'blocked' ? 'model' as const : 'user' as const, + turnsUsed: 1, + tokensUsed: 10, + wallClockMs: 100, + budget: { + tokenBudget: null, + turnBudget: 20, + wallClockBudgetMs: null, + remainingTokens: null, + remainingTurns: 19, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, + }; +} + +function makeHost(options: { createGoalRejects?: boolean } = {}) { + const session = { + createGoal: vi.fn(async () => { + if (options.createGoalRejects === true) throw new Error('create failed'); + return fakeGoalSnapshot('Ship queued goal', 'active'); + }), + cancelGoal: vi.fn(async () => fakeGoalSnapshot('Ship queued goal', 'active')), + }; + const host = { + state: { + appState: { + sessionId: 's1', + streamingPhase: 'waiting', + model: 'kimi-model', + permissionMode: 'auto', + }, + queuedMessages: [], + theme: { colors: getColorPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: { + setTurnId: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(), + }, + requireSession: vi.fn(() => session), + setAppState: vi.fn(), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + host.setAppState.mockImplementation((patch: Record) => { + Object.assign(host.state.appState, patch); + }); + host.streamingUI.finalizeTurn.mockImplementation(() => { + host.setAppState({ streamingPhase: 'idle' }); + }); + return { host: host as any, session }; +} + +function sendQueuedViaHost(host: ReturnType['host'], session: unknown) { + return (item: unknown) => { + host.sendQueuedMessage(session as never, item as never); + }; +} + +function completionEvent() { + return { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot: fakeGoalSnapshot('Current goal', 'complete'), + change: { + kind: 'completion', + status: 'complete', + stats: { turnsUsed: 1, tokensUsed: 10, wallClockMs: 100 }, + }, + } as const; +} + +function clearedEvent() { + return { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot: null, + } as const; +} + +function turnEndedEvent() { + return { + type: 'turn.ended', + sessionId: 's1', + agentId: 'main', + turnId: 1, + reason: 'completed', + } as const; +} + +describe('SessionEventHandler goal queue promotion', () => { + beforeEach(() => { + vi.mocked(readGoalQueue).mockClear(); + vi.mocked(removeGoalQueueItem).mockClear(); + vi.mocked(restoreGoalQueueItem).mockClear(); + }); + + it('starts the next queued goal after the completion turn ends', async () => { + const { host, session } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(completionEvent(), vi.fn()); + expect(session.createGoal).not.toHaveBeenCalled(); + handler.handleEvent(clearedEvent(), vi.fn()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + + handler.handleEvent(turnEndedEvent(), sendQueuedViaHost(host, session)); + + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }); + expect(removeGoalQueueItem).toHaveBeenCalledWith(session, { goalId: 'q1' }); + expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { + text: 'Ship queued goal', + }); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.track).toHaveBeenCalledWith('goal_create', { replace: false }); + }); + + it('waits for queued user input to drain before promoting the next queued goal', async () => { + const { host, session } = makeHost(); + host.state.queuedMessages = [{ text: 'queued user turn' }]; + host.setAppState.mockImplementation((patch: Record) => { + Object.assign(host.state.appState, patch); + }); + host.shiftQueuedMessage.mockImplementation(() => host.state.queuedMessages.shift()); + host.streamingUI.finalizeTurn.mockImplementation((sendQueued: (item: unknown) => void) => { + const next = host.shiftQueuedMessage(); + if (next !== undefined) { + host.setAppState({ streamingPhase: 'idle' }); + setTimeout(() => { + sendQueued(next); + }, 0); + return; + } + host.setAppState({ streamingPhase: 'idle' }); + }); + host.sendQueuedMessage.mockImplementation((_session: unknown, item: { text: string }) => { + if (item.text === 'queued user turn') { + host.setAppState({ streamingPhase: 'waiting' }); + } + }); + const handler = new SessionEventHandler(host); + const sendQueued = sendQueuedViaHost(host, session); + + handler.handleEvent(completionEvent(), sendQueued); + handler.handleEvent(clearedEvent(), sendQueued); + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { text: 'queued user turn' }); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.createGoal).not.toHaveBeenCalled(); + + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }); + expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' }); + }); + + it('leaves the queued goal in place when the next goal cannot start', async () => { + const { host, session } = makeHost({ createGoalRejects: true }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(completionEvent(), vi.fn()); + handler.handleEvent(clearedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('create failed')); + }); + expect(removeGoalQueueItem).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + expect(session.createGoal).toHaveBeenCalledOnce(); + }); + + it('does not send the queued objective when removal fails after goal creation', async () => { + vi.mocked(removeGoalQueueItem).mockRejectedValueOnce(new Error('remove failed')); + const { host, session } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(completionEvent(), vi.fn()); + handler.handleEvent(clearedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('could not be removed')); + }); + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + }); + + it('restores the queued goal and cancels the started goal when the session changes before send', async () => { + const { host, session } = makeHost(); + vi.mocked(removeGoalQueueItem).mockImplementationOnce(async () => { + host.session = undefined; + return { goals: [] }; + }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(completionEvent(), vi.fn()); + handler.handleEvent(clearedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), sendQueuedViaHost(host, session)); + + await vi.waitFor(() => { + expect(restoreGoalQueueItem).toHaveBeenCalledWith(session, { + id: 'q1', + objective: 'Ship queued goal', + createdAt: '', + updatedAt: '', + }); + }); + expect(session.cancelGoal).toHaveBeenCalledOnce(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + }); + + it('shows a notice when a blocked goal has queued goals', async () => { + const { host, session } = makeHost(); + const handler = new SessionEventHandler(host); + const event = { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot: fakeGoalSnapshot('Blocked goal', 'blocked'), + change: { kind: 'lifecycle', status: 'blocked', reason: 'waiting for access' }, + } as const; + + handler.handleEvent(event, vi.fn()); + + await vi.waitFor(() => { + expect(host.showNotice).toHaveBeenCalledWith( + 'Goal blocked.', + 'The next queued goal will start only after this goal is complete.', + ); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + }); + + it('does not promote on paused or cancelled updates', async () => { + const { host, session } = makeHost(); + const handler = new SessionEventHandler(host); + const paused = { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot: fakeGoalSnapshot('Paused goal', 'paused'), + change: { kind: 'lifecycle', status: 'paused' }, + } as const; + + handler.handleEvent(paused, vi.fn()); + handler.handleEvent(clearedEvent(), vi.fn()); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/goal-queue-store.test.ts b/apps/kimi-code/test/tui/goal-queue-store.test.ts new file mode 100644 index 0000000000..721d24d18e --- /dev/null +++ b/apps/kimi-code/test/tui/goal-queue-store.test.ts @@ -0,0 +1,162 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ErrorCodes, KimiError } from '@moonshot-ai/kimi-code-sdk'; + +import { + appendGoalQueueItem, + moveGoalQueueItem, + readGoalQueue, + removeGoalQueueItem, + restoreGoalQueueItem, + updateGoalQueueItem, +} from '#/tui/goal-queue-store'; + +const QUEUE_FILE = 'upcoming-goals.json'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'kimi-goal-queue-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +function session(sessionDir = dir) { + return { + id: 'session_test', + summary: { + sessionDir, + }, + }; +} + +async function readQueueFile() { + return JSON.parse(await readFile(join(dir, QUEUE_FILE), 'utf-8')) as unknown; +} + +describe('goal queue store', () => { + it('reads an empty queue when the file is missing', async () => { + await expect(readGoalQueue(session())).resolves.toEqual({ goals: [] }); + }); + + it('appends a trimmed upcoming goal and writes the session file', async () => { + const snapshot = await appendGoalQueueItem(session(), { objective: ' Ship release notes ' }); + + expect(snapshot.goals).toHaveLength(1); + expect(snapshot.goals[0]).toMatchObject({ objective: 'Ship release notes' }); + expect(snapshot.goals[0]?.id).toEqual(expect.any(String)); + expect(snapshot.goals[0]?.createdAt).toEqual(expect.any(String)); + expect(await readQueueFile()).toMatchObject({ + version: 1, + goals: [{ objective: 'Ship release notes' }], + }); + }); + + it('preserves concurrent appends to the same session queue', async () => { + await Promise.all( + Array.from({ length: 10 }, (_, index) => + appendGoalQueueItem(session(), { objective: `Queued goal ${index + 1}` }), + ), + ); + + const snapshot = await readGoalQueue(session()); + + expect(snapshot.goals.map((goal) => goal.objective).toSorted()).toEqual( + Array.from({ length: 10 }, (_, index) => `Queued goal ${index + 1}`).toSorted(), + ); + }); + + it('updates an upcoming goal objective', async () => { + const first = await appendGoalQueueItem(session(), { objective: 'Draft docs' }); + const goal = first.goals[0]!; + + const updated = await updateGoalQueueItem(session(), { + goalId: goal.id, + objective: ' Publish docs ', + }); + + expect(updated.goals).toHaveLength(1); + expect(updated.goals[0]).toMatchObject({ + id: goal.id, + objective: 'Publish docs', + createdAt: goal.createdAt, + }); + expect(updated.goals[0]?.updatedAt).not.toBe(goal.updatedAt); + }); + + it('removes an upcoming goal by id', async () => { + const first = await appendGoalQueueItem(session(), { objective: 'First' }); + const second = await appendGoalQueueItem(session(), { objective: 'Second' }); + + const snapshot = await removeGoalQueueItem(session(), { goalId: first.goals[0]!.id }); + + expect(snapshot.goals).toEqual([second.goals[1]]); + }); + + it('restores a removed upcoming goal at the front without duplicating it', async () => { + const first = await appendGoalQueueItem(session(), { objective: 'First' }); + await appendGoalQueueItem(session(), { objective: 'Second' }); + const removed = first.goals[0]!; + await removeGoalQueueItem(session(), { goalId: removed.id }); + + const restored = await restoreGoalQueueItem(session(), removed); + expect(restored.goals.map((goal) => goal.objective)).toEqual(['First', 'Second']); + + const deduped = await restoreGoalQueueItem(session(), removed); + expect(deduped.goals.map((goal) => goal.objective)).toEqual(['First', 'Second']); + }); + + it('moves an upcoming goal up and down', async () => { + const first = await appendGoalQueueItem(session(), { objective: 'First' }); + await appendGoalQueueItem(session(), { objective: 'Second' }); + const third = await appendGoalQueueItem(session(), { objective: 'Third' }); + + const movedUp = await moveGoalQueueItem(session(), { + goalId: third.goals[2]!.id, + direction: 'up', + }); + expect(movedUp.goals.map((goal) => goal.objective)).toEqual(['First', 'Third', 'Second']); + + const movedDown = await moveGoalQueueItem(session(), { + goalId: first.goals[0]!.id, + direction: 'down', + }); + expect(movedDown.goals.map((goal) => goal.objective)).toEqual(['Third', 'First', 'Second']); + }); + + it('rejects empty and over-long objectives', async () => { + await expect(appendGoalQueueItem(session(), { objective: ' ' })).rejects.toMatchObject({ + code: ErrorCodes.GOAL_OBJECTIVE_EMPTY, + }); + await expect(appendGoalQueueItem(session(), { objective: 'x'.repeat(4001) })).rejects.toMatchObject({ + code: ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, + }); + }); + + it('normalizes malformed queue files to an empty queue', async () => { + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, QUEUE_FILE), JSON.stringify({ version: 1, goals: [{ bad: true }] }), 'utf-8'); + + await expect(readGoalQueue(session())).resolves.toEqual({ goals: [] }); + await expect(readQueueFile()).resolves.toEqual({ version: 1, goals: [] }); + }); + + it('throws when the session summary does not expose a session directory', async () => { + await expect(readGoalQueue({ id: 'missing', summary: undefined })).rejects.toThrow( + 'Session missing does not expose a session directory', + ); + }); + + it('throws a goal-not-found error when the target item is missing', async () => { + await expect(removeGoalQueueItem(session(), { goalId: 'missing' })).rejects.toBeInstanceOf( + KimiError, + ); + await expect(removeGoalQueueItem(session(), { goalId: 'missing' })).rejects.toMatchObject({ + code: ErrorCodes.GOAL_NOT_FOUND, + }); + }); +}); diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 010eedb5d2..dd6c726be2 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -53,6 +53,7 @@ const config = withMermaid(defineConfig({ { text: '从 kimi-cli 迁移', link: '/zh/guides/migration' }, { text: '常见使用案例', link: '/zh/guides/use-cases' }, { text: '交互与输入', link: '/zh/guides/interaction' }, + { text: '使用目标模式', link: '/zh/guides/goals' }, { text: '会话与上下文', link: '/zh/guides/sessions' }, { text: '在 IDE 中使用', link: '/zh/guides/ides' }, ], @@ -129,6 +130,7 @@ const config = withMermaid(defineConfig({ { text: 'Migrating from kimi-cli', link: '/en/guides/migration' }, { text: 'Common Use Cases', link: '/en/guides/use-cases' }, { text: 'Interaction and Input', link: '/en/guides/interaction' }, + { text: 'Using Goals', link: '/en/guides/goals' }, { text: 'Sessions and Context', link: '/en/guides/sessions' }, { text: 'Using in IDEs', link: '/en/guides/ides' }, ], diff --git a/docs/en/configuration/data-locations.md b/docs/en/configuration/data-locations.md index 3577b17852..2f08ca0c18 100644 --- a/docs/en/configuration/data-locations.md +++ b/docs/en/configuration/data-locations.md @@ -71,6 +71,7 @@ Each session's data is stored under `sessions///`, and a Inside each session directory: - **`state.json`**: session metadata including title, `lastPrompt`, creation/update timestamps, and `forkedFrom`. +- **`upcoming-goals.json`**: the TUI-only queue created by `/goal next `. It is not part of the agent conversation until a queued goal is promoted after the current goal completes. - **`agents/main/wire.jsonl`**: the main Agent's complete communication record, used for session resumption and replay. - **`agents/main/plans/`**: plan files written in Plan mode, named by plan id (`.md`). - **`agents/agent-0/` etc.**: sub-Agent instance directories, each containing their own `wire.jsonl`. diff --git a/docs/en/guides/goals.md b/docs/en/guides/goals.md new file mode 100644 index 0000000000..e2839ea4bd --- /dev/null +++ b/docs/en/guides/goals.md @@ -0,0 +1,151 @@ +# Goals + +Goals keep Kimi Code working toward a defined outcome across turns. Use `/goal` when the task has a clear finish line, but the next useful step depends on what the agent learns while it works. + +A normal prompt says what to do next. A goal says what must become true. Kimi Code keeps that objective visible, checks progress against evidence, and continues while the goal is active. + +::: info +`/goal` is still experimental. Start `kimi` with: + +```sh +KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi +``` +::: + +## Start a goal + +Write the objective after `/goal`: + +```sh +/goal Fix bugs listed in the issue tracker. +``` + +Kimi Code saves the objective, sends it as the next user message, and starts goal mode. After each turn, it checks whether the goal is complete, blocked, paused, or still active. + +Goals work best when the objective names the finish line and the evidence that proves it: + +```sh +/goal Fix every bug labeled checkout-regression, add or update tests for each fix, and run the checkout test suite +``` + +Avoid goals that only name a broad direction: + +```sh +/goal Find all bugs in this codebase. +``` + +That goal does not say what counts as success, what to inspect, or when to stop. The agent may block immediately, or keep working far longer than you expected. + +### When to use goals + +1. Use goals for work with a clear finish line and verifiable evidence. + + ```sh + /goal Fix every failing checkout test and run the checkout test suite successfully. + ``` + + Kimi Code can inspect test output, change files, rerun checks, and decide when the goal is complete. + +2. Use goals when the task may need several turns of investigation and repair. + + ```sh + /goal Find why the release build fails, fix the root cause, and verify the build passes. + ``` + + The goal describes the result, so the agent can adapt when the first clue is not the root cause. + +3. Use goals for ordered work that should continue without another prompt. + + ```sh + /goal Update the feature implementation, add docs, run tests, and summarize the changed files. + ``` + + This is useful when you already know the checks or artifacts that must exist before the work is done. + +### When not to use goals + +1. Do not use goals for broad topics or open-ended discussions. + + ::: warning Counterexample + ```sh + /goal Greetings! + ``` + ::: + + Agents will mark the goal as complete immediately for non-goals. + +2. Do not use goals for tasks that are known to be impossible or unresolvable. + + ::: warning Counterexample + ```sh + /goal Prove 1 + 1 = 3. + ``` + ::: + + Agents will mark the goal as blocked if the goal seems impossible or unresolvable. + +3. Do not use goals with ambiguous or complicated objectives. + + ::: warning Counterexample + ```sh + /goal Create a videogame in a single HTML file. + ``` + ::: + + Agents may complete goals, but also may produce unexpected or surprising outcomes after a long time. + +## Manage the lifecycle + +Use the same command surface to inspect or control the current goal: + +| Command | Action | +| --- | --- | +| `/goal` or `/goal status` | Show the current goal and its progress | +| `/goal pause` | Pause the active goal without deleting it | +| `/goal resume` | Resume a paused or blocked goal | +| `/goal cancel` | Remove the current goal | +| `/goal replace ` | Replace the current goal with a new objective | + +A goal can stop in three ways: + +- `complete`: the objective is done, and Kimi Code clears the goal +- `paused`: you paused it, interrupted the turn, or resumed a session that had an active goal +- `blocked`: Kimi Code needs input, cannot complete the goal as stated, reached a budget limit, or hit a runtime failure + +Write stop conditions into the objective. `/goal` does not have a separate stop-limit flag. + +## Queue upcoming goals + +Agents sometimes complete a goal too quickly. Users can be disappointed that they can assign only one goal at a time. Many people already know the upcoming goals they want to pursue. They had to wait for the current goal to complete, opens the TUI, and submit the next goal manually. + +Use `/goal next` when you have more work ready but do not want to interrupt the current goal: + +```sh +/goal next Update the release notes after the tests pass +``` + +Upcoming goals are not visible to the agent while the current goal is running. When the current goal completes, Kimi Code starts the first upcoming goal in the same way as users enter `/goal `. + +Manage upcoming goals interactively: + +```sh +/goal next manage +``` + +In the manager, use / to browse, Space to select a goal for moving, / to reorder it, E to edit, D to delete, and Esc to cancel. + +The feature helps you run sequential goals in a manageable way. If the current goal is paused, canceled, or blocked, Kimi Code does not start the next upcoming goal. When a goal blocks and upcoming goals exist, the TUI reminds you that they wait for completion. + +## Use goal mode carefully + +Goal mode is useful for work that can be checked with files, tests, command output, generated artifacts, or a clear written report. It is less useful for a one-off edit or a question that only needs one answer. + +In `manual` permission mode, goal work may pause for tool call approval. For unattended work, use a permission mode that matches the risk of the repository and the commands the agent may run. + +In non-interactive prompt mode, only goal creation is supported: + +```sh +KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi -p "/goal Fix the failing checkout test" +``` + +Prompt mode exits with code `0` when the goal completes, `3` when it blocks, and `6` when it pauses. `/goal next` and other management commands are TUI controls. diff --git a/docs/en/guides/sessions.md b/docs/en/guides/sessions.md index 22e9e5540c..6f8a059133 100644 --- a/docs/en/guides/sessions.md +++ b/docs/en/guides/sessions.md @@ -85,7 +85,7 @@ To explore a new direction without disrupting the current conversation, use `/fo /fork ``` -The two resulting sessions are completely independent and do not affect each other. You can switch back to the original at any time using `/sessions`. +The two resulting sessions are completely independent and do not affect each other. You can switch back to the original at any time using `/sessions`. A saved `/goal` is not copied to the fork. Start a new goal there if you want autonomous goal work. ## Exporting a session diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 88b188a156..07e20176a4 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -67,22 +67,12 @@ KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi ``` ::: -`/goal` is for tasks you want Kimi Code to work on continuously across automatically continuing turns. Write the goal after the command to start: +`/goal` starts or manages goal mode: a persistent objective that Kimi Code works toward across automatically continuing turns. For usage guidance and examples, see [Goals](../guides/goals.md). ``` /goal Update the checkout docs, run docs build, and stop if still blocked after 20 turns ``` -Kimi Code saves the goal, sends it as the next User message, and keeps running subsequent turns until the goal stops. A goal has three stop states: - -- `complete`: The goal is done — Kimi Code sends a completion message and clears the goal -- `paused`: You paused the goal, interrupted the current turn, or resumed a session that had an active goal -- `blocked`: Kimi Code stopped because it needs input, cannot complete the goal, reached a budget limit, or encountered a runtime failure - -Stop conditions must be written into the goal itself; `/goal` has no separate stop-limit flag. - -Subcommands for managing the current goal: - | Command | Action | Availability | | --- | --- | --- | | `/goal` or `/goal status` | Display the current goal along with its status, elapsed time, turn count, and token count | Always available | @@ -90,14 +80,28 @@ Subcommands for managing the current goal: | `/goal resume` | Resume a paused or blocked goal | Idle only | | `/goal cancel` | Remove the current goal | Always available | | `/goal replace ` | Replace the saved goal with a new objective | Idle only | +| `/goal next ` | Queue an upcoming goal for this session. The agent does not see it until the current goal completes | Always available | +| `/goal next manage` | Open the upcoming-goal manager. Use `↑`/`↓` to browse, `Space` to select a goal for moving, selected `↑`/`↓` to reorder it, `E` to edit, `D` to delete, and `Esc` to cancel | Always available | + +The words `status`, `pause`, `resume`, `cancel`, `replace`, and `next` act as subcommands only when they are the first word after `/goal`. If your objective needs to start with one of those words, put `--` before it: + +```sh +/goal -- cancel the old rollout note after the new docs are published +``` -Only one goal can be saved per session. If the objective needs to start with a subcommand keyword such as `status` or `pause`, use `--` as a separator: +If an upcoming goal needs to start with `manage`, put `--` after `next`: +```sh +/goal next -- manage the release checklist ``` -/goal -- cancel The function needs to return a retryable error on order failure, with tests added + +In non-interactive prompt mode, only the create forms start goal mode: + +```sh +KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi -p "/goal Fix the failing checkout test" ``` -In `manual` permission mode, the goal may pause to wait for tool call approval — not suitable for unattended scenarios. +Prompt mode exits with code `0` when the goal completes, `3` when it blocks, and `6` when it pauses. Other `/goal` subcommands, including `next`, are TUI controls and are not handled by `kimi -p`. ## Information & Status diff --git a/docs/zh/configuration/data-locations.md b/docs/zh/configuration/data-locations.md index b84b67b1ec..1325a74513 100644 --- a/docs/zh/configuration/data-locations.md +++ b/docs/zh/configuration/data-locations.md @@ -71,6 +71,7 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code) 会话目录内部包含: - **`state.json`**:会话标题、`lastPrompt`、创建/更新时间、`forkedFrom` 等元数据。 +- **`upcoming-goals.json`**:由 `/goal next ` 创建的 TUI 专属队列。它不属于 Agent 对话;只有当前目标完成并提升后续目标后,才会进入 Agent 对话。 - **`agents/main/wire.jsonl`**:主 Agent 的完整通信记录,用于会话恢复和回放。 - **`agents/main/plans/`**:Plan 模式下写入的计划文件,按计划 id 命名(`.md`)。 - **`agents/agent-0/` 等**:子 Agent 实例目录,各自含 `wire.jsonl`。 diff --git a/docs/zh/guides/goals.md b/docs/zh/guides/goals.md new file mode 100644 index 0000000000..1f71011b6c --- /dev/null +++ b/docs/zh/guides/goals.md @@ -0,0 +1,149 @@ +# 使用目标模式 + +目标(goal)让 Kimi Code 在多个轮次中持续朝一个明确结果工作。当任务有清晰终点,但下一步要取决于 Agent 工作中发现的信息时,使用 `/goal`。 + +平常对话中的提示词说明下一步要做什么。而目标说明了要追求的最终状态。Kimi Code 会持续保留该目标,根据目标的描述检查进展,并在目标仍为「活跃(`active`)」状态时继续工作。 + +::: info 说明 +`/goal` 仍是实验功能,需要在启动 `kimi` 时设置相应的环境变量: + +```sh +KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi +``` +::: + +## 开始目标 + +在 `/goal` 命令后写目标: + +```sh +/goal 修复项目的 GitHub 的 issues 中列出的 bug +``` + +Kimi Code 会保存该目标,把它作为下一条用户消息发送,并进入目标模式。每个轮次结束后,它会检查目标是「完成(`complete`)」、「阻塞(`blocked`)」、「暂停(`paused`)」,还是仍然「活跃(`active`)」。 + +好的目标应当说清楚具体的完成条件。 + +```sh +/goal 修复所有标签关于结算系统的回退的漏洞,为每个修复新增或更新测试,最后运行所有有关结算的测试套件 +``` + +避免只写宽泛方向。 + +```sh +/goal 找出这个代码库中的所有 bug +``` + +这个目标没有说明什么算成功、要检查什么,也没有说明其他的停止条件。Agent 可能会因为一些问题立刻进入「阻塞(`blocked`)」状态,也可能工作得比预期更久。 + +### 何时使用目标模式 + +1. 对有明确终点和可验证证据的工作使用目标模式。 + + ```sh + /goal 修复所有失败的结算测试,并确保可以成功运行有关结算的测试套件 + ``` + + Kimi Code 可以检查测试输出、修改文件、重新运行检查,并判断什么时候可以标记为「完成(`complete`)」状态。 + +2. 对可能需要多个轮次调查和修复的任务使用目标模式。 + + ```sh + /goal 找出发行版构建失败的原因,修复最本质的原因,并确认构建通过 + ``` + + 目标描述的是结果,因此当第一条线索不是根因时,Agent 也能调整方向。 + +3. 对无需再次提示、应按顺序持续推进的工作使用目标模式。 + + ```sh + /goal 更新功能实现,补充文档,运行测试,并总结变更文件 + ``` + + 当你已经知道完成前必须存在的检查或产物时,这种写法很有用。 + +### 何时不要使用目标模式 + +1. 不要把目标模式用于宽泛主题或开放式讨论。 + + ::: warning 反例 + ```sh + /goal 你好! + ``` + ::: + + 对于并不构成目标的内容,Agent 会立即把该目标标记为「完成(`complete`)」状态。 + +2. 不要把目标模式用于已知不可能或无法解决的任务。 + + ::: warning 反例 + ```sh + /goal 证明 1 + 1 = 3。 + ``` + ::: + + 如果目标看起来不可能或无法解决,Agent 会把它标记为「阻塞(`blocked`)」状态。 + +3. 不要使用含糊或过于复杂的目标。 + + ::: warning 反例 + ```sh + /goal 用单个 HTML 文件创建一个电子游戏。 + ``` + ::: + + Agent 有可能会完成该目标,但也可能在等待很久之后产出出人意料的结果。 + +## 管理生命周期 + +使用同一组命令查看或控制当前目标: + +| 命令 | 作用 | +| --- | --- | +| `/goal` 或 `/goal status` | 显示当前目标及其进展 | +| `/goal pause` | 暂停当前的目标,但不删除 | +| `/goal resume` | 继续被暂停或被阻塞的目标 | +| `/goal cancel` | 移除当前目标 | +| `/goal replace ` | 用新目标替换当前目标 | + +目标有三种停止方式: + +- 「完成(`complete`)」:目标已完成,Kimi Code 会清除该目标 +- 「暂停(`paused`)」:你暂停了它、中断了当前轮次,或恢复了原本有目标的会话 +- 「阻塞(`blocked`)」:Kimi Code 需要输入、无法按当前表述完成目标、达到预算上限,或遇到运行时失败 + +停止条件需要写在目标本身里。`/goal` 没有单独用于描述停止限制的语法。 + +## 安排后续目标 + +如果已经准备好更多工作,但不想中断当前目标,使用 `/goal next`: + +```sh +/goal next 测试通过后更新发布说明 +``` + +当前目标运行期间,安排的后续目标对 Agent 不可见。当前目标完成后,Kimi Code 会用与 `/goal ` 相同的效果开始第一个后续目标。 + +交互式管理后续目标: + +```sh +/goal next manage +``` + +在管理器中,用 / 浏览,Space 选择一个目标以便移动,选中后用 / 调整顺序,E 编辑,D 删除,Esc 取消。 + +这个功能帮助你以可管理的方式顺序运行后续目标。如果当前目标被暂停、取消或阻塞,Kimi Code 不会开始下一个后续目标。当目标进入「阻塞(blocked)」状态且存在后续目标时,TUI 会提醒你,这些后续目标会等待当前目标完成。 + +## 谨慎使用目标模式 + +目标模式适合能通过文件、测试、命令输出、生成产物或明确报告验证的工作。对于一次性修改或只需要一个答案的问题,普通提示词通常更合适。 + +在 `manual` 权限模式下,目标工作可能会停下来等待工具调用审批。无人值守工作应选择与代码库风险和可运行命令相匹配的权限模式。 + +在非交互式 prompt 模式中,只支持创建目标: + +```sh +KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi -p "/goal 修复 checkout 测试失败" +``` + +Prompt 模式在目标完成时以退出码 `0` 退出,在目标阻塞时以 `3` 退出,在目标暂停时以 `6` 退出。`/goal next` 和其它管理命令都是 TUI 控制命令。 diff --git a/docs/zh/guides/sessions.md b/docs/zh/guides/sessions.md index 99c6503426..ad042f610e 100644 --- a/docs/zh/guides/sessions.md +++ b/docs/zh/guides/sessions.md @@ -85,7 +85,7 @@ kimi --session /fork ``` -派生后的两个会话彼此独立,互不影响,可以随时通过 `/sessions` 切回原来的会话。 +派生后的两个会话彼此独立,互不影响,可以随时通过 `/sessions` 切回原来的会话。已保存的 `/goal` 不会复制到派生会话。如果你想在派生会话中进行自主 goal 工作,需要在那里开始一个新 goal。 ## 导出会话 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index c528c1f078..14f12e545a 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -44,13 +44,13 @@ | `/auto [on\|off]` | — | 切换 auto 权限模式。开启后工具审批自动处理,Agent 不会向用户提问 | 是 | | `/plan [on\|off]` | — | 切换 Plan 模式。不带参数时翻转;显式传 `on`/`off` 时强制设置。单纯切换不会创建空计划文件 | 是 | | `/plan clear` | — | 清除当前 plan 方案 | 否 | -| `/goal [...]` | — | 开始或管理一个自主 goal(实验功能;可通过 `/experiments`、`[experimental].goal_command` 或 `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1` 启用) | 见下文 | +| `/goal [...]` | — | 开始或管理目标模式(实验功能;可通过 `/experiments`、`[experimental].goal_command` 或 `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1` 启用) | 见下文 | ::: warning 注意 `/yolo` 会跳过普通工具调用的审批确认,使用前请确保了解可能的风险。Plan 模式的退出审批不会被 `/yolo` 跳过;Plan 模式下的 `Bash` 也按 `/yolo` 的普通放行规则处理。 ::: -## 自主 goal(实验功能) +## 目标模式(实验功能) ::: info `/goal` 是实验命令。可以通过 `/experiments` 启用,也可以写入 `~/.kimi-code/config.toml`: @@ -65,37 +65,41 @@ KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi ``` ::: -`/goal` 适用于你希望 Kimi Code 通过自动续跑的轮次持续处理的任务。在命令后写目标即可开始: +`/goal` 用于开始或管理目标模式:Kimi Code 会在自动续跑的轮次中持续朝一个持久目标工作。使用指导和示例见[使用目标模式](../guides/goals.md)。 ``` /goal 更新 checkout 文档,运行 docs build,如果 20 轮后仍被阻塞就停止 ``` -Kimi Code 会保存该目标,把它作为下一条 User 消息发送,然后持续运行后续轮次,直到 goal 停止。goal 有三种停止状态: +| 命令 | 作用 | 可用性 | +| --- | --- | --- | +| `/goal` 或 `/goal status` | 显示当前目标及其状态、已用时间、轮次数、token 数 | 随时可用 | +| `/goal pause` | 暂停当前的目标,但不删除 | 随时可用 | +| `/goal resume` | 继续被暂停或被阻塞的目标 | 仅空闲时 | +| `/goal cancel` | 移除当前目标 | 随时可用 | +| `/goal replace ` | 用新目标替换已保存的目标 | 仅空闲时 | +| `/goal next ` | 为当前会话安排一个后续目标。当前目标完成前,Agent 不会看到它 | 随时可用 | +| `/goal next manage` | 打开后续目标管理器。用 `↑`/`↓` 浏览,`Space` 选择一个目标以便移动,选中后用 `↑`/`↓` 调整顺序,`E` 编辑,`D` 删除,`Esc` 取消 | 随时可用 | -- `complete`:目标已完成,Kimi Code 发送完成消息并清除该 goal -- `paused`:你暂停了 goal、中断了当前轮次,或恢复了原本有 active goal 的会话 -- `blocked`:Kimi Code 因需要输入、无法完成目标、达到预算上限或遇到运行时失败而停止 +`status`、`pause`、`resume`、`cancel`、`replace` 和 `next` 只有作为 `/goal` 后的第一个词时才是子命令。如果你的目标需要以这些词开头,请在目标前加 `--`: -停止条件需要写在目标本身里,`/goal` 没有单独的停止限制 flag。 +```sh +/goal -- cancel 函数需要在订单失败时返回可重试错误,并补充测试 +``` -管理当前 goal 的子命令: +如果后续目标需要以 `manage` 开头,请在 `next` 后加 `--`: -| 命令 | 作用 | 可用性 | -| --- | --- | --- | -| `/goal` 或 `/goal status` | 显示当前 goal 及其状态、已用时间、轮次数、token 数 | 随时可用 | -| `/goal pause` | 暂停 active goal 并保留 | 随时可用 | -| `/goal resume` | 恢复 paused 或 blocked goal | 仅空闲时 | -| `/goal cancel` | 移除当前 goal | 随时可用 | -| `/goal replace ` | 用新目标替换已保存的 goal | 仅空闲时 | +```sh +/goal next -- manage 发布检查清单 +``` -一个会话中只能保存一个 goal。如果目标需要以 `status`、`pause` 等子命令关键词开头,使用 `--` 分隔: +在非交互式 prompt 模式中,只有创建形式会启动目标模式: -``` -/goal -- cancel 函数需要在订单失败时返回可重试错误,并补充测试 +```sh +KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi -p "/goal 修复 checkout 测试失败" ``` -在 `manual` 权限模式下,goal 可能会停下来等待工具调用审批,不适合无人值守场景。 +Prompt 模式在目标完成时以退出码 `0` 退出,在目标阻塞时以 `3` 退出,在目标暂停时以 `6` 退出。其它 `/goal` 子命令,包括 `next`,都是 TUI 控制命令,不由 `kimi -p` 处理。 ## 信息与状态 diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 61cb011316..4a38bd1320 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -61,6 +61,7 @@ import type { GetKimiConfigPayload, GetPluginInfoPayload, InstallPluginPayload, + JsonObject, ListSessionsPayload, McpServerInfo, McpStartupMetrics, @@ -98,6 +99,11 @@ import { KaosShellNotFoundError, LocalKaos, type Kaos } from '@moonshot-ai/kaos' import type { ToolServices } from '../tools/support/services'; const KIMI_CODE_PROVIDER_NAME = 'managed:kimi-code'; +const GOAL_FORK_CLEARED_REMINDER = [ + 'This fork does not have a current goal.', + 'Ignore earlier active-goal reminders from the source session.', + 'Handle requests normally unless the user starts a new goal.', +].join(' '); type AgentScopedPayload = T & { readonly agentId: string }; type SessionScopedPayload = T & { readonly sessionId: string }; @@ -359,8 +365,10 @@ export class KimiCore implements PromisableMethods { async forkSession(input: ForkSessionPayload): Promise { const source = await this.sessionStore.get(input.sessionId); const active = this.sessions.get(source.id); + let sourceHadGoal = hasGoalMetadata(source.metadata) || hasGoalMetadata(input.metadata); if (active !== undefined) { await active.flushMetadata(); + sourceHadGoal = sourceHadGoal || active.goals.getGoal().goal !== null; } const id = input.id ?? createSessionId(); @@ -370,7 +378,19 @@ export class KimiCore implements PromisableMethods { title: input.title, metadata: input.metadata, }); - return this.resumeSession({ sessionId: id }); + const resumed = await this.resumeSession({ sessionId: id }); + if (sourceHadGoal) { + const forked = this.sessions.get(id); + if (forked !== undefined) { + const mainAgent = await forked.ensureAgentResumed('main'); + mainAgent.context.appendSystemReminder(GOAL_FORK_CLEARED_REMINDER, { + kind: 'system_trigger', + name: 'goal_fork_cleared', + }); + await forked.flushMetadata(); + } + } + return resumed; } async listSessions(input: ListSessionsPayload = {}): Promise { @@ -884,6 +904,10 @@ function nonEmptyString(value: string | undefined): string | undefined { return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; } +function hasGoalMetadata(metadata: JsonObject | undefined): boolean { + return metadata !== undefined && 'goal' in metadata; +} + function requiredWorkDir(operation: string, value: string): string { if (typeof value !== 'string' || value.trim() === '') { throw new KimiError(ErrorCodes.REQUEST_WORK_DIR_REQUIRED, `${operation} requires workDir`); diff --git a/packages/agent-core/src/session/store/session-store.ts b/packages/agent-core/src/session/store/session-store.ts index 58853c875a..04389d03d4 100644 --- a/packages/agent-core/src/session/store/session-store.ts +++ b/packages/agent-core/src/session/store/session-store.ts @@ -17,6 +17,8 @@ const SessionSummaryStateSchema = z.object({ custom: z.record(z.string(), z.unknown()).optional(), }); +const FORKED_SESSION_DROPPED_FILES = ['upcoming-goals.json'] as const; + type SessionSummaryState = z.infer; export interface CreateSessionRecordInput { @@ -90,6 +92,7 @@ export class SessionStore { force: false, errorOnExist: true, }); + await dropForkedSessionFiles(targetDir); await this.writeForkedState(input, source.sessionDir, targetDir); const summary = await this.summaryFromDir(input.targetId, targetDir, source.workDir); await appendSessionIndexEntry(this.homeDir, { @@ -261,7 +264,7 @@ export class SessionStore { isCustomTitle: input.title === undefined ? parsed['isCustomTitle'] === true : true, forkedFrom: input.sourceId, agents: rewriteAgentHomedirs(parsed['agents'], sourceDir, targetDir), - custom: Object.assign({}, isRecord(parsed['custom']) ? parsed['custom'] : {}, input.metadata), + custom: forkCustomMetadata(parsed['custom'], input.metadata), }; await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); } @@ -301,6 +304,29 @@ function metadataFromState(state: SessionSummaryState | undefined): JsonObject | return state.custom as JsonObject; } +function forkCustomMetadata(source: unknown, metadata: JsonObject | undefined): Record { + return { + ...customMetadataWithoutGoal(source), + ...customMetadataWithoutGoal(metadata), + }; +} + +async function dropForkedSessionFiles(sessionDir: string): Promise { + await Promise.all( + FORKED_SESSION_DROPPED_FILES.map((fileName) => rm(join(sessionDir, fileName), { force: true })), + ); +} + +function customMetadataWithoutGoal(value: unknown): Record { + if (!isRecord(value)) return {}; + const custom: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (key === 'goal') continue; + custom[key] = entry; + } + return custom; +} + async function latestAgentWireMtime(sessionDir: string): Promise { const agentsDir = join(sessionDir, 'agents'); let entries; diff --git a/packages/node-sdk/test/list-sessions.test.ts b/packages/node-sdk/test/list-sessions.test.ts index ed958d1fd8..9a9d071669 100644 --- a/packages/node-sdk/test/list-sessions.test.ts +++ b/packages/node-sdk/test/list-sessions.test.ts @@ -72,7 +72,7 @@ describe('SessionStore.list', () => { expect(indexRaw).toContain(`"workDir":"${workDir}"`); }); - it('forks a session directory and rewrites fork metadata', async () => { + it('forks a session directory, rewrites metadata, and drops reserved goal state', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); const store = new SessionStore(homeDir); @@ -81,6 +81,11 @@ describe('SessionStore.list', () => { const sourceAgentDir = join(source.sessionDir, 'agents', 'main'); await mkdir(sourceAgentDir, { recursive: true }); await writeFile(join(sourceAgentDir, 'wire.jsonl'), '{"type":"context.clear"}\n', 'utf-8'); + await writeFile( + join(source.sessionDir, 'upcoming-goals.json'), + `${JSON.stringify({ version: 1, goals: [{ id: 'queued-1', objective: 'source queued goal' }] })}\n`, + 'utf-8', + ); await writeSessionState(source.sessionDir, { createdAt: '2030-01-01T00:00:00.000Z', updatedAt: '2030-01-01T00:00:00.000Z', @@ -94,6 +99,14 @@ describe('SessionStore.list', () => { }, custom: { source: true, + goal: { + goalId: 'source-goal', + objective: 'source objective', + status: 'active', + turnsUsed: 0, + tokensUsed: 0, + budgetLimits: {}, + }, }, }); @@ -101,7 +114,17 @@ describe('SessionStore.list', () => { sourceId: source.id, targetId: 'ses_fork_child', title: 'Fork title', - metadata: { child: true }, + metadata: { + child: true, + goal: { + goalId: 'metadata-goal', + objective: 'metadata objective', + status: 'active', + turnsUsed: 0, + tokensUsed: 0, + budgetLimits: {}, + }, + }, }); const forkState = JSON.parse(await readFile(join(fork.sessionDir, 'state.json'), 'utf-8')) as { @@ -116,6 +139,9 @@ describe('SessionStore.list', () => { expect(forkState.forkedFrom).toBe(source.id); expect(forkState.agents?.main?.homedir).toBe(join(fork.sessionDir, 'agents', 'main')); expect(forkState.custom).toMatchObject({ source: true, child: true }); + expect(forkState.custom).not.toHaveProperty('goal'); + expect(existsSync(join(fork.sessionDir, 'upcoming-goals.json'))).toBe(false); + expect(existsSync(join(source.sessionDir, 'upcoming-goals.json'))).toBe(true); await expect(readFile(join(fork.sessionDir, 'agents', 'main', 'wire.jsonl'), 'utf-8')).resolves.toBe( '{"type":"context.clear"}\n', ); diff --git a/packages/node-sdk/test/session-plan-compact-usage-resume.test.ts b/packages/node-sdk/test/session-plan-compact-usage-resume.test.ts index 366bb2df74..290170ccd0 100644 --- a/packages/node-sdk/test/session-plan-compact-usage-resume.test.ts +++ b/packages/node-sdk/test/session-plan-compact-usage-resume.test.ts @@ -190,7 +190,7 @@ describe('Session plan, compact, usage, and resume APIs', () => { } }); - it('forks a session and returns an active fork session', async () => { + it('forks a session, drops goal state, and returns an active fork session', async () => { const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-fork-home-'); const workDir = await makeTempDir(tempDirs, 'kimi-sdk-fork-work-'); await writeTestConfig(homeDir); @@ -201,7 +201,17 @@ describe('Session plan, compact, usage, and resume APIs', () => { id: 'ses_fork_runtime_source', workDir, model: 'test-model', - metadata: { source: true }, + metadata: { + source: true, + goal: { + goalId: 'source-goal', + objective: 'source objective', + status: 'active', + turnsUsed: 0, + tokensUsed: 0, + budgetLimits: {}, + }, + }, }); await source.setPlanMode(true); const sourcePlan = await source.getPlan(); @@ -213,7 +223,17 @@ describe('Session plan, compact, usage, and resume APIs', () => { id: source.id, forkId: 'ses_fork_runtime_child', title: 'Forked runtime', - metadata: { child: true }, + metadata: { + child: true, + goal: { + goalId: 'metadata-goal', + objective: 'metadata objective', + status: 'active', + turnsUsed: 0, + tokensUsed: 0, + budgetLimits: {}, + }, + }, }); expect(fork.id).toBe('ses_fork_runtime_child'); @@ -235,16 +255,22 @@ describe('Session plan, compact, usage, and resume APIs', () => { join(forkSummary!.sessionDir, 'agents', 'main', 'wire.jsonl'), 'utf-8', ); - const enterRecord = forkWire + const forkRecords = forkWire .trim() .split('\n') - .map((line) => JSON.parse(line) as Record) - .find((record) => record['type'] === 'plan_mode.enter'); + .map((line) => JSON.parse(line) as Record); + const enterRecord = forkRecords.find((record) => record['type'] === 'plan_mode.enter'); expect(enterRecord).toEqual({ type: 'plan_mode.enter', id: sourcePlan.id, time: expect.any(Number), }); + const goalReminder = forkRecords.find((record) => { + const message = record['message'] as { origin?: { name?: string } } | undefined; + return record['type'] === 'context.append_message' && message?.origin?.name === 'goal_fork_cleared'; + }); + expect(goalReminder).toBeDefined(); + expect(JSON.stringify(goalReminder)).toContain('This fork does not have a current goal.'); const forkState = JSON.parse( await readFile(join(forkSummary!.sessionDir, 'state.json'), 'utf-8'), ) as { @@ -257,6 +283,7 @@ describe('Session plan, compact, usage, and resume APIs', () => { expect(forkState.forkedFrom).toBe(source.id); expect(forkState.agents?.main?.homedir).toBe(join(forkSummary!.sessionDir, 'agents', 'main')); expect(forkState.custom).toMatchObject({ source: true, child: true }); + expect(forkState.custom).not.toHaveProperty('goal'); } finally { await harness.close(); } diff --git a/plans/2026-06-03-upcoming-goals.md b/plans/2026-06-03-upcoming-goals.md new file mode 100644 index 0000000000..154846d4d4 --- /dev/null +++ b/plans/2026-06-03-upcoming-goals.md @@ -0,0 +1,228 @@ +# Upcoming Goals Queue Plan + +## Goal + +Add a private, per-session queue of upcoming goals in the TUI. + +The user can queue autonomous tasks that should run after the current goal completes. The agent only sees the active goal. It must not see upcoming goals. + +## User Value + +We found that agents sometimes complete a goal too quickly. Users can be disappointed that they can assign only one goal at a time. + +Many users already know the next independent tasks they want to run. Today they need to wait for the current goal to complete, return to the TUI, and submit the next goal manually. + +An upcoming-goals queue lets users prepare several autonomous tasks in one session. The agent still works on one goal at a time, but the TUI can start the next queued goal after the current goal is complete. + +This avoids giving the agent a broad combined goal. It also keeps the next tasks hidden until they become the active goal. + +## Current Goal Behavior + +The current `/goal` command creates one active goal through the session API. + +The goal driver keeps running turns while the goal is `active`. The model ends the loop by calling `UpdateGoal`. + +The TUI receives `goal.updated` events and can observe when the current goal completes. + +## Proposed Commands + +Add these TUI commands: + +```text +/goal next +/goal next manage +``` + +`/goal next ` appends an objective to the upcoming goals queue. + +`/goal next manage` opens an interactive management list. + +Use `--` to force objective parsing when the objective starts with a reserved word: + +```text +/goal next -- manage the release notes +``` + +## Management List + +The management list shows all upcoming goals for the current session. + +Expected controls: + +- Up and Down browse goals. +- Space enters or exits move mode for the focused goal. +- In move mode, Up and Down reorder the selected goal. +- `e` edits the focused goal. +- `d` removes the focused goal. +- Escape closes the list. + +The footer should show the active controls. + +Use wording like: + +```text +↑↓ browse · Space move · e edit · d delete · Esc close +``` + +When move mode is active, the footer should make that state clear. + +## Queue State + +Store the queue per session. + +The queue should survive closing and resuming the TUI session. + +The agent must not receive the queue in prompt injection, tool output, goal reminders, or normal user messages. + +Recommended state shape: + +```ts +interface UpcomingGoal { + id: string; + objective: string; + createdAt: string; + updatedAt: string; +} +``` + +The queue should live outside `metadata.custom.goal`. + +Recommended storage location: + +- `/upcoming-goals.json` + +The SDK `SessionSummary` already exposes `sessionDir` to the TUI. + +This keeps the queue session-scoped without adding upcoming-goal methods or types to RPC or SDK. + +## Promotion Rules + +When the active goal completes: + +1. The TUI observes the `goal.updated` completion event. +2. The TUI reads the first upcoming goal. +3. The TUI removes that item from the queue. +4. The TUI calls `session.createGoal({ objective })`. +5. The TUI sends the objective as normal user input. + +Do not promote the next goal when the current goal becomes `paused`. + +Do not promote the next goal when the current goal is cancelled. + +Do not promote the next goal when the current goal becomes `blocked`. + +When the current goal is blocked and the queue is non-empty, show a TUI notice: + +```text +Goal blocked. The next queued goal will start only after this goal is complete. +``` + +## No Current Goal + +Decision: `/goal next ` queues the goal even when there is no active, paused, or blocked current goal. + +This keeps `/goal next` literal and avoids surprising automatic starts. + +## Architecture + +Keep the feature TUI-owned. + +The TUI should provide persistence and small queue operations. It should not add upcoming goals to agent-core RPC, the node SDK, prompt injection, or agent context. + +Suggested split: + +- `apps/kimi-code/src/tui/goal-queue-store.ts` + - Owns queue state validation, queue operations, and file persistence. + - Stores queue state inside the current session directory. +- `apps/kimi-code/src/tui/commands/goal.ts` + - Parses `/goal next`. + - Adds the queue append and manage entry points. +- `apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts` + - Implements the interactive management list. +- `apps/kimi-code/src/tui/controllers/session-event-handler.ts` + - Promotes the next queued goal after completion. + - Shows the blocked notice when needed. + +## UI Placement + +The management list should be a dialog component mounted with `mountEditorReplacement`. + +Follow the existing selector and dialog patterns in `apps/kimi-code/src/tui/components/dialogs/`. + +Use theme tokens from `ColorPalette`. Do not use chalk named colors. + +## Event Flow + +Completion flow: + +```text +goal.updated(completion) + -> TUI renders completion message + -> TUI checks upcoming queue + -> TUI starts next goal if one exists + -> TUI sends next objective as normal input +``` + +Blocked flow: + +```text +goal.updated(lifecycle, blocked) + -> TUI renders blocked marker + -> TUI checks upcoming queue + -> TUI shows notice if queue is non-empty +``` + +Paused and cancelled flow: + +```text +goal.updated(paused or null) + -> TUI does not start a queued goal +``` + +## Tests + +Add or update tests in the nearest existing test files. + +Recommended coverage: + +- `/goal next ` parses as queue append. +- `/goal next manage` opens the manager. +- `--` lets users queue objectives that start with reserved words. +- The queue persists in session metadata. +- Resuming a session restores the queue. +- Completion promotes the next goal and sends the next objective as normal input. +- Blocked goals do not promote the next item. +- Paused goals do not promote the next item. +- Cancelled goals do not promote the next item. +- The management list can browse, move, edit, and delete entries. + +## Deferred Choices + +- Should there be a footer badge that shows the upcoming goal count? +- Should deleting an item ask for confirmation after the first version? +- Should the queue emit a live `goal.queue.updated` event for non-TUI clients? + +## Suggested First Implementation Slice + +Build the feature in this order: + +1. Add TUI-owned session-level queue persistence. +2. Add `/goal next ` and queue status display. +3. Promote the next queued goal only after completion. +4. Add blocked notice. +5. Add `/goal next manage`. +6. Add reorder, edit, and delete behavior in the manager. + +This keeps each slice testable and avoids mixing persistence, promotion, and interactive editing in one change. + +## Detailed Implementation Plan + +Use these files for the implementation-level plan: + +- `plans/upcoming-goals/00-index.md` +- `plans/upcoming-goals/01-session-queue-store.md` +- `plans/upcoming-goals/02-tui-queue-store.md` +- `plans/upcoming-goals/03-goal-next-command.md` +- `plans/upcoming-goals/04-completion-promotion.md` +- `plans/upcoming-goals/05-management-dialog.md` +- `plans/upcoming-goals/06-verification-docs-changeset.md` diff --git a/plans/upcoming-goals/00-index.md b/plans/upcoming-goals/00-index.md new file mode 100644 index 0000000000..07569a59e6 --- /dev/null +++ b/plans/upcoming-goals/00-index.md @@ -0,0 +1,49 @@ +# Upcoming Goals Queue Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a private, per-session queue of upcoming goals that the TUI can promote after the current goal completes. + +**Architecture:** The TUI owns queue persistence in a session-local file. The TUI owns command parsing, the manager dialog, and promotion after completion. RPC and SDK do not expose upcoming-goal methods or types. + +**Tech Stack:** TypeScript, Vitest, `@earendil-works/pi-tui`, Kimi Code SDK, agent-core session RPC. + +--- + +## Decisions + +- `/goal next ` always queues the objective. +- It queues even when there is no current goal. +- `/goal next manage` opens the manager dialog. +- `/upcoming-goals.json` stores the queue. +- RPC and SDK do not expose upcoming-goal queue methods or types. +- The agent must not see queued goals in prompt injection, system reminders, tools, or user messages. +- The TUI promotes a queued goal only after a completion event and the follow-up `goal.updated` event with `snapshot: null`. +- The TUI must not promote after pause, cancel, or blocked. +- When blocked and the queue is non-empty, the TUI shows a notice that the next goal starts only after completion. +- The first implementation does not add a queue count badge or queue events. + +## Plan Files + +- `01-session-queue-store.md` defines the queue file and TUI store. +- `02-tui-queue-store.md` records the RPC and SDK boundary rule. +- `03-goal-next-command.md` adds `/goal next `. +- `04-completion-promotion.md` promotes queued goals after completion. +- `05-management-dialog.md` adds `/goal next manage`. +- `06-verification-docs-changeset.md` covers final tests, docs, and changeset work. + +## Implementation Order + +- [ ] Build and test the TUI queue store. +- [ ] Wire commands and event handling to the TUI queue store. +- [ ] Add queue append command parsing and handling. +- [ ] Add completion promotion and blocked notice. +- [ ] Add the management dialog with reorder, edit, and delete. +- [ ] Run focused tests, update docs, and create a changeset. + +## Risk Checks + +- Promotion must wait for the null clear event. Creating a new goal during the completion event can race with the current goal clear. +- Queue add and manage must work while the agent is streaming. +- Queue objectives must not be appended to the transcript until the queued goal is promoted. +- Create failure during promotion must leave the queued item in place. diff --git a/plans/upcoming-goals/01-session-queue-store.md b/plans/upcoming-goals/01-session-queue-store.md new file mode 100644 index 0000000000..ac82220e0e --- /dev/null +++ b/plans/upcoming-goals/01-session-queue-store.md @@ -0,0 +1,163 @@ +# Upcoming Goals Queue Store Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist upcoming goals in a TUI-owned session file without adding queue methods or types to RPC or SDK. + +**Architecture:** The TUI reads and writes `/upcoming-goals.json`. The store uses `Session.summary.sessionDir`, which already exists in the SDK session summary. + +**Tech Stack:** TypeScript, Node file I/O, Kimi Code TUI, Vitest. + +--- + +### Task 1: Add The Queue Store + +**Files:** + +- Create: `apps/kimi-code/src/tui/goal-queue-store.ts` +- Test: `apps/kimi-code/test/tui/goal-queue-store.test.ts` + +- [ ] **Step 1: Write store tests** + +Cover these cases: + +```ts +it('reads an empty queue when the file is missing', async () => {}); +it('appends a trimmed upcoming goal and writes the session file', async () => {}); +it('updates an upcoming goal objective', async () => {}); +it('removes an upcoming goal by id', async () => {}); +it('moves an upcoming goal up and down', async () => {}); +it('rejects empty and over-long objectives', async () => {}); +it('normalizes malformed queue files to an empty queue', async () => {}); +it('throws when the session summary does not expose a session directory', async () => {}); +``` + +Expected before implementation: the import from `#/tui/goal-queue-store` fails. + +- [ ] **Step 2: Add store types** + +Create `apps/kimi-code/src/tui/goal-queue-store.ts`. + +Use these exported types: + +```ts +export interface UpcomingGoal { + readonly id: string; + readonly objective: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface GoalQueueSnapshot { + readonly goals: readonly UpcomingGoal[]; +} + +export type GoalQueueMoveDirection = 'up' | 'down'; +``` + +Use this private file shape: + +```ts +interface GoalQueueFile { + readonly version: 1; + readonly goals: readonly UpcomingGoal[]; +} +``` + +- [ ] **Step 3: Resolve the file path from the session** + +Use `Session.summary.sessionDir`. + +```ts +const GOAL_QUEUE_FILE = 'upcoming-goals.json'; + +function goalQueuePath(session: Pick): string { + const sessionDir = session.summary?.sessionDir; + if (sessionDir === undefined || sessionDir.trim().length === 0) { + throw new Error(`Session ${session.id} does not expose a session directory`); + } + return join(sessionDir, GOAL_QUEUE_FILE); +} +``` + +Import `Session` from `@moonshot-ai/kimi-code-sdk`. + +- [ ] **Step 4: Implement queue operations** + +Export these functions: + +```ts +export async function readGoalQueue( + session: Pick, +): Promise; + +export async function appendGoalQueueItem( + session: Pick, + input: { readonly objective: string }, +): Promise; + +export async function updateGoalQueueItem( + session: Pick, + input: { readonly goalId: string; readonly objective: string }, +): Promise; + +export async function removeGoalQueueItem( + session: Pick, + input: { readonly goalId: string }, +): Promise; + +export async function moveGoalQueueItem( + session: Pick, + input: { readonly goalId: string; readonly direction: GoalQueueMoveDirection }, +): Promise; +``` + +Use `randomUUID()` for ids. + +Use `mkdir(dirname(file), { recursive: true })` before writes. + +- [ ] **Step 5: Validate objectives** + +Use the existing public errors from `@moonshot-ai/kimi-code-sdk`: + +```ts +ErrorCodes.GOAL_OBJECTIVE_EMPTY +ErrorCodes.GOAL_OBJECTIVE_TOO_LONG +ErrorCodes.GOAL_NOT_FOUND +KimiError +``` + +Keep the max objective length at 4000. + +- [ ] **Step 6: Normalize malformed files** + +When the queue file is missing, return: + +```ts +{ goals: [] } +``` + +When the queue file exists but does not match the expected shape, overwrite it with: + +```json +{ "version": 1, "goals": [] } +``` + +- [ ] **Step 7: Run store tests** + +Run: + +```bash +pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/goal-queue-store.test.ts +``` + +Expected: all TUI queue store tests pass. + +- [ ] **Step 8: Commit this slice** + +Use: + +```bash +git add apps/kimi-code/src/tui/goal-queue-store.ts apps/kimi-code/test/tui/goal-queue-store.test.ts +git commit -m "feat: add tui upcoming goal queue store" +``` diff --git a/plans/upcoming-goals/02-tui-queue-store.md b/plans/upcoming-goals/02-tui-queue-store.md new file mode 100644 index 0000000000..84c62844fc --- /dev/null +++ b/plans/upcoming-goals/02-tui-queue-store.md @@ -0,0 +1,86 @@ +# Upcoming Goals RPC And SDK Boundary Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep upcoming-goal queue data out of RPC and SDK. + +**Architecture:** The feature uses only the TUI store from `apps/kimi-code/src/tui/goal-queue-store.ts`. RPC and SDK continue to expose only the current goal lifecycle. + +**Tech Stack:** TypeScript, `rg`, Kimi Code TUI. + +--- + +### Task 1: Confirm The Current Boundary + +**Files:** + +- Read: `packages/agent-core/src/rpc/core-api.ts` +- Read: `packages/agent-core/src/session/rpc.ts` +- Read: `packages/node-sdk/src/session.ts` +- Read: `packages/node-sdk/src/types.ts` + +- [ ] **Step 1: Search RPC and SDK for queue symbols** + +Run: + +```bash +rg -n "GoalQueue|UpcomingGoal|goalQueue|appendGoalQueue|getGoalQueue|moveGoalQueue|removeGoalQueue|updateGoalQueue" packages/agent-core packages/node-sdk +``` + +Expected: no matches in source files. + +- [ ] **Step 2: Keep current goal methods unchanged** + +Do not change these existing methods: + +```ts +createGoal +getGoal +pauseGoal +resumeGoal +cancelGoal +``` + +They are for the active goal only. + +### Task 2: Avoid New RPC Or SDK Types + +**Files:** + +- Do not modify: `packages/agent-core/src/rpc/core-api.ts` +- Do not modify: `packages/agent-core/src/rpc/core-impl.ts` +- Do not modify: `packages/agent-core/src/session/rpc.ts` +- Do not modify: `packages/agent-core/src/rpc/events.ts` +- Do not modify: `packages/node-sdk/src/session.ts` +- Do not modify: `packages/node-sdk/src/rpc.ts` +- Do not modify: `packages/node-sdk/src/types.ts` +- Do not modify: `packages/node-sdk/src/events.ts` + +- [ ] **Step 1: Use TUI imports only** + +When command, event handler, or dialog code needs the queue, import from: + +```ts +import { + appendGoalQueueItem, + moveGoalQueueItem, + readGoalQueue, + removeGoalQueueItem, + updateGoalQueueItem, + type GoalQueueMoveDirection, + type GoalQueueSnapshot, + type UpcomingGoal, +} from '#/tui/goal-queue-store'; +``` + +- [ ] **Step 2: Add a final boundary check** + +After implementation, run the same `rg` command from Task 1. + +Expected: no matches in `packages/agent-core` or `packages/node-sdk`. + +- [ ] **Step 3: Commit no source changes for this slice** + +This slice is a boundary rule and verification step. + +Do not create a commit unless you had to remove accidental RPC or SDK changes. diff --git a/plans/upcoming-goals/03-goal-next-command.md b/plans/upcoming-goals/03-goal-next-command.md new file mode 100644 index 0000000000..9c0a4b5917 --- /dev/null +++ b/plans/upcoming-goals/03-goal-next-command.md @@ -0,0 +1,171 @@ +# Goal Next Command Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `/goal next ` so users can queue work while the current goal is running. + +**Architecture:** Extend the existing `/goal` parser and handler. Queueing writes session metadata through the SDK and does not send a user message to the agent. + +**Tech Stack:** TypeScript, Kimi Code TUI slash commands, Vitest. + +--- + +### Task 1: Parse `/goal next` + +**Files:** + +- Modify: `apps/kimi-code/src/tui/commands/goal.ts` +- Modify: `apps/kimi-code/src/tui/commands/registry.ts` +- Test: `apps/kimi-code/test/tui/commands/goal.test.ts` +- Test: `apps/kimi-code/test/tui/commands/registry.test.ts` + +- [ ] **Step 1: Add parser tests** + +Add these assertions to `parseGoalCommand` tests: + +```ts +expect(parseGoalCommand('next Ship release notes')).toEqual({ + kind: 'next-add', + objective: 'Ship release notes', +}); + +expect(parseGoalCommand('next manage')).toEqual({ kind: 'next-manage' }); + +expect(parseGoalCommand('next -- manage release notes')).toEqual({ + kind: 'next-add', + objective: 'manage release notes', +}); + +expect(parseGoalCommand('next')).toEqual({ + kind: 'error', + severity: 'hint', + message: + 'Provide an upcoming goal objective, e.g. `/goal next Ship feature X`, or use `/goal next manage`.', +}); +``` + +- [ ] **Step 2: Extend the parsed command union** + +Add: + +```ts +| { readonly kind: 'next-add'; readonly objective: string } +| { readonly kind: 'next-manage' } +``` + +- [ ] **Step 3: Parse `next` before `replace`** + +In `parseGoalCommand`, handle `tokens[0] === 'next'` before the existing `replace` parsing. + +Rules: + +- `next manage` maps to `next-manage`. +- `next -- manage` queues an objective that starts with `manage`. +- `next ` queues the objective. +- Empty `next` returns a hint. +- The same 4000-character limit applies. + +- [ ] **Step 4: Add autocomplete and availability** + +Add `next` to `GOAL_ARG_COMPLETIONS`: + +```ts +{ value: 'next', description: 'Queue an upcoming goal' }, +``` + +In `/goal` availability, make `next` and any argument string that starts with `next ` available while streaming: + +```ts +if (trimmed === 'next' || trimmed.startsWith('next ')) return 'always'; +``` + +Keep `resume`, `replace`, and direct goal creation as idle-only. + +### Task 2: Queue The Objective + +**Files:** + +- Modify: `apps/kimi-code/src/tui/commands/goal.ts` +- Test: `apps/kimi-code/test/tui/commands/goal.test.ts` + +- [ ] **Step 1: Mock the TUI queue store** + +In `apps/kimi-code/test/tui/commands/goal.test.ts`, mock: + +```ts +vi.mock('#/tui/goal-queue-store', () => ({ + appendGoalQueueItem: vi.fn(async () => ({ + goals: [{ id: 'q1', objective: 'obj', createdAt: '', updatedAt: '' }], + })), +})); +``` + +- [ ] **Step 2: Add command handler tests** + +Add tests for: + +```ts +await handleGoalCommand(host, 'next Ship release notes'); +expect(appendGoalQueueItem).toHaveBeenCalledWith(session, { + objective: 'Ship release notes', +}); +expect(host.sendNormalUserInput).not.toHaveBeenCalled(); +expect(host.showStatus).toHaveBeenCalledWith( + 'Upcoming goal added. It will start after the current goal is complete.', +); +``` + +Also test that queueing does not require a configured model. + +- [ ] **Step 3: Implement `queueNextGoal`** + +Add a helper in `goal.ts`: + +```ts +async function queueNextGoal( + host: SlashCommandHost, + parsed: Extract, +): Promise { + try { + await appendGoalQueueItem(host.requireSession(), { objective: parsed.objective }); + } catch (error) { + host.showError(formatErrorMessage(error)); + return; + } + host.track('goal_queue_append'); + host.showStatus('Upcoming goal added. It will start after the current goal is complete.'); +} +``` + +Import `appendGoalQueueItem` from `#/tui/goal-queue-store`. + +Do not call `sendNormalUserInput`. + +- [ ] **Step 4: Add a temporary manager response** + +Until the manager dialog is added, route `next-manage` to a small status message: + +```ts +host.showStatus('Upcoming goal manager is not available yet.'); +``` + +The management-dialog slice will replace this with the real dialog. + +- [ ] **Step 5: Run command tests** + +Run: + +```bash +pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/commands/goal.test.ts apps/kimi-code/test/tui/commands/registry.test.ts +``` + +Expected: parsing, availability, and queue append tests pass. + +- [ ] **Step 6: Commit this slice** + +Use: + +```bash +git add apps/kimi-code/src/tui/commands/goal.ts apps/kimi-code/src/tui/commands/registry.ts apps/kimi-code/test/tui/commands/goal.test.ts apps/kimi-code/test/tui/commands/registry.test.ts +git commit -m "feat: add goal next queue command" +``` diff --git a/plans/upcoming-goals/04-completion-promotion.md b/plans/upcoming-goals/04-completion-promotion.md new file mode 100644 index 0000000000..f903f0860e --- /dev/null +++ b/plans/upcoming-goals/04-completion-promotion.md @@ -0,0 +1,233 @@ +# Upcoming Goals Promotion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Start the next queued goal only after the current goal completes and clears. + +**Architecture:** The session event handler observes goal lifecycle events. It marks completion as pending, waits for the follow-up null snapshot, then starts the first queued goal. + +**Tech Stack:** TypeScript, Kimi Code TUI event handling, Kimi Code SDK, Vitest. + +--- + +### Task 1: Promote After The Clear Event + +**Files:** + +- Modify: `apps/kimi-code/src/tui/controllers/session-event-handler.ts` +- Create: `apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts` + +- [ ] **Step 1: Write the promotion event test** + +Create a host with: + +```ts +const session = { + createGoal: vi.fn(async () => fakeGoalSnapshot('Ship queued goal')), +}; +``` + +Mock the TUI queue store: + +```ts +vi.mock('#/tui/goal-queue-store', () => ({ + readGoalQueue: vi.fn(async () => ({ + goals: [{ id: 'q1', objective: 'Ship queued goal', createdAt: '', updatedAt: '' }], + })), + removeGoalQueueItem: vi.fn(async () => ({ goals: [] })), +})); +``` + +Call `handler.handleEvent()` twice: + +```ts +handler.handleEvent(goalCompletionEvent, vi.fn()); +handler.handleEvent(goalClearedEvent, vi.fn()); +``` + +Assert: + +```ts +await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ objective: 'Ship queued goal' }); +}); +expect(removeGoalQueueItem).toHaveBeenCalledWith(session, { goalId: 'q1' }); +expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship queued goal'); +``` + +Expected before implementation: the test fails because no promotion runs. + +- [ ] **Step 2: Track completion awaiting clear** + +Add a private field: + +```ts +private goalCompletionAwaitingClear = false; +``` + +In the completion branch of `handleGoalUpdated`, set it before returning: + +```ts +this.goalCompletionAwaitingClear = true; +``` + +- [ ] **Step 3: Promote only on `snapshot: null`** + +At the start of `handleGoalUpdated`, after `setAppState`, add: + +```ts +if (event.snapshot === null && this.goalCompletionAwaitingClear) { + this.goalCompletionAwaitingClear = false; + void this.promoteNextQueuedGoal(); +} +``` + +This avoids racing with `SessionGoalStore.markComplete()`, which emits completion before it clears the durable goal. + +- [ ] **Step 4: Implement `promoteNextQueuedGoal`** + +Add a private async method: + +```ts +private async promoteNextQueuedGoal(): Promise { + const session = this.host.session; + if (session === undefined || this.host.aborted) return; + + let next; + try { + const queue = await readGoalQueue(session); + next = queue.goals[0]; + } catch (error) { + this.host.showError(`Failed to read upcoming goals: ${formatErrorMessage(error)}`); + return; + } + if (next === undefined) return; + + try { + await session.createGoal({ objective: next.objective }); + } catch (error) { + this.host.showError(`Failed to start queued goal: ${formatErrorMessage(error)}`); + return; + } + + try { + await removeGoalQueueItem(session, { goalId: next.id }); + } catch (error) { + this.host.showError(`Queued goal started, but could not be removed from the queue: ${formatErrorMessage(error)}`); + } + + this.host.state.transcriptContainer.addChild( + new GoalSetMessageComponent(this.host.state.theme.colors), + ); + this.host.state.ui.requestRender(); + this.host.sendNormalUserInput(next.objective); +} +``` + +Import `GoalSetMessageComponent` from `../components/messages/goal-panel`. + +Import `formatErrorMessage` from `../utils/event-payload` if the file does not already import it. + +Import `readGoalQueue` and `removeGoalQueueItem` from `#/tui/goal-queue-store`. + +- [ ] **Step 5: Keep create failures non-destructive** + +Add a test where `session.createGoal` rejects. + +Assert: + +```ts +expect(removeGoalQueueItem).not.toHaveBeenCalled(); +expect(host.sendNormalUserInput).not.toHaveBeenCalled(); +expect(host.showError).toHaveBeenCalled(); +``` + +This ensures the queued goal stays in the queue when it cannot start. + +### Task 2: Do Not Promote On Blocked, Paused, Or Cancelled + +**Files:** + +- Modify: `apps/kimi-code/src/tui/controllers/session-event-handler.ts` +- Test: `apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts` + +- [ ] **Step 1: Add blocked notice test** + +Use a blocked lifecycle event: + +```ts +const event = { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot: { ...fakeGoalSnapshot('Blocked'), status: 'blocked' }, + change: { kind: 'lifecycle', status: 'blocked', reason: 'waiting for access' }, +} as const; +``` + +Assert: + +```ts +await vi.waitFor(() => { + expect(host.showNotice).toHaveBeenCalledWith( + 'Goal blocked.', + 'The next queued goal will start only after this goal is complete.', + ); +}); +expect(session.createGoal).not.toHaveBeenCalled(); +``` + +- [ ] **Step 2: Implement blocked notice** + +In `handleGoalUpdated`, when `change.kind === 'lifecycle' && change.status === 'blocked'`, call: + +```ts +void this.notifyQueuedGoalWaitingOnBlocked(); +``` + +Add: + +```ts +private async notifyQueuedGoalWaitingOnBlocked(): Promise { + const session = this.host.session; + if (session === undefined || this.host.aborted) return; + try { + const queue = await readGoalQueue(session); + if (queue.goals.length === 0) return; + } catch { + return; + } + this.host.showNotice( + 'Goal blocked.', + 'The next queued goal will start only after this goal is complete.', + ); +} +``` + +- [ ] **Step 3: Add paused and cancelled tests** + +Add tests that send: + +- a lifecycle event with `status: 'paused'` +- a clear event with `snapshot: null` and no prior completion + +Assert `createGoal` is not called. + +- [ ] **Step 4: Run event handler tests** + +Run: + +```bash +pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +``` + +Expected: promotion, blocked notice, paused, and cancelled tests pass. + +- [ ] **Step 5: Commit this slice** + +Use: + +```bash +git add apps/kimi-code/src/tui/controllers/session-event-handler.ts apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +git commit -m "feat: promote queued goals after completion" +``` diff --git a/plans/upcoming-goals/05-management-dialog.md b/plans/upcoming-goals/05-management-dialog.md new file mode 100644 index 0000000000..ea88771a2e --- /dev/null +++ b/plans/upcoming-goals/05-management-dialog.md @@ -0,0 +1,234 @@ +# Upcoming Goals Management Dialog Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `/goal next manage` so users can browse, reorder, edit, and delete upcoming goals. + +**Architecture:** Add a focused dialog component mounted with `mountEditorReplacement`. The dialog calls SDK queue methods and never sends queued objectives to the agent. + +**Tech Stack:** TypeScript, `@earendil-works/pi-tui`, Kimi Code TUI dialogs, Vitest. + +--- + +### Task 1: Build The Manager Component + +**Files:** + +- Create: `apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts` +- Create: `apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts` + +- [ ] **Step 1: Write render and key tests** + +Cover: + +```ts +it('renders queued goals with the standard list header', () => {}); +it('renders an empty state with the add command', () => {}); +it('moves the cursor with Up and Down', () => {}); +it('toggles move mode with Space', () => {}); +it('calls onMove while move mode is active', () => {}); +it('calls onEdit when E or e is pressed', () => {}); +it('calls onDelete when D or d is pressed', () => {}); +it('calls onCancel on Escape', () => {}); +``` + +Use `stripAnsi()` like other dialog tests. + +- [ ] **Step 2: Add component options** + +Use this shape: + +```ts +export interface GoalQueueManagerOptions { + readonly goals: readonly UpcomingGoal[]; + readonly colors: ColorPalette; + readonly requestRender: () => void; + readonly onMove: ( + goalId: string, + direction: GoalQueueMoveDirection, + ) => Promise; + readonly onEdit: (goal: UpcomingGoal) => void; + readonly onDelete: (goalId: string) => Promise; + readonly onCancel: () => void; +} +``` + +Import `UpcomingGoal`, `GoalQueueMoveDirection`, and `GoalQueueSnapshot` from `#/tui/goal-queue-store`. + +- [ ] **Step 3: Follow the TUI dialog design** + +Render with: + +```text +───────────────────────────────────────── + Upcoming goals + ↑↓ navigate · Space move · E edit · D delete · Esc cancel + + ❯ 1. Ship release notes + 2. Update docs + +───────────────────────────────────────── +``` + +When move mode is active, use: + +```text +↑↓ reorder · Space done · Esc cancel +``` + +Use `SELECT_POINTER`, `SearchableList`, `truncateToWidth`, `visibleWidth`, and `printableChar()`. + +Use `chalk.hex(colors.)`. + +- [ ] **Step 4: Implement move mode** + +Behavior: + +- Normal Up and Down browse. +- Space toggles move mode for the selected item. +- In move mode, Up calls `onMove(goalId, 'up')`. +- In move mode, Down calls `onMove(goalId, 'down')`. +- After `onMove` resolves, replace the local goals list with `snapshot.goals`. +- Keep focus on the moved goal by id. +- Call `requestRender()` after async updates. + +- [ ] **Step 5: Implement delete** + +Behavior: + +- `D` and `d` delete the focused goal. +- No confirmation dialog in the first version. +- After delete resolves, replace the local list with `snapshot.goals`. +- Clamp the cursor to the new list length. + +### Task 2: Build The Edit Dialog + +**Files:** + +- Modify: `apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts` +- Test: `apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts` + +- [ ] **Step 1: Add edit dialog tests** + +Cover: + +```ts +it('prefills the current objective', () => {}); +it('submits a trimmed objective on Enter', () => {}); +it('rejects an empty objective', () => {}); +it('rejects objectives over 4000 characters', () => {}); +it('cancels on Escape', () => {}); +``` + +- [ ] **Step 2: Add `GoalQueueEditDialogComponent`** + +Use an `Input` inside a rounded box. + +Use: + +```ts +this.input.setValue(opts.initialObjective); +this.input.onSubmit = (value) => this.submit(value); +``` + +Return: + +```ts +export type GoalQueueEditResult = + | { readonly kind: 'ok'; readonly objective: string } + | { readonly kind: 'cancel' }; +``` + +Use the same max length as `goal.ts`: + +```ts +const MAX_GOAL_OBJECTIVE_LENGTH = 4000; +``` + +### Task 3: Wire `/goal next manage` + +**Files:** + +- Modify: `apps/kimi-code/src/tui/commands/goal.ts` +- Test: `apps/kimi-code/test/tui/commands/goal.test.ts` + +- [ ] **Step 1: Replace the temporary manager response** + +Add `showGoalQueueManager(host)` in `goal.ts`. + +It should: + +```ts +const session = host.requireSession(); +const snapshot = await readGoalQueue(session); +host.mountEditorReplacement( + new GoalQueueManagerComponent({ + goals: snapshot.goals, + colors: host.state.theme.colors, + requestRender: () => { + host.state.ui.requestRender(); + }, + onMove: (goalId, direction) => moveGoalQueueItem(session, { goalId, direction }), + onDelete: (goalId) => removeGoalQueueItem(session, { goalId }), + onEdit: (goal) => { + showGoalQueueEditDialog(host, goal); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), +); +``` + +Callbacks: + +- `onMove` calls `moveGoalQueueItem`. +- `onDelete` calls `removeGoalQueueItem`. +- `onEdit` mounts `GoalQueueEditDialogComponent`. +- `onCancel` calls `host.restoreEditor()`. + +- [ ] **Step 2: Edit selected goal** + +On edit submit: + +```ts +const updated = await updateGoalQueueItem(session, { + goalId: goal.id, + objective: result.objective, +}); +``` + +Then remount the manager with `updated.goals`. + +On edit cancel, remount the manager with the current queue from `readGoalQueue(session)`. + +- [ ] **Step 3: Add command tests** + +Assert: + +```ts +await handleGoalCommand(host, 'next manage'); +expect(readGoalQueue).toHaveBeenCalledWith(session); +expect(host.mountEditorReplacement).toHaveBeenCalled(); +``` + +Assert manager actions call the TUI store methods. + +- [ ] **Step 4: Run TUI tests** + +Run: + +```bash +pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/commands/goal.test.ts apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts +``` + +Expected: manager command and dialog tests pass. + +- [ ] **Step 5: Commit this slice** + +Use: + +```bash +git add apps/kimi-code/src/tui/commands/goal.ts apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts apps/kimi-code/test/tui/commands/goal.test.ts apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts +git commit -m "feat: manage upcoming goals in the tui" +``` diff --git a/plans/upcoming-goals/06-verification-docs-changeset.md b/plans/upcoming-goals/06-verification-docs-changeset.md new file mode 100644 index 0000000000..b63e51e1a7 --- /dev/null +++ b/plans/upcoming-goals/06-verification-docs-changeset.md @@ -0,0 +1,183 @@ +# Upcoming Goals Verification And Release Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Verify the upcoming goals feature, update user docs, and add the release metadata. + +**Architecture:** Run focused tests first, then broader package tests. Update docs because the slash command behavior changes. Add a changeset for the CLI package. + +**Tech Stack:** pnpm, Vitest, VitePress docs, changesets. + +--- + +### Task 1: Run Focused Verification + +**Files:** + +- Read: changed source and test files from the previous slices. + +- [ ] **Step 1: Run TUI queue store tests** + +Run: + +```bash +pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/goal-queue-store.test.ts +``` + +Expected: all queue store tests pass. + +- [ ] **Step 2: Run TUI command tests** + +Run: + +```bash +pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/commands/goal.test.ts apps/kimi-code/test/tui/commands/registry.test.ts +``` + +Expected: `/goal next` parsing, queueing, and availability tests pass. + +- [ ] **Step 3: Run TUI dialog and event tests** + +Run: + +```bash +pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +``` + +Expected: manager and promotion tests pass. + +- [ ] **Step 4: Verify RPC and SDK stay clean** + +Run: + +```bash +rg -n "GoalQueue|UpcomingGoal|goalQueue|appendGoalQueue|getGoalQueue|moveGoalQueue|removeGoalQueue|updateGoalQueue" packages/agent-core packages/node-sdk +``` + +Expected: no matches. + +### Task 2: Run Package Verification + +**Files:** + +- Read: `package.json` +- Read: package `package.json` files for changed packages. + +- [ ] **Step 1: Run changed package tests** + +Run: + +```bash +pnpm --filter @moonshot-ai/kimi-code test +``` + +Expected: all changed package tests pass. + +- [ ] **Step 2: Run root checks if package tests expose cross-package issues** + +Run: + +```bash +pnpm test +``` + +Expected: root Vitest suite passes. + +### Task 3: Update User Docs + +**Files:** + +- Modify: docs pages that list slash commands and goal behavior. +- Read: `docs/AGENTS.md` +- Use skill: `.agents/skills/gen-docs/SKILL.md` + +- [ ] **Step 1: Use the docs skill** + +Run the `gen-docs` skill after the implementation diff exists. + +Update the English and Chinese docs that describe `/goal`. + +The docs should cover: + +- `/goal next ` queues an upcoming goal. +- `/goal next manage` opens the manager. +- Queued goals start only after the current goal completes. +- Queued goals do not start after pause, cancel, or blocked. +- `--` lets an objective start with `manage`. + +- [ ] **Step 2: Keep docs user-focused** + +Use wording like: + +```text +Use `/goal next ` to queue another goal for the same session. +Kimi Code starts it after the current goal completes. +Use `/goal next manage` to reorder, edit, or remove queued goals. +``` + +Do not describe internal metadata keys in user docs. + +### Task 4: Add A Changeset + +**Files:** + +- Create: `.changeset/.md` +- Use skill: `.agents/skills/gen-changesets/SKILL.md` + +- [ ] **Step 1: Use the changeset skill** + +Run the `gen-changesets` skill after all source changes are in place. + +This feature is a backwards-compatible user-facing CLI feature, so the likely bump is `minor`. + +The changed package should be: + +```markdown +"@moonshot-ai/kimi-code": minor +``` + +Use this changelog style: + +```markdown +Add an upcoming goals queue for autonomous goal work in the TUI. +``` + +- [ ] **Step 2: Commit docs and changeset** + +Use: + +```bash +git add docs .changeset +git commit -m "docs: document upcoming goal queue" +``` + +### Task 5: Final Diff Review + +**Files:** + +- Read: `git diff --stat` +- Read: `git diff` + +- [ ] **Step 1: Check privacy boundaries** + +Confirm: + +- queued objectives are not added to agent context until promotion +- queued objectives are not included in goal prompt injection +- queued objectives are not written as user transcript messages until promotion +- blocked, paused, and cancelled do not promote + +- [ ] **Step 2: Check plain command behavior** + +Confirm: + +- `/goal next ` works while streaming +- `/goal next manage` opens the dialog +- `e` edits the selected goal +- `d` deletes the selected goal +- Space toggles move mode +- Up and Down reorder only while move mode is active + +- [ ] **Step 3: Commit final fixes** + +Use focused commits for any fixes found during review.