Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/add-worktree-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add `-w, --worktree [name]` flag to create a new git worktree for the session.
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' };
}
6 changes: 5 additions & 1 deletion apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,11 @@ 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;
const session = await harness.createSession({ workDir, model, permission: 'auto', metadata });
installHeadlessHandlers(session);
return {
session,
Expand Down
13 changes: 13 additions & 0 deletions apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { execSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';

import { removeWorktree } from '#/utils/git/worktree';

import {
setCrashPhase,
setTelemetryContext,
Expand Down Expand Up @@ -136,6 +138,17 @@ export async function runShell(
const hasContent = tui.hasSessionContent();
setCrashPhase('shutdown');
trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 });

// Clean up the git worktree for empty sessions so abandoned worktrees
// do not accumulate.
if (!hasContent && opts.worktreePath !== undefined && opts.parentRepoPath !== undefined) {
try {
removeWorktree(opts.parentRepoPath, opts.worktreePath);
Comment thread
rrva marked this conversation as resolved.
Outdated
} catch {
// Best-effort cleanup only.
}
}

await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
const gutter = ' '.repeat(CHROME_GUTTER);
process.stdout.write(`${gutter}Bye!\n`);
Expand Down
31 changes: 31 additions & 0 deletions apps/kimi-code/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ 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 { createWorktree, findGitRoot, 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 +39,21 @@ import { cleanupStaleNativeCacheForCurrent } from './native/native-assets';
import { installNativeModuleHook } from './native/module-hook';
import { runNativeAssetSmokeIfRequested } from './native/smoke';

// Defensive guard: if handleMainCommand is ever re-entered (e.g. reload),
// do not create a nested worktree.
let worktreeCreated = false;

async function prepareWorktree(worktreeName: string): Promise<{ 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);
process.chdir(worktreePath);
Comment thread
rrva marked this conversation as resolved.
Outdated
Comment thread
rrva marked this conversation as resolved.
Outdated
return { worktreePath, parentRepoPath: repoRoot };
}

export async function handleMainCommand(opts: CLIOptions, version: string): Promise<void> {
let validated: ReturnType<typeof validateOptions>;
try {
Expand All @@ -50,6 +66,21 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom
throw error;
}

if (opts.worktree !== undefined && !worktreeCreated) {
try {
const { worktreePath, parentRepoPath } = await prepareWorktree(opts.worktree);
Comment thread
rrva marked this conversation as resolved.
Outdated
opts.worktreePath = worktreePath;
opts.parentRepoPath = parentRepoPath;
worktreeCreated = true;
} catch (error) {
if (error instanceof WorktreeError) {
process.stderr.write(`error: ${error.message}\n`);
process.exit(1);
}
throw error;
}
}

const preflightResult = await runUpdatePreflight(
version,
validated.uiMode === 'print' ? { track, isTTY: false } : { track },
Expand Down
13 changes: 13 additions & 0 deletions 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,16 @@ 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,
};
}

interface SendMessageOptions {
readonly parts?: readonly PromptPart[];
readonly imageAttachmentIds?: readonly number[];
Expand Down Expand Up @@ -268,6 +279,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.
Outdated
},
};
this.options = tuiOptions;
Expand Down Expand Up @@ -562,6 +574,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
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
157 changes: 157 additions & 0 deletions apps/kimi-code/src/utils/git/worktree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* Git worktree management for isolated agent sessions.
*
* Mirrors the upstream kimi-cli worktree feature:
* - Worktrees are created under <repo-root>/.kimi/worktrees/<name>
* - Default name is kimi-<timestamp>
* - Default checkout is detached HEAD at current HEAD
*/

import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';

const GIT_TIMEOUT_MS = 30_000;
const WORKTREE_SUBDIR = '.kimi/worktrees';
Comment thread
rrva marked this conversation as resolved.

export class WorktreeError extends Error {
constructor(
message: string,
readonly stderr?: string,
) {
super(message);
this.name = 'WorktreeError';
}
}

export interface WorktreeInfo {
readonly path: string;
readonly branch?: string;
}

function runGit(cwd: string, args: readonly string[]): { stdout: string; stderr: string; status: number | null } {
const result = spawnSync('git', ['-C', cwd, ...args], {
encoding: 'utf8',
timeout: GIT_TIMEOUT_MS,
});
return {
stdout: result.stdout?.trim() ?? '',
stderr: result.stderr?.trim() ?? '',
status: result.status,
};
}

export function findGitRoot(cwd: string): string | null {
const { stdout, status } = runGit(cwd, ['rev-parse', '--show-toplevel']);
if (status !== 0 || stdout.length === 0) {
return null;
}
return resolve(stdout);
}

function isInsideGitRepo(cwd: string): boolean {
const { stdout, status } = runGit(cwd, ['rev-parse', '--is-inside-work-tree']);
return status === 0 && stdout === 'true';
}

function generateDefaultWorktreeName(): string {
const now = new Date();
const yyyy = String(now.getUTCFullYear());
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
const dd = String(now.getUTCDate()).padStart(2, '0');
const hh = String(now.getUTCHours()).padStart(2, '0');
const min = String(now.getUTCMinutes()).padStart(2, '0');
const ss = String(now.getUTCSeconds()).padStart(2, '0');
return `kimi-${yyyy}${mm}${dd}-${hh}${min}${ss}`;
}

export function createWorktree(repoRoot: string, name?: string): string {
if (!isInsideGitRepo(repoRoot)) {
throw new WorktreeError(`Not a git repository: ${repoRoot}`);
}

const worktreeName = name && name.trim().length > 0 ? name.trim() : generateDefaultWorktreeName();
const worktreesDir = resolve(repoRoot, WORKTREE_SUBDIR);
const worktreePath = resolve(worktreesDir, worktreeName);

if (resolve(worktreePath) === resolve(repoRoot)) {
throw new WorktreeError(`Worktree path cannot be the repository root: ${worktreePath}`);
}

// git worktree add will fail if the path already exists, but check early
// to give a clearer error and avoid partial git state.
if (existsSync(worktreePath)) {
throw new WorktreeError(
`Worktree directory already exists: ${worktreePath}\n` +
'Use --worktree to choose a different name, or remove the existing directory.',
);
}

// Ensure parent directory exists; git does not create nested parent dirs.
mkdirSync(worktreesDir, { recursive: true });

const { stderr, status } = runGit(repoRoot, ['worktree', 'add', '--detach', worktreePath]);
if (status !== 0) {
// Clean up partial directory if git created it
if (existsSync(worktreePath)) {
rmSync(worktreePath, { recursive: true, force: true });
}
throw new WorktreeError(
`Failed to create git worktree at ${worktreePath}${stderr ? `\n${stderr}` : ''}`,
stderr,
);
}

return worktreePath;
}

export function removeWorktree(repoRoot: string, worktreePath: string): void {
const canonicalRepoRoot = findGitRoot(repoRoot);
if (canonicalRepoRoot === null) {
// Repository is gone; best-effort remove the directory itself.
rmSync(worktreePath, { recursive: true, force: true });
return;
}

const { stderr, status } = runGit(canonicalRepoRoot, ['worktree', 'remove', worktreePath]);
if (status !== 0) {
// Git may complain if the worktree is not registered; fall back to rm.
rmSync(worktreePath, { recursive: true, force: true });
Comment thread
rrva marked this conversation as resolved.
Outdated
// Only surface an error if the directory is still there after fallback.
if (existsSync(worktreePath)) {
throw new WorktreeError(
`Failed to remove worktree at ${worktreePath}${stderr ? `\n${stderr}` : ''}`,
stderr,
);
}
}

// Prune stale worktree metadata (best-effort).
runGit(canonicalRepoRoot, ['worktree', 'prune']);
}

export function listWorktrees(repoRoot: string): WorktreeInfo[] {
const { stdout, status } = runGit(repoRoot, ['worktree', 'list', '--porcelain']);
if (status !== 0) {
return [];
}

const worktrees: WorktreeInfo[] = [];
let current: { path?: string; branch?: string } = {};
for (const line of stdout.split('\n')) {
if (line.startsWith('worktree ')) {
if (current.path !== undefined) {
worktrees.push({ path: current.path, branch: current.branch });
}
current = { path: line.slice('worktree '.length).trim() };
} else if (line.startsWith('branch ')) {
current.branch = line.slice('branch '.length).trim();
} else if (line === 'detached') {
current.branch = '(detached HEAD)';
}
}
if (current.path !== undefined) {
worktrees.push({ path: current.path, branch: current.branch });
}
return worktrees;
}
1 change: 1 addition & 0 deletions apps/kimi-code/test/cli/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ function defaultOpts(): CLIOptions {
outputFormat: undefined,
prompt: undefined,
skillsDirs: [],
worktree: undefined,
};
}

Expand Down
Loading