Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b7db693
feat(kimi-code): add -w, --worktree [name] flag for isolated sessions
rrva Jun 16, 2026
3974aa3
docs(reference): document -w, --worktree flag in EN and ZH
rrva Jun 16, 2026
c61f5d5
feat(kimi-code): add bundled word lists for --worktree auto-generated…
rrva Jun 16, 2026
3bbf152
fix(kimi-code): harden --worktree startup cleanup and name validation
rrva Jun 16, 2026
a74eb19
fix(kimi-code): correct --worktree cleanup and prepareWorktree signature
rrva Jun 16, 2026
91be1d7
feat(agent-core): surface worktree metadata in agent system prompt
rrva Jun 16, 2026
6a86ada
fix(kimi-code): address Codex review feedback on --worktree flag
rrva Jun 16, 2026
ab83d9f
fix(kimi-code): preserve worktree metadata for /new sessions
rrva Jun 16, 2026
4710074
fix(kimi-code): address second round of Codex review feedback
rrva Jun 16, 2026
42902f6
fix(kimi-code): address third round of Codex review feedback
rrva Jun 16, 2026
950156e
feat(kimi-code): add -w, --worktree [name] flag for isolated sessions
rrva Jun 16, 2026
b8a2539
docs(reference): document -w, --worktree flag in EN and ZH
rrva Jun 16, 2026
751c395
feat(kimi-code): add bundled word lists for --worktree auto-generated…
rrva Jun 16, 2026
389a09b
fix(kimi-code): harden --worktree startup cleanup and name validation
rrva Jun 16, 2026
dfd32f2
fix(kimi-code): correct --worktree cleanup and prepareWorktree signature
rrva Jun 16, 2026
d24988a
feat(agent-core): surface worktree metadata in agent system prompt
rrva Jun 16, 2026
a80cdc2
fix(kimi-code): address Codex review feedback on --worktree flag
rrva Jun 16, 2026
b1e69b4
fix(kimi-code): preserve worktree metadata for /new sessions
rrva Jun 16, 2026
3c0958f
fix(kimi-code): address second round of Codex review feedback
rrva Jun 16, 2026
5f4ea9f
fix(kimi-code): address third round of Codex review feedback
rrva Jun 16, 2026
35f9612
Merge branch 'main' into feat/worktree-cli-flag
rrva Jun 18, 2026
8e4c290
Merge branch 'fork/feat/worktree-cli-flag' into feat/worktree-cli-flag
rrva Jun 18, 2026
0379c3a
Merge branch 'main' into feat/worktree-cli-flag
rrva Jun 22, 2026
3cea709
Merge branch 'main' into feat/worktree-cli-flag
rrva Jun 23, 2026
e60c17f
Merge branch 'main' into feat/worktree-cli-flag
rrva Jun 25, 2026
44b1296
refactor(kimi-code): isolate --worktree into feature modules
rrva Jun 25, 2026
49fa0b2
fix(kimi-code): address worktree review findings
rrva Jun 26, 2026
2a6e0aa
Merge remote-tracking branch 'origin/main' into feat/worktree-cli-flag
rrva Jun 26, 2026
f04656d
Merge branch 'main' into feat/worktree-cli-flag
rrva Jun 30, 2026
043de86
Merge branch 'main' into feat/worktree-cli-flag
rrva Jul 1, 2026
9cd5098
Merge branch 'main' into feat/worktree-cli-flag
rrva Jul 2, 2026
180c924
Merge remote-tracking branch 'origin/main' into feat/worktree-cli-flag
rrva Jul 3, 2026
b2a5dd9
Merge branch 'main' into feat/worktree-cli-flag
rrva Jul 5, 2026
7c4b24d
Merge branch 'main' into feat/worktree-cli-flag
rrva Jul 7, 2026
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 @@ -84,7 +84,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 @@ -123,6 +129,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'] === true || raw['C'] === true,
Expand All @@ -133,6 +142,7 @@ export function createProgram(
outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
prompt: raw['prompt'] as string | undefined,
skillsDirs: raw['skillsDir'] as string[],
worktree: worktreeValue,
addDirs: raw['addDir'] as string[],
};

Expand Down
7 changes: 7 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,7 @@ export interface CLIOptions {
outputFormat: PromptOutputFormat | undefined;
prompt: string | undefined;
skillsDirs: string[];
worktree?: string;
addDirs?: string[];
}

Expand Down Expand Up @@ -56,5 +57,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' };
}
19 changes: 19 additions & 0 deletions apps/kimi-code/src/cli/resume-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { quoteShellArg } from '#/utils/shell-quote';

/**
* Formats the `kimi -r <id>` command shown to resume a session.
*
* When the session ran in a worktree, the command is prefixed with a `cd` into
* that directory so resuming lands in the same checkout. Centralized here so
* the prompt and shell runners format resume output identically and future
* resume-output work touches one place.
*/
export function formatResumeCommand(
sessionId: string,
options: { readonly cwd?: string } = {},
): string {
const { cwd } = options;
return cwd !== undefined
? `cd ${quoteShellArg(cwd)} && kimi -r ${sessionId}`
: `kimi -r ${sessionId}`;
}
27 changes: 25 additions & 2 deletions apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
} from './goal-prompt';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
import { createKimiCodeHostIdentity } from './version';
import { formatResumeCommand } from './resume-hint';
import { metadataFromWorktree, type WorktreeRuntime } from './worktree-runtime';

/**
* Await `promise`, but stop waiting after `timeoutMs`.
Expand Down Expand Up @@ -82,6 +84,14 @@ interface PromptRunIO {
readonly stdout?: PromptOutput;
readonly stderr?: PromptOutput;
readonly process?: PromptProcess;
/** The git worktree this prompt session runs in, when launched with --worktree. */
readonly worktree?: WorktreeRuntime;
/**
* Called once the prompt session has been created or resumed. Lets the caller
* distinguish a startup failure (before any session exists) from a later turn
* failure, e.g. to decide whether an empty worktree should be cleaned up.
*/
readonly onSessionCreated?: () => void;
}

interface PromptProcess {
Expand All @@ -104,6 +114,7 @@ export async function runPrompt(
const stdout = io.stdout ?? process.stdout;
const stderr = io.stderr ?? process.stderr;
const promptProcess = io.process ?? process;
const worktree = io.worktree;
const workDir = process.cwd();
const telemetryBootstrap = createCliTelemetryBootstrap();
const telemetryClient: TelemetryClient = {
Expand Down Expand Up @@ -169,11 +180,14 @@ export async function runPrompt(
workDir,
config.defaultModel,
stderr,
worktree,
(restorePermission) => {
restorePromptSessionPermission = restorePermission;
},
);
restorePromptSessionPermission = restorePermission;
// A session now exists; a later turn failure must not delete its worktree.
io.onSessionCreated?.();

initializeCliTelemetry({
harness,
Expand All @@ -197,7 +211,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, worktree !== undefined ? workDir : undefined);

withTelemetryContext({ sessionId: session.id }).track('exit', {
duration_ms: Date.now() - startedAt,
Expand Down Expand Up @@ -265,6 +279,7 @@ async function resolvePromptSession(
workDir: string,
defaultModel: string | undefined,
stderr: PromptOutput,
worktree: WorktreeRuntime | undefined,
setRestorePermission: (restorePermission: () => Promise<void>) => void,
): Promise<ResolvedPromptSession> {
if (opts.session !== undefined) {
Expand Down Expand Up @@ -337,10 +352,17 @@ async function resolvePromptSession(
}

const model = requireConfiguredModel(opts.model, defaultModel);
const metadata = metadataFromWorktree(worktree);
// 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,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
drainAgentTasksOnStop: true,
});
Expand Down Expand Up @@ -654,8 +676,9 @@ function writeResumeHint(
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
workDir?: string,
): void {
const command = `kimi -r ${sessionId}`;
const command = formatResumeCommand(sessionId, { cwd: workDir });
const content = `To resume this session: ${command}`;
if (outputFormat === 'stream-json') {
const message: PromptJsonResumeMetaMessage = {
Expand Down
25 changes: 22 additions & 3 deletions apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,17 @@ import { restoreTerminalModes } from '#/utils/terminal-restore';
import type { CLIOptions } from './options';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
import { createKimiCodeHostIdentity } from './version';
import { formatResumeCommand } from './resume-hint';
import {
cleanupEmptyWorktree,
metadataFromWorktree,
type WorktreeRuntime,
} from './worktree-runtime';

export async function runShell(
opts: CLIOptions,
version: string,
runOptions: { readonly migrateOnly?: boolean } = {},
runOptions: { readonly migrateOnly?: boolean; readonly worktree?: WorktreeRuntime } = {},
): Promise<void> {
const startedAt = Date.now();
const configStartedAt = startedAt;
Expand Down Expand Up @@ -100,6 +106,7 @@ export async function runShell(
configWarning = combineStartupNotice(configWarning, warning);
}
const configMs = Date.now() - configStartedAt;
const worktree = runOptions.worktree;
const tui = new KimiTUI(harness, {
cliOptions: opts,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
Expand All @@ -109,6 +116,7 @@ export async function runShell(
startupNotice: configWarning,
migrationPlan,
migrateOnly: runOptions.migrateOnly,
sessionMetadata: metadataFromWorktree(worktree),
});

initializeCliTelemetry({
Expand Down Expand Up @@ -190,15 +198,26 @@ 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_ms: Date.now() - startedAt });

// 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) {
cleanupEmptyWorktree(worktree);
}

await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
const gutter = ' '.repeat(CHROME_GUTTER);
process.stdout.write(`${gutter}Bye!\n`);
const hints: string[] = [];
if (sessionId !== '' && hasContent) {
hints.push(`${gutter}To resume this session: kimi -r ${sessionId}`);
const resumeCommand = formatResumeCommand(sessionId, {
cwd: worktree !== undefined ? process.cwd() : undefined,
});
hints.push(`${gutter}To resume this session: ${resumeCommand}`);
}
if (tui.exitOpenUrl !== undefined) {
hints.push(`${gutter}open ${toTerminalHyperlink(tui.exitOpenUrl, tui.exitOpenUrl)}`);
Expand Down
98 changes: 98 additions & 0 deletions apps/kimi-code/src/cli/worktree-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Runtime lifecycle for the `--worktree` flag.
*
* Keeps worktree creation, cleanup, and session-metadata derivation in one
* feature module so the central CLI files (`main.ts`, `run-prompt.ts`,
* `run-shell.ts`) only carry one-line integration hooks. This narrows the
* surface that collides when those churn-heavy files change for unrelated
* reasons.
*/

import { existsSync } from 'node:fs';
import { relative, resolve } from 'node:path';

import type { JsonObject } from '@moonshot-ai/kimi-code-sdk';
import { log } from '@moonshot-ai/kimi-code-sdk';

import { createWorktree, findGitRoot, removeWorktree, WorktreeError } from '#/utils/git/worktree';

/**
* The live worktree a session runs in. Unlike the parsed CLI options, this is
* runtime state produced after git has registered the worktree and the process
* has entered it, so it is threaded as an explicit execution context rather
* than stored back on `CLIOptions`.
*/
export interface WorktreeRuntime {
readonly worktreePath: string;
readonly parentRepoPath: string;
/** The directory the process changed into (the worktree-relative cwd). */
readonly effectiveCwd: string;
}

/**
* Creates the git worktree for `--worktree [name]` and enters it.
*
* Throws {@link WorktreeError} when the cwd is not inside a git repository or
* when entering the created worktree fails (in which case the worktree is
* removed again before throwing). On success the process cwd has been changed
* to {@link WorktreeRuntime.effectiveCwd}.
*/
export function prepareWorktreeRuntime(worktreeName: string): WorktreeRuntime {
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 {
removeWorktree(repoRoot, worktreePath);
throw new WorktreeError(
`Failed to enter worktree directory: ${effectiveCwd}. The worktree has been removed.`,
);
}
return { worktreePath, parentRepoPath: repoRoot, effectiveCwd };
}

/**
* Removes a worktree that never held session content. Best-effort: a cleanup
* failure is logged but never surfaced, so it cannot mask the original error
* path that triggered the cleanup. A no-op when no worktree is active.
*/
export function cleanupEmptyWorktree(runtime: WorktreeRuntime | undefined): void {
if (runtime === undefined) {
return;
}
try {
removeWorktree(runtime.parentRepoPath, runtime.worktreePath);
} catch (cleanupError) {
log.warn('Failed to clean up git worktree', cleanupError);
}
}

/**
* The flat session metadata persisted for a worktree session, or `undefined`
* when no worktree is active. Mirrors the shape recovered from existing
* sessions so a `/new` replacement stays in the same worktree context.
*/
export function metadataFromWorktree(runtime: WorktreeRuntime | undefined): JsonObject | undefined {
if (runtime === undefined) {
return undefined;
}
return {
worktreePath: runtime.worktreePath,
parentRepoPath: runtime.parentRepoPath,
};
}
48 changes: 43 additions & 5 deletions apps/kimi-code/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ 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 {
cleanupEmptyWorktree,
prepareWorktreeRuntime,
type WorktreeRuntime,
} from './cli/worktree-runtime';
import { 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 Down Expand Up @@ -74,13 +80,45 @@ export async function handleMainCommand(
process.exit(0);
}

if (validated.uiMode === 'print') {
await runPrompt(validated.options, version);
return { headlessCompleted: true };
let worktree: WorktreeRuntime | undefined;
if (opts.worktree !== undefined) {
try {
worktree = prepareWorktreeRuntime(opts.worktree);
} catch (error) {
if (error instanceof WorktreeError) {
process.stderr.write(`error: ${error.message}\n`);
process.exit(1);
}
throw error;
}
}

await runShell(validated.options, version);
return { headlessCompleted: false };
let promptSessionCreated = false;
try {
if (validated.uiMode === 'print') {
await runPrompt(validated.options, version, {
worktree,
onSessionCreated: () => {
promptSessionCreated = true;
},
});
return { headlessCompleted: true };
}

await runShell(validated.options, version, { worktree });
return { headlessCompleted: false };
} catch (error) {
// Clean up a worktree that never carried a session so failed startups do
// not leak empty worktrees. The shell runner only fails before a session
// has content, so any failure there is safe to clean up. Print mode is
// different: once a prompt session exists it is documented to leave the
// worktree inspectable, so only clean up when the failure happened before
// session creation.
if (validated.uiMode !== 'print' || !promptSessionCreated) {
cleanupEmptyWorktree(worktree);
}
throw error;
}
}

/** `kimi migrate`: launch the migration screen only, then exit. */
Expand Down
Loading