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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/add-worktree-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code": minor
---

Add `-w, --worktree [name]` flag to create a new git worktree for the session, and surface the worktree metadata in the agent system prompt.
12 changes: 11 additions & 1 deletion apps/kimi-code/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,13 @@ export function createProgram(
)
.addOption(new Option('--yes').hideHelp().default(false))
.addOption(new Option('--auto-approve').hideHelp().default(false))
.option('--plan', 'Start in plan mode.', false);
.option('--plan', 'Start in plan mode.', false)
.addOption(
new Option(
'-w, --worktree [name]',
'Create a new git worktree for this session (optionally specify a name).',
).argParser((val: string | boolean) => (val === true ? '' : (val as string))),
);

registerExportCommand(program);
registerProviderCommand(program);
Expand Down Expand Up @@ -111,6 +117,9 @@ export function createProgram(
const yoloValue = raw['yolo'] === true || raw['yes'] === true || raw['autoApprove'] === true;
const autoValue = raw['auto'] === true;

const rawWorktree = raw['worktree'];
const worktreeValue = rawWorktree === true ? '' : (rawWorktree as string | undefined);

const opts: CLIOptions = {
session: sessionValue,
continue: raw['continue'] as boolean,
Expand All @@ -121,6 +130,7 @@ export function createProgram(
outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
prompt: raw['prompt'] as string | undefined,
skillsDirs: raw['skillsDir'] as string[],
worktree: worktreeValue,
};

onMain(opts);
Expand Down
11 changes: 11 additions & 0 deletions apps/kimi-code/src/cli/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export interface CLIOptions {
outputFormat: PromptOutputFormat | undefined;
prompt: string | undefined;
skillsDirs: string[];
worktree?: string;
/** Populated during startup when --worktree is used. */
worktreePath?: string;
/** Populated during startup when --worktree is used. */
parentRepoPath?: string;
}

export interface ValidatedOptions {
Expand Down Expand Up @@ -55,5 +60,11 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions {
if (opts.yolo && opts.auto) {
throw new OptionConflictError('Cannot combine --yolo with --auto.');
}
if (opts.worktree !== undefined && opts.session !== undefined) {
throw new OptionConflictError('Cannot combine --worktree with --session.');
}
if (opts.worktree !== undefined && opts.continue) {
throw new OptionConflictError('Cannot combine --worktree with --continue.');
}
return { options: opts, uiMode: promptMode ? 'print' : 'shell' };
}
20 changes: 17 additions & 3 deletions apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from './goal-prompt';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
import { createKimiCodeHostIdentity } from './version';
import { quoteShellArg } from '#/utils/shell-quote';

interface PromptOutput {
readonly columns?: number | undefined;
Expand Down Expand Up @@ -156,7 +157,7 @@ export async function runPrompt(
} else {
await runPromptTurn(session, opts.prompt!, outputFormat, stdout, stderr);
}
writeResumeHint(session.id, outputFormat, stdout, stderr);
writeResumeHint(session.id, outputFormat, stdout, stderr, opts.worktreePath !== undefined ? workDir : undefined);

withTelemetryContext({ sessionId: session.id }).track('exit', {
duration_s: (Date.now() - startedAt) / 1000,
Expand Down Expand Up @@ -290,7 +291,16 @@ async function resolvePromptSession(
}

const model = requireConfiguredModel(opts.model, defaultModel);
const session = await harness.createSession({ workDir, model, permission: 'auto' });
const metadata =
opts.worktreePath !== undefined && opts.parentRepoPath !== undefined
? { worktreePath: opts.worktreePath, parentRepoPath: opts.parentRepoPath }
: undefined;
// Note: --prompt mode intentionally does not auto-remove the worktree on
// exit. Unlike the TUI, a prompt session always produces at least a user
// prompt and assistant response, so the "empty session" cleanup rule does
// not apply; leaving the worktree makes the non-interactive output
// inspectable after the fact.
const session = await harness.createSession({ workDir, model, permission: 'auto', metadata });
installHeadlessHandlers(session);
return {
session,
Expand Down Expand Up @@ -589,8 +599,12 @@ function writeResumeHint(
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
workDir?: string,
): void {
const command = `kimi -r ${sessionId}`;
const command =
workDir !== undefined
? `cd ${quoteShellArg(workDir)} && kimi -r ${sessionId}`
: `kimi -r ${sessionId}`;
const content = `To resume this session: ${command}`;
if (outputFormat === 'stream-json') {
const message: PromptJsonResumeMetaMessage = {
Expand Down
24 changes: 22 additions & 2 deletions apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { execSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';

import { removeWorktree } from '#/utils/git/worktree';
import { quoteShellArg } from '#/utils/shell-quote';

import {
setCrashPhase,
setTelemetryContext,
Expand Down Expand Up @@ -133,14 +136,31 @@ export async function runShell(

tui.onExit = async (exitCode = 0) => {
const sessionId = tui.getCurrentSessionId();
const hasContent = tui.hasSessionContent();
const hasContent = tui.hasEverHadSessionContent();
setCrashPhase('shutdown');
trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 });

// Clean up the git worktree for empty sessions so abandoned worktrees
// do not accumulate. Use the lifetime flag so `/new` does not delete a
// worktree that held an earlier session.
if (!hasContent && opts.worktreePath !== undefined && opts.parentRepoPath !== undefined) {
try {
removeWorktree(opts.parentRepoPath, opts.worktreePath);
Comment thread
rrva marked this conversation as resolved.
} catch (cleanupError) {
// Best-effort cleanup only; do not let cleanup failures prevent a clean exit.
log.warn('Failed to clean up git worktree on exit', cleanupError);
}
}

await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
const gutter = ' '.repeat(CHROME_GUTTER);
process.stdout.write(`${gutter}Bye!\n`);
if (sessionId !== '' && hasContent) {
process.stderr.write(`\n${gutter}To resume this session: kimi -r ${sessionId}\n`);
const resumeCommand =
opts.worktreePath !== undefined
? `cd ${quoteShellArg(process.cwd())} && kimi -r ${sessionId}`
: `kimi -r ${sessionId}`;
process.stderr.write(`\n${gutter}To resume this session: ${resumeCommand}\n`);
}
process.exit(exitCode);
};
Expand Down
74 changes: 70 additions & 4 deletions apps/kimi-code/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import type { CLIOptions } from './cli/options';
import { OptionConflictError, validateOptions } from './cli/options';
import { runPrompt } from './cli/run-prompt';
import { runShell } from './cli/run-shell';
import { existsSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import { createWorktree, findGitRoot, removeWorktree, WorktreeError } from './utils/git/worktree';
import { formatStartupError } from './cli/startup-error';
import { runPluginNodeEntry } from './cli/sub/plugin-run-node';
import { handleUpgrade } from './cli/sub/upgrade';
Expand All @@ -38,6 +41,35 @@ import { cleanupStaleNativeCacheForCurrent } from './native/native-assets';
import { installNativeModuleHook } from './native/module-hook';
import { runNativeAssetSmokeIfRequested } from './native/smoke';

function prepareWorktree(worktreeName: string): { worktreePath: string; parentRepoPath: string } {
const cwd = process.cwd();
const repoRoot = findGitRoot(cwd);
if (repoRoot === null) {
throw new WorktreeError('--worktree requires the working directory to be inside a git repository.');
}
const worktreePath = createWorktree(repoRoot, worktreeName || undefined);
const relativeCwd = relative(repoRoot, cwd);
const targetCwd =
relativeCwd.length > 0 && relativeCwd !== '.'
? resolve(worktreePath, relativeCwd)
: worktreePath;

// If the caller was inside an ignored or untracked subdirectory, the
// mirrored path may not exist in the detached worktree. Fall back to the
// worktree root rather than fail after git has already registered the
// worktree.
const effectiveCwd = existsSync(targetCwd) ? targetCwd : worktreePath;
try {
process.chdir(effectiveCwd);
} catch (error) {
removeWorktree(repoRoot, worktreePath);
throw new WorktreeError(
`Failed to enter worktree directory: ${effectiveCwd}. The worktree has been removed.`,
);
}
return { worktreePath, parentRepoPath: repoRoot };
}

export async function handleMainCommand(opts: CLIOptions, version: string): Promise<void> {
let validated: ReturnType<typeof validateOptions>;
try {
Expand All @@ -58,12 +90,46 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom
process.exit(0);
}

if (validated.uiMode === 'print') {
await runPrompt(validated.options, version);
return;
if (opts.worktree !== undefined) {
try {
const { worktreePath, parentRepoPath } = await prepareWorktree(opts.worktree);
Comment thread
rrva marked this conversation as resolved.
opts.worktreePath = worktreePath;
opts.parentRepoPath = parentRepoPath;
} catch (error) {
if (error instanceof WorktreeError) {
process.stderr.write(`error: ${error.message}\n`);
process.exit(1);
}
throw error;
}
}

await runShell(validated.options, version);
try {
if (validated.uiMode === 'print') {
await runPrompt(validated.options, version);
return;
}

await runShell(validated.options, version);
} catch (error) {
// If the shell runner failed during startup after we created a worktree,
// the worktree is still empty (no session ran), so clean it up to avoid
// leaks. Print mode intentionally leaves the worktree inspectable even on
// failure, so we do not clean it up here.
if (
validated.uiMode !== 'print' &&
opts.worktreePath !== undefined &&
opts.parentRepoPath !== undefined
) {
try {
removeWorktree(opts.parentRepoPath, opts.worktreePath);
} catch (cleanupError) {
// Best-effort cleanup only; do not let cleanup failures mask the original error.
log.warn('Failed to clean up git worktree after runner startup failed', cleanupError);
}
}
throw error;
}
}

/** `kimi migrate`: launch the migration screen only, then exit. */
Expand Down
59 changes: 58 additions & 1 deletion apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
ApprovalResponse,
BackgroundTaskInfo,
CreateSessionOptions,
JsonObject,
KimiHarness,
PermissionMode,
PromptPart,
Expand Down Expand Up @@ -195,6 +196,35 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
};
}

function buildSessionMetadata(cliOptions: CLIOptions): JsonObject | undefined {
if (cliOptions.worktreePath === undefined || cliOptions.parentRepoPath === undefined) {
return undefined;
}
return {
worktreePath: cliOptions.worktreePath,
parentRepoPath: cliOptions.parentRepoPath,
};
}

// Recover the worktree metadata carried by an existing session so a replacement
// session (e.g. from /new) stays in the same worktree context. Resuming a
// worktree session via `-r <id>` carries no --worktree CLI flags, so
// startup.metadata is undefined; without this the new session would lose its
// worktreePath/parentRepoPath and agents/subagents would drop the worktree
// system-prompt context. Mirrors the flat shape buildSessionMetadata produces.
function worktreeMetadataFromSession(session: Session | undefined): JsonObject | undefined {
const metadata = session?.summary?.metadata;
if (metadata === undefined) {
return undefined;
}
const worktreePath = metadata['worktreePath'];
const parentRepoPath = metadata['parentRepoPath'];
if (typeof worktreePath !== 'string' || typeof parentRepoPath !== 'string') {
return undefined;
}
return { worktreePath, parentRepoPath };
}

interface SendMessageOptions {
readonly parts?: readonly PromptPart[];
readonly imageAttachmentIds?: readonly number[];
Expand Down Expand Up @@ -228,6 +258,7 @@ export class KimiTUI {
private startupNotice: string | undefined;
private lastActivityMode: string | undefined;
private lastHistoryContent: string | undefined;
private everHadSessionContent = false;
readonly streamingUI: StreamingUIController;
readonly authFlow: AuthFlowController;
readonly btwPanelController: BtwPanelController;
Expand Down Expand Up @@ -268,6 +299,7 @@ export class KimiTUI {
plan: startupInput.cliOptions.plan,
model: startupInput.cliOptions.model,
startupNotice: startupInput.startupNotice,
metadata: buildSessionMetadata(startupInput.cliOptions),
Comment thread
rrva marked this conversation as resolved.
},
};
this.options = tuiOptions;
Expand Down Expand Up @@ -562,6 +594,7 @@ export class KimiTUI {
model: startup.model,
permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined,
planMode: startup.plan ? true : undefined,
metadata: startup.metadata,
};

try {
Expand Down Expand Up @@ -1000,6 +1033,7 @@ export class KimiTUI {

pushTranscriptEntry(entry: TranscriptEntry): void {
this.state.transcriptEntries.push(entry);
this.recordSessionContent();
}

setExternalEditorRunning(running: boolean): void {
Expand All @@ -1023,7 +1057,27 @@ export class KimiTUI {
}

hasSessionContent(): boolean {
return this.state.transcriptEntries.length > 0;
const hasContent = this.state.transcriptEntries.length > 0;
if (hasContent) {
this.recordSessionContent();
}
return hasContent;
}

private recordSessionContent(): void {
this.everHadSessionContent = true;
}

/**
* Whether any session in this TUI lifetime has had transcript content.
*
* Used for worktree cleanup: after `/new` the current session may be empty,
* but we must not delete a worktree that held an earlier session.
*/
hasEverHadSessionContent(): boolean {
// Refresh the flag in case content was added since the last call.
this.hasSessionContent();
Comment thread
rrva marked this conversation as resolved.
return this.everHadSessionContent;
}

async getStartupMcpMs(): Promise<number> {
Expand Down Expand Up @@ -1087,6 +1141,7 @@ export class KimiTUI {
this.session === undefined ? undefined : this.state.appState.thinking ? 'on' : 'off',
permission: this.state.appState.permissionMode,
planMode: this.state.appState.planMode ? true : undefined,
metadata: this.options.startup.metadata ?? worktreeMetadataFromSession(this.session),
});
}

Expand Down Expand Up @@ -1465,6 +1520,7 @@ export class KimiTUI {

appendTranscriptEntry(entry: TranscriptEntry): void {
this.state.transcriptEntries.push(entry);
this.recordSessionContent();
const component = this.createTranscriptComponent(entry);
if (component) {
markTranscriptComponent(component, entry);
Expand Down Expand Up @@ -1519,6 +1575,7 @@ export class KimiTUI {
}

private clearTranscriptAndRedraw(): void {
this.recordSessionContent();
this.streamingUI.discardPending();
this.state.transcriptEntries = [];
this.streamingUI.disposeActiveCompactionBlock();
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
GoalChange,
GoalSnapshot,
JsonObject,
ModelAlias,
PermissionMode,
ProviderConfig,
Expand Down Expand Up @@ -199,6 +200,7 @@ export interface TUIStartupOptions {
readonly plan: boolean;
readonly model?: string;
readonly startupNotice?: string;
readonly metadata?: JsonObject;
}

export type TUIStartupState = 'pending' | 'ready' | 'picker';
Expand Down
Loading