From b7db6935780b06fb86948b0cb45f1e6400e01ae8 Mon Sep 17 00:00:00 2001 From: Ragnar Rova Date: Tue, 16 Jun 2026 13:30:31 +0200 Subject: [PATCH 01/10] feat(kimi-code): add -w, --worktree [name] flag for isolated sessions Add a CLI flag that creates a new git worktree under /.kimi/worktrees/ and runs the session inside it. When no name is given, a kimi- name is generated. The worktree is created in detached HEAD at the current HEAD. - New worktree utility module with create/remove/list/find helpers. - Conflict validation against --session and --continue. - Persist worktree_path and parent_repo_path in session metadata. - Clean up the worktree on exit when the session is empty. - Add CLI parsing tests and worktree utility unit tests. Inspired by the upstream kimi-cli worktree feature. --- .changeset/add-worktree-flag.md | 5 + apps/kimi-code/src/cli/commands.ts | 12 +- apps/kimi-code/src/cli/options.ts | 11 ++ apps/kimi-code/src/cli/run-prompt.ts | 6 +- apps/kimi-code/src/cli/run-shell.ts | 13 ++ apps/kimi-code/src/main.ts | 31 ++++ apps/kimi-code/src/tui/kimi-tui.ts | 13 ++ apps/kimi-code/src/tui/types.ts | 2 + apps/kimi-code/src/utils/git/worktree.ts | 157 ++++++++++++++++++ apps/kimi-code/test/cli/main.test.ts | 1 + apps/kimi-code/test/cli/options.test.ts | 40 +++++ .../kimi-code/test/utils/git/worktree.test.ts | 115 +++++++++++++ 12 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 .changeset/add-worktree-flag.md create mode 100644 apps/kimi-code/src/utils/git/worktree.ts create mode 100644 apps/kimi-code/test/utils/git/worktree.test.ts diff --git a/.changeset/add-worktree-flag.md b/.changeset/add-worktree-flag.md new file mode 100644 index 000000000..8e51fbe1e --- /dev/null +++ b/.changeset/add-worktree-flag.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add `-w, --worktree [name]` flag to create a new git worktree for the session. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index bdc886c4d..2290e3908 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -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); @@ -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, @@ -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); diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index 98f4cb196..320ef5232 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -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 { @@ -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' }; } diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index f7cef067d..c643941fc 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -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, diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index e5bdfef24..5e18b4d2c 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -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, @@ -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); + } catch { + // Best-effort cleanup only. + } + } + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); const gutter = ' '.repeat(CHROME_GUTTER); process.stdout.write(`${gutter}Bye!\n`); diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index e94472590..9a184d6b5 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -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'; @@ -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); + return { worktreePath, parentRepoPath: repoRoot }; +} + export async function handleMainCommand(opts: CLIOptions, version: string): Promise { let validated: ReturnType; try { @@ -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); + 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 }, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 330f9c7f1..968b7ccbd 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -14,6 +14,7 @@ import type { ApprovalResponse, BackgroundTaskInfo, CreateSessionOptions, + JsonObject, KimiHarness, PermissionMode, PromptPart, @@ -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[]; @@ -268,6 +279,7 @@ export class KimiTUI { plan: startupInput.cliOptions.plan, model: startupInput.cliOptions.model, startupNotice: startupInput.startupNotice, + metadata: buildSessionMetadata(startupInput.cliOptions), }, }; this.options = tuiOptions; @@ -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 { diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 6b407f777..47e3b4679 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -1,6 +1,7 @@ import type { GoalChange, GoalSnapshot, + JsonObject, ModelAlias, PermissionMode, ProviderConfig, @@ -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'; diff --git a/apps/kimi-code/src/utils/git/worktree.ts b/apps/kimi-code/src/utils/git/worktree.ts new file mode 100644 index 000000000..2e8af0447 --- /dev/null +++ b/apps/kimi-code/src/utils/git/worktree.ts @@ -0,0 +1,157 @@ +/** + * Git worktree management for isolated agent sessions. + * + * Mirrors the upstream kimi-cli worktree feature: + * - Worktrees are created under /.kimi/worktrees/ + * - Default name is kimi- + * - 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'; + +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 }); + // 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; +} diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index 52aba94b1..0f34b71a6 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -144,6 +144,7 @@ function defaultOpts(): CLIOptions { outputFormat: undefined, prompt: undefined, skillsDirs: [], + worktree: undefined, }; } diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index f4fb7d7e9..2d91d8daa 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -41,6 +41,7 @@ describe('CLI options parsing', () => { expect(opts.outputFormat).toBeUndefined(); expect(opts.prompt).toBeUndefined(); expect(opts.skillsDirs).toEqual([]); + expect(opts.worktree).toBeUndefined(); }); }); @@ -167,6 +168,45 @@ describe('CLI options parsing', () => { }); }); + describe('--worktree', () => { + it('parses --worktree with an explicit name', () => { + const opts = parse(['--worktree', 'my-fix']); + expect(opts.worktree).toBe('my-fix'); + }); + + it('parses --worktree=value with an explicit name', () => { + const opts = parse(['--worktree=my-fix']); + expect(opts.worktree).toBe('my-fix'); + }); + + it('parses -w with an explicit name', () => { + const opts = parse(['-w', 'my-fix']); + expect(opts.worktree).toBe('my-fix'); + }); + + it('bare --worktree yields empty string for auto-naming', () => { + const opts = parse(['--worktree']); + expect(opts.worktree).toBe(''); + }); + + it('bare -w yields empty string for auto-naming', () => { + const opts = parse(['-w']); + expect(opts.worktree).toBe(''); + }); + + it('rejects --worktree combined with --session', () => { + const opts = parse(['--worktree', 'my-fix', '--session', 'ses_123']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Cannot combine --worktree with --session.'); + }); + + it('rejects --worktree combined with --continue', () => { + const opts = parse(['--worktree', 'my-fix', '--continue']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Cannot combine --worktree with --continue.'); + }); + }); + describe('--auto / --yolo / --plan with --session / --continue', () => { it('allows --auto with --continue', () => { const opts = parse(['--auto', '--continue']); diff --git a/apps/kimi-code/test/utils/git/worktree.test.ts b/apps/kimi-code/test/utils/git/worktree.test.ts new file mode 100644 index 000000000..2573e1702 --- /dev/null +++ b/apps/kimi-code/test/utils/git/worktree.test.ts @@ -0,0 +1,115 @@ +import { execSync } from 'node:child_process'; +import { existsSync, mkdtempSync, realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { createWorktree, findGitRoot, listWorktrees, removeWorktree, WorktreeError } from '#/utils/git/worktree'; + +function initRepo(path: string): void { + execSync('git init', { cwd: path, stdio: 'ignore' }); + execSync('git config user.email "test@example.com"', { cwd: path, stdio: 'ignore' }); + execSync('git config user.name "Test"', { cwd: path, stdio: 'ignore' }); + execSync('git commit --allow-empty -m "initial"', { cwd: path, stdio: 'ignore' }); +} + +function makeTempDir(prefix: string): string { + return realpathSync(mkdtempSync(join(tmpdir(), prefix))); +} + +describe('findGitRoot', () => { + it('returns null outside a git repository', () => { + const dir = makeTempDir('kimi-not-git-'); + expect(findGitRoot(dir)).toBeNull(); + }); + + it('finds the repo root from the repo root', () => { + const dir = makeTempDir('kimi-git-root-'); + initRepo(dir); + expect(findGitRoot(dir)).toBe(dir); + }); + + it('finds the repo root from a subdirectory', () => { + const dir = makeTempDir('kimi-git-sub-'); + initRepo(dir); + const subdir = join(dir, 'a', 'b'); + execSync('mkdir -p a/b', { cwd: dir, stdio: 'ignore' }); + expect(findGitRoot(subdir)).toBe(dir); + }); +}); + +describe('createWorktree', () => { + it('creates a detached worktree with the given name', () => { + const dir = makeTempDir('kimi-create-wt-'); + initRepo(dir); + + const wt = createWorktree(dir, 'feature-x'); + + expect(existsSync(wt)).toBe(true); + expect(wt).toContain(join('.kimi', 'worktrees', 'feature-x')); + const branch = execSync('git branch --show-current', { cwd: wt, encoding: 'utf8', stdio: 'pipe' }); + expect(branch.trim()).toBe(''); + }); + + it('auto-generates a kimi-prefixed name when none is given', () => { + const dir = makeTempDir('kimi-auto-wt-'); + initRepo(dir); + + const wt = createWorktree(dir); + + expect(existsSync(wt)).toBe(true); + const baseName = wt.split('/').pop(); + expect(baseName).toMatch(/^kimi-\d{8}-\d{6}$/); + }); + + it('raises when the worktree directory already exists', () => { + const dir = makeTempDir('kimi-dup-wt-'); + initRepo(dir); + createWorktree(dir, 'dup'); + + expect(() => createWorktree(dir, 'dup')).toThrow(WorktreeError); + expect(() => createWorktree(dir, 'dup')).toThrow('already exists'); + }); + + it('raises outside a git repository', () => { + const dir = makeTempDir('kimi-no-git-'); + expect(() => createWorktree(dir, 'x')).toThrow(WorktreeError); + }); +}); + +describe('removeWorktree', () => { + it('removes a created worktree', () => { + const dir = makeTempDir('kimi-rm-wt-'); + initRepo(dir); + const wt = createWorktree(dir, 'to-remove'); + expect(existsSync(wt)).toBe(true); + + removeWorktree(dir, wt); + + expect(existsSync(wt)).toBe(false); + }); + + it('does not throw for a missing worktree path', () => { + const dir = makeTempDir('kimi-rm-missing-'); + initRepo(dir); + const missing = join(dir, '.kimi', 'worktrees', 'ghost'); + + expect(() => removeWorktree(dir, missing)).not.toThrow(); + }); +}); + +describe('listWorktrees', () => { + it('lists created worktrees', () => { + const dir = makeTempDir('kimi-list-wt-'); + initRepo(dir); + const wt1 = createWorktree(dir, 'wt1'); + const wt2 = createWorktree(dir, 'wt2'); + + const list = listWorktrees(dir); + const paths = list.map((w) => w.path); + + expect(paths).toContain(wt1); + expect(paths).toContain(wt2); + }); +}); From 3974aa34a0c3162d37b7f57ed6a18611d151fb76 Mon Sep 17 00:00:00 2001 From: Ragnar Rova Date: Tue, 16 Jun 2026 13:33:40 +0200 Subject: [PATCH 02/10] docs(reference): document -w, --worktree flag in EN and ZH --- docs/en/reference/kimi-command.md | 16 ++++++++++++++++ docs/zh/reference/kimi-command.md | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index a0623445b..8d6faf9c3 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -23,6 +23,7 @@ All flags are optional — run `kimi` directly to enter an interactive session: | `--yolo` | `-y` | Auto-approve regular tool calls, skipping approval requests | | `--auto` | | Start with auto permission mode; tool approvals are handled automatically and the Agent will not ask the user questions | | `--plan` | | Start a new session in Plan mode — the AI will prioritize read-only tools for exploration and planning | +| `--worktree [name]` | `-w` | Create a new git worktree for this session. When no name is given, a `kimi-` name is generated. The worktree is created in detached HEAD at the current commit | | `--skills-dir ` | | Load Skills from the specified directory, replacing the automatically discovered user and project directories. Can be repeated | `-r` / `--resume` is a hidden alias for `--session`; `--yes` and `--auto-approve` are hidden aliases for `--yolo` and are not shown in help output. @@ -38,6 +39,7 @@ The following combinations are rejected at startup: - `--continue` and `--session` are mutually exclusive — both mean "resume a previous session" - `--yolo` and `--auto` are mutually exclusive — the two permission modes cannot be combined - `--prompt` cannot be used with `--yolo`, `--auto`, or `--plan` — non-interactive mode uses `auto` permission by default +- `--worktree` cannot be used with `--session` or `--continue` — a worktree is only created for a new session - `--output-format` can only be used together with `--prompt` When resuming a session, you can override its saved permission or plan mode by adding `--auto`, `--yolo`, or `--plan`. For example, `kimi --continue --auto` resumes the latest session and switches it to auto permission mode. @@ -81,6 +83,20 @@ Read the code and produce an implementation plan before making any file changes: kimi --plan ``` +### Isolated Worktree Sessions + +Start a session in a fresh git worktree to avoid interfering with the main working tree or other active sessions: + +```sh +# Auto-generate a kimi- worktree name +kimi -w + +# Specify a custom worktree name +kimi --worktree refactor-auth +``` + +The worktree is created under `/.kimi/worktrees/` in a detached HEAD checkout at the current commit. Empty worktree sessions are cleaned up automatically on exit; sessions with content are left in place so work is preserved. + ### Custom Skills Directories There are two ways to specify Skills directories, with different semantics: diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 9e8c9180b..af5163c54 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -23,6 +23,7 @@ kimi [options] | `--yolo` | `-y` | 自动批准普通工具调用,跳过审批请求 | | `--auto` | | 以 auto 权限模式启动;工具审批自动处理,Agent 不会向用户提问 | | `--plan` | | 以 Plan 模式启动新会话,AI 会优先使用只读工具进行探索和规划 | +| `--worktree [name]` | `-w` | 为本次会话创建一个新的 git worktree。省略名称时自动生成 `kimi-`;工作区以 detached HEAD 方式基于当前 commit 创建 | | `--skills-dir ` | | 从指定目录加载 Skills,替换自动发现的用户和项目目录。可重复传入 | `-r` / `--resume` 是 `--session` 的隐藏别名;`--yes` 和 `--auto-approve` 是 `--yolo` 的隐藏别名,在帮助信息中不显示。 @@ -38,6 +39,7 @@ kimi [options] - `--continue` 与 `--session` 互斥——两者都表示"恢复历史会话" - `--yolo` 和 `--auto` 互斥——两种权限模式互斥 - `--prompt` 不能与 `--yolo`、`--auto` 或 `--plan` 同时使用——非交互模式固定使用 `auto` 权限 +- `--worktree` 不能与 `--session` 或 `--continue` 同时使用——worktree 仅用于新建会话 - `--output-format` 只能与 `--prompt` 一起使用 恢复会话时,可以通过 `--auto`、`--yolo` 或 `--plan` 覆盖原会话保存的权限或计划模式。例如,`kimi --continue --auto` 会恢复最近会话并切换到 auto 权限模式。 @@ -81,6 +83,20 @@ kimi --auto kimi --plan ``` +### 隔离的 Worktree 会话 + +在全新的 git worktree 中启动会话,避免干扰主工作区或其他正在运行的会话: + +```sh +# 自动生成 kimi- 名称的 worktree +kimi -w + +# 指定自定义 worktree 名称 +kimi --worktree refactor-auth +``` + +工作区创建在 `/.kimi/worktrees/`,以 detached HEAD 方式基于当前 commit 检出。空的 worktree 会话在退出时会自动清理;包含内容的会话会保留,以免丢失工作成果。 + ### 自定义 Skills 目录 有两种方式指定 Skills 目录,语义不同: From c61f5d5d78e7b1ee187bdc743fe0e7d5edd082d4 Mon Sep 17 00:00:00 2001 From: Ragnar Rova Date: Tue, 16 Jun 2026 17:46:07 +0200 Subject: [PATCH 03/10] feat(kimi-code): add bundled word lists for --worktree auto-generated names --- .../src/utils/git/worktree-adjectives.txt | 476 +++++ .../src/utils/git/worktree-nouns.txt | 1666 +++++++++++++++++ .../src/utils/git/worktree-verbs.txt | 1356 ++++++++++++++ 3 files changed, 3498 insertions(+) create mode 100644 apps/kimi-code/src/utils/git/worktree-adjectives.txt create mode 100644 apps/kimi-code/src/utils/git/worktree-nouns.txt create mode 100644 apps/kimi-code/src/utils/git/worktree-verbs.txt diff --git a/apps/kimi-code/src/utils/git/worktree-adjectives.txt b/apps/kimi-code/src/utils/git/worktree-adjectives.txt new file mode 100644 index 000000000..b523402ee --- /dev/null +++ b/apps/kimi-code/src/utils/git/worktree-adjectives.txt @@ -0,0 +1,476 @@ +amber +ancient +autumn +azure +bailan +bengbuzhule +bixie +blazing +blissful +blue +bold +bonny +bouncy +brave +breezy +bright +brisk +broad +bronze +calm +carefree +careful +cedar +charming +cheerful +chigua +chilly +chocolate +cinnamon +circular +citrus +civil +classic +clean +clear +clever +cloudy +cobalt +cold +colorful +colossal +cool +copper +coral +cosmic +cozy +crimson +crystal +cuipi +curious +curly +cyan +dainty +damp +dapper +dappled +daring +dark +dawn +dear +deep +delicate +delightful +dewy +different +digital +dim +direct +distant +docile +dormant +double +dramatic +dry +dusky +dutiful +eager +early +earnest +earthy +easy +ebony +elegant +elemental +emerald +emo +empty +enchanted +endless +energetic +enormous +equal +ethereal +even +evergreen +exact +exotic +fabulous +facai +faint +fair +faithful +familiar +fancy +far +fast +fearless +feathered +fern +fierce +fiery +fine +firm +first +flaming +flat +flawless +fleeting +floral +flowery +fluffy +fluid +foggy +foreign +formal +foxi +fragile +fragrant +free +fresh +friendly +frosted +frosty +fuchsia +full +fuzzy +gallant +gaoqian +gentle +genuine +ghostly +giant +giddy +gifted +gilded +glad +glassy +gleaming +glorious +glossy +golden +good +graceful +gracious +grand +grassy +grateful +gray +great +green +happy +harmonic +hasty +hearty +heather +heavy +hidden +high +hollow +holy +honest +honey +hopeful +humble +humid +hushed +icy +idle +indigo +infinite +innocent +iron +ivory +jade +jasper +jinli +jolly +joyful +joyous +juejuezi +kaibai +keen +kind +lacy +lake +late +lazy +leafy +lean +level +light +lilac +limber +lime +lipu +little +lively +livid +lone +long +loopy +loose +loud +lovely +low +lucid +lucky +lunar +lustrous +magenta +magic +magnetic +majestic +male +maopao +marble +marine +maroon +massive +mauve +meadow +meek +mellow +melodic +merry +mild +milky +mint +misty +modern +modest +moist +mossy +moyu +murmuring +mysterious +mythic +nafu +napping +narrow +natural +navy +neat +neon +new +nice +nimble +noble +noisy +noon +normal +nutmeg +oaken +ocean +odd +olive +opal +open +orange +ordinary +ouqi +oval +pale +palm +paper +parallel +pastel +patient +peach +pearl +perfect +petite +pine +pink +placid +plain +platinum +plump +pofang +polar +polished +polite +poppy +posh +pretty +prim +proud +public +puffy +purple +qianshui +quiet +rad +rapid +rare +rational +ready +real +regal +remote +rich +ripe +rising +roaming +robust +rocky +rolling +rosy +royal +ruby +rustic +sable +sacred +saffron +sage +sandy +sapphire +satin +scarlet +serene +shady +shangan +shangtou +sharp +shekong +sheniu +shesi +shimmering +shining +shiny +short +shy +silent +silky +silver +simple +sincere +sleek +sleepy +slender +slight +slim +slow +small +smart +smiling +smooth +snappy +snowy +soft +solar +solemn +solid +songchigan +spare +sparkling +sparse +spicy +spiral +splendid +spongy +spring +square +stable +starry +static +steadfast +steady +steely +still +stocky +stone +stormy +stout +straight +strange +strong +sturdy +subtle +sudden +sugar +sunny +super +superb +supreme +sure +sweet +swift +tall +tame +tangled +tangping +tart +teal +tender +thankful +thirsty +tidy +tiny +titanic +topaz +tranquil +trim +triple +true +trusting +tutou +twilight +twin +unique +upbeat +urban +usual +valiant +valid +vanilla +vast +velvet +verdant +viable +vibrant +violet +virtual +visible +vital +vivid +wang +warm +wary +watery +wavy +waxen +weak +wealthy +weekly +weighty +western +wet +wheat +whimsical +white +whole +wide +wild +willing +winding +windy +wintry +wise +witty +wonderful +wooden +woody +woolen +woven +xianyanbao +xiao +xiuxian +yearly +yellow +young +yyds +zany +zen +zhenxiang +zhenzhai diff --git a/apps/kimi-code/src/utils/git/worktree-nouns.txt b/apps/kimi-code/src/utils/git/worktree-nouns.txt new file mode 100644 index 000000000..2dff800a9 --- /dev/null +++ b/apps/kimi-code/src/utils/git/worktree-nouns.txt @@ -0,0 +1,1666 @@ +acorn +air +alarm +alley +almond +amber +anchor +angel +angle +animal +ant +anthem +apple +apricot +arch +arena +aria +ark +arm +arrow +ash +atom +audio +aurora +autumn +avenue +avocado +axle +azalea +badge +bagel +baker +ball +ballad +bamboo +banana +band +bank +banner +baozaifan +baozi +bar +bark +barn +baron +basil +basin +basket +bat +bath +beacon +bead +beaker +beam +bean +bear +beard +beast +beat +beauty +beaver +bed +bee +beech +beet +beetle +bell +belt +bench +berry +bihuan +bike +billow +bin +binggan +bingqilin +birch +bird +biscuit +bit +blade +blazer +blimp +blizzard +block +bloom +blossom +blue +bluebell +blur +boar +board +boat +bog +bolt +bone +book +boom +boot +bottle +boulder +bouquet +bow +bowl +box +boy +bracken +braid +brake +branch +brand +brass +bread +breeze +brick +bridge +brook +broom +brush +bubble +bud +buding +buffalo +bug +bugle +building +bull +bump +bunch +bunny +burger +burn +bush +butter +butterfly +button +buzz +cabin +cable +cactus +cake +calf +camel +cameo +camera +camp +can +canal +candle +candy +cane +canoe +canvas +canyon +cape +caper +car +card +cardinal +cargo +carp +carpet +carrot +cart +cascade +case +cash +castle +cat +caterpillar +cavern +cedar +cello +chain +chaiquan +chair +chalk +chamber +champion +chance +changfen +channel +chant +chaos +chapter +charcoal +charger +charm +chart +chasm +cheek +cheese +cherry +chest +chestnut +chick +chief +child +chill +chimney +chin +chip +chive +chocolate +choir +chord +chorus +choudoufu +chrome +chronicle +cider +cinder +circle +circus +city +clam +clan +clap +clarinet +clasp +clay +cliff +climb +clock +cloud +clover +club +clue +coach +coal +coast +coat +cobra +cocoa +code +coffee +coin +cola +cold +color +comet +compass +cone +cook +cookie +cool +copper +coral +cord +cork +corn +corridor +cottage +cotton +couch +council +count +country +court +cousin +cover +cow +crab +crack +cradle +craft +crane +crate +crayon +cream +creature +creek +crest +crew +cricket +crimson +crocus +crook +crop +cross +crow +crown +crumb +crystal +cube +cuckoo +cuff +cup +curb +cure +curl +currant +current +curry +curve +cushion +custard +cyclone +cypress +daei +daffodil +daisy +dale +dam +dance +danchaofan +dandelion +dangao +danta +dark +dart +data +date +dawn +day +deal +deer +degree +den +depth +desert +desk +dew +diamond +dice +dill +dimple +diner +dinner +dinosaur +dirt +dish +disk +ditch +dive +dock +dodge +doe +dog +doll +dollar +dolphin +domain +donkey +door +douhua +dove +dragon +dragonfly +drain +drama +dream +dress +drift +drill +drink +drip +drive +drone +drop +drum +duck +dune +dust +eagle +ear +earth +ease +east +echo +eclipse +edge +eel +egg +elbow +elder +electric +elephant +elf +elm +ember +emerald +emo +engine +era +erha +error +essay +eve +evening +event +evergreen +ewer +eye +fable +fabric +face +fact +falcon +fall +family +fan +fang +farm +fawn +feast +feather +feature +fern +ferry +festival +field +fig +film +fin +finger +fir +fire +firefly +fish +fist +flag +flame +flare +flash +flask +flea +fleece +fleet +flint +flock +flood +floor +flora +flour +flower +flute +fly +foam +focus +fog +foil +font +food +foot +force +forest +fork +form +fort +fossil +fountain +fox +frame +freckle +freeze +fresco +fridge +friend +fringe +frog +front +frost +fruit +fry +fuel +funeng +fur +furnace +future +gadget +gale +gallery +game +gap +garden +garlic +gas +gate +gear +gecko +gem +genius +ghost +giant +gift +gill +ginger +giraffe +girl +glacier +glade +glass +glen +globe +glove +glue +gnat +goat +gold +gongwei +goose +gopher +gourd +grain +granite +grape +grass +gravel +gravy +green +grief +griffin +grill +grin +grip +grizzly +ground +group +grove +growth +guard +guest +guide +gulf +gull +gum +gust +gym +habit +hail +hair +hall +halo +ham +hammer +hammock +hand +harbor +hare +harp +hat +hatch +hawk +hay +haze +head +heart +heat +heath +heaven +hedge +heel +heir +helium +helmet +help +hen +herb +herd +hero +heron +hickory +hill +hive +hoe +hog +hole +holly +home +honey +hongguo +hood +hoof +hook +hope +horn +horse +hose +hotel +hound +hour +house +hue +hug +hull +hum +hummingbird +hump +hunter +huntun +huoguo +hurdle +hurricane +husk +hyacinth +hydrant +hyena +hymn +ice +icing +icon +idea +igloo +image +inch +ink +inlet +insect +iris +iron +island +item +ivory +ivy +jackal +jacket +jade +jaguar +jam +jar +jasmine +jaw +jay +jazz +jeans +jelly +jest +jet +jewel +jianbing +jiaozi +jingle +jinli +job +jockey +joke +journey +joy +judge +juejuezi +jug +jumao +jungle +juniper +junk +jury +kale +kangaroo +kaochuan +kapibala +kayak +keel +keeper +kelidu +kernel +kettle +key +kick +kid +king +kiosk +kiss +kitchen +kite +kitten +kiwi +knee +knife +knight +knob +knot +koala +label +labor +lace +ladder +lady +lagoon +lake +lamb +lamian +lamp +lance +land +lane +language +lap +lark +laser +laugh +laundry +lava +lawn +layer +lead +leaf +league +leap +leather +leek +leg +lemon +lens +leopard +lesson +letter +lettuce +level +lever +liangpi +library +lichen +lid +life +lift +light +lighthouse +lihua +lilac +lily +limb +lime +line +linen +lion +lip +liquid +list +liter +litter +lizard +loaf +lobby +lobster +lock +locust +log +lollipop +longmao +loop +lord +lotus +love +low +luck +lump +lunch +luosifen +lynx +lyre +machine +magnet +maiden +mail +maize +maker +malatang +mallow +mammoth +man +mango +mantou +map +maple +marble +march +mare +margin +mark +market +marlin +marmot +marsh +mashi +mast +master +mat +match +mate +matrix +matter +maze +meadow +meadowlark +meal +meat +medal +melon +melt +member +memory +menu +mercury +merit +mess +metal +meteor +meter +method +mianbao +mice +middle +midnight +might +mile +milk +mill +mimosa +mind +mine +mint +mirror +mist +mite +mitt +mix +mixian +moat +mock +mode +model +moment +monarch +monkey +month +moon +moor +morning +moss +moth +mother +motion +mound +mountain +mouse +mouth +move +movie +mud +muffin +mug +mule +murk +muscle +muse +mushroom +music +mustard +myth +naicha +nail +name +nap +napkin +nation +nature +navy +neck +nectar +needle +neighbor +nerve +nest +net +network +newt +nexus +nick +niece +night +nine +ninja +noble +node +noise +nook +noon +north +nose +note +notion +nova +novel +now +nugget +number +nun +nurse +nut +oak +oar +oasis +oat +oboe +ocean +octave +octopus +odor +offer +office +ogre +oil +olive +omen +onion +opal +opera +orbit +orchard +orchid +order +ore +organ +origami +ornament +otter +ounce +oven +owl +owner +ox +oxygen +oyster +pace +pack +pact +page +pail +paint +pair +palace +palm +pan +panda +pane +paopao +paper +parade +parcel +parchment +parent +park +parrot +part +party +pass +past +pasture +patch +path +patio +pattern +paw +peak +peanut +pear +pearl +pebble +pedal +peek +peel +peg +pelican +pelt +pen +pencil +penguin +people +pepper +perch +period +person +pest +pet +petal +phase +pheasant +phone +photo +piano +pick +pickle +picture +pie +piece +pier +pig +pigeon +pile +pill +pillow +pilot +pimento +pin +pine +pineapple +pink +pipe +pirate +pit +pitch +pizza +place +plain +plan +plane +planet +plant +plate +play +plaza +pleat +plot +plow +pluck +plum +plume +plush +pocket +pod +poem +poet +point +poke +polar +pole +polish +pollen +pond +pony +pool +pop +poppy +porch +porcupine +port +pose +post +pot +potato +pouch +pound +powder +power +practice +prairie +prawn +prayer +present +prey +price +pride +priest +prince +print +prism +prize +probe +problem +process +prose +prow +prune +puddle +puff +pug +pulp +pulse +pump +pumpkin +punch +pup +pupil +puppet +puppy +purple +purse +push +puzzle +qianshui +qingtuan +quail +quake +quality +quartz +queen +quest +quill +quilt +quince +quiver +quote +rabbit +race +rack +radar +raft +rag +rail +rain +rainbow +rake +rally +ram +ranch +range +rank +raptor +rat +raven +ray +razor +realm +reason +record +red +reed +reef +reflex +region +reign +relay +relic +remedy +rest +result +rhyme +ribbon +rice +ridge +rift +right +ring +rink +riot +rise +risk +river +road +roar +roast +robe +robin +rock +rocket +rod +rogue +roll +roof +room +root +rope +rose +rosette +rouge +roujiamo +round +route +row +ruby +rug +ruin +rule +rune +rung +rush +rust +rut +sack +saddle +safety +saffron +sage +sail +salad +salmon +salon +salt +samoye +sand +sandal +sap +sash +satin +sauce +sausage +savanna +save +saw +scale +scallop +scar +scarf +scene +scent +school +science +scion +scoop +score +scout +scrap +screen +scroll +scrub +sea +seal +seashell +season +seat +secret +seed +segment +self +sense +shadow +shale +shallot +shampoo +shangan +shape +shark +shawl +sheaf +shear +sheep +sheet +shelf +shell +shelter +shepherd +shield +shift +shine +ship +shirt +shoe +shoot +shop +shore +short +shot +shoulder +shout +show +shower +shrub +side +sight +sign +signal +silence +silk +silver +sink +siren +sister +site +six +size +skate +ski +skill +skin +skirt +skull +sky +slate +sled +sleep +sleet +slice +slope +slot +slug +smile +smoke +snack +snail +snake +snap +snow +soap +sock +soda +sofa +soil +soldier +sole +solid +son +song +soot +soul +sound +soup +source +south +space +spade +spark +sparrow +spear +speech +speed +spell +sphere +spice +spider +spike +spin +spire +spirit +sponge +spoon +sport +spot +spray +spring +spruce +spur +spy +square +squash +squid +squirrel +stable +stack +staff +stage +stain +stair +stake +stalk +stall +stallion +stamp +stand +star +starch +start +state +statue +steam +steel +stem +step +stern +stew +stick +sting +stock +stone +stool +stop +store +storm +story +stove +strand +stranger +strap +straw +strawberry +stream +street +stress +string +stripe +stroke +strut +student +study +stuff +stump +style +suanlafen +suburb +success +sugar +suit +sulfur +summer +sun +sunbeam +sunset +surf +surge +swamp +swan +sweat +sweet +swift +swim +swing +switch +symbol +symphony +sync +syrup +system +table +tackle +tail +tale +talk +talon +tangerine +tank +tap +tape +tar +target +taste +tavern +tax +tea +team +tear +teeth +tell +tempest +temple +tempo +tendril +tennis +tent +term +terrace +test +text +texture +theater +theme +theory +thorn +thread +threat +throne +thunder +tick +tide +tie +tiger +tile +timber +time +tin +tint +tip +tissue +toad +toast +toe +token +tomato +tone +tongdian +tongue +tool +tooth +top +topic +torch +tornado +torrent +tortoise +touch +tour +towel +tower +town +toy +track +trade +trail +train +trait +tram +trap +trash +travel +tray +treasure +tree +trellis +trend +trial +tribe +trick +trim +trio +trip +trolley +troop +trouble +trout +truck +trumpet +trunk +trust +truth +tube +tucao +tulip +tuna +tune +tunnel +turf +turkey +turn +turnip +turtle +twig +twin +twist +type +umbrella +uncle +unit +universe +urn +use +usher +valley +value +valve +vane +vanilla +vapor +vase +vault +veil +vein +velvet +vendor +vent +verb +verse +vessel +vest +vial +vibe +vicar +victim +victory +video +view +village +vine +violet +violin +virus +visit +voice +void +volcano +volume +vortex +vote +voyage +wage +wagon +waist +wait +wake +walk +wall +walnut +walrus +warbler +ward +warmth +warp +warrior +wasp +waste +watch +water +wave +wax +way +wealth +weapon +weasel +weather +weave +web +wedge +weed +week +well +west +whale +wheat +wheel +whip +whirl +whisker +whisper +whistle +white +whole +wick +widow +width +wife +wild +willow +win +wind +window +wine +wing +wink +winter +wire +wish +wit +witch +wolf +woman +wonder +wood +wool +word +work +world +worm +worry +worth +wound +wraith +wreath +wreck +wren +wrinkle +wrist +writer +xiangcai +xiaolongbao +xiongmao +yard +yarn +yawn +year +yeast +yellow +yew +yield +yolk +young +youtiao +yuanyuan +yyds +zeal +zebra +zenith +zero +zest +zigzag +zinc +zone +zongzi +zoo diff --git a/apps/kimi-code/src/utils/git/worktree-verbs.txt b/apps/kimi-code/src/utils/git/worktree-verbs.txt new file mode 100644 index 000000000..9c9295531 --- /dev/null +++ b/apps/kimi-code/src/utils/git/worktree-verbs.txt @@ -0,0 +1,1356 @@ +anli +aoye +arching +arising +ascending +asking +awakening +bacao +baking +balancing +beaming +becoming +bending +bihuan +binding +blazing +blessing +blinking +blooming +blowing +blushing +boiling +bounding +bracing +breathing +breezing +brewing +brimming +bristling +bubbling +building +busting +buzzing +caching +calling +calming +capping +careening +caring +carrying +carving +casting +catching +causing +chaining +changing +chanting +charging +chasing +chendian +chilling +chiming +choosing +churning +clamping +clapping +clashing +climbing +clinging +closing +clutching +coasting +coding +coiling +colliding +coming +composing +consuming +cooking +cooling +coping +copying +coring +coursing +cracking +cradling +crawling +creeping +cresting +crimping +crossing +crowning +cruising +crumbling +crunching +curling +cutting +dacall +dakai +dancing +daring +darting +datong +dawning +dealing +decking +decoding +deepening +defying +delaying +delving +descending +desiring +digging +dimming +dining +dipping +directing +diving +divining +docking +dodging +doing +doubling +doutu +drafting +dragging +draining +drawing +dreaming +drenching +dressing +dribbling +drifting +drilling +drinking +dripping +driving +droning +dropping +drying +ducking +dueling +duiqi +duoshou +dusting +dwelling +earning +eating +echoing +edging +editing +eking +eloping +emerging +ending +engaging +enjoying +entering +erasing +erupting +escaping +evading +examining +exiting +expanding +facai +fading +failing +falling +fangong +fanning +faring +farming +fastening +feasting +feeding +feeling +fencing +fetching +finding +firing +fishing +fitting +fixing +flaming +flapping +flaring +flashing +flattening +flavoring +fleeing +flickering +flicking +flinging +flipping +floating +flooding +flopping +flowering +flowing +fluctuating +flushing +flying +focusing +folding +following +fooling +footing +forcing +forecasting +forgetting +forging +forking +forming +fostering +founding +framing +fretting +frying +fueling +fumbling +fuming +funding +funeng +fupan +fusing +gaining +galloping +gambling +gaoqian +gaoshi +gardening +gasping +gathering +gazing +gearing +getting +gilding +giving +glancing +glaring +gliding +glimmering +glistening +glittering +glowing +gnawing +going +governing +grading +granting +grasping +grating +grazing +greening +greeting +grinding +gripping +groaning +growing +grumbling +guarding +guessing +guiding +gushing +hacking +hailing +halting +hammering +handling +hanging +happening +hardening +harping +harvesting +hastening +hatching +hauling +healing +heaping +hearing +heating +heaving +helping +hesitating +hiding +hiking +hindering +hitching +hoarding +holding +homing +honing +hooking +hopping +hovering +huashui +hugging +humming +hurling +hurrying +hushing +hustling +igniting +illuminating +imagining +immersing +incoming +increasing +inducing +inflating +informing +inhabiting +inheriting +initiating +injecting +inking +inquiring +inserting +inspecting +inspiring +installing +intending +interesting +intersecting +inventing +investing +inviting +ironing +itching +jaunting +jazzing +jesting +jingling +jogging +joining +joking +jolting +jostling +judging +juggling +jumping +justifying +kaibai +keening +keeping +keying +kidding +kissing +kneading +kneeling +knitting +knocking +knotting +knowing +kongchang +labeling +laboring +lacing +lacking +lagging +landing +lapping +laqi +lashing +lasting +latching +laughing +launching +leaning +leaping +learning +leaving +lecturing +lending +lengthening +letting +leveling +liandong +lifting +lighting +liking +limping +linking +listening +living +loading +loafing +locking +lodging +logging +looking +looming +looping +loosening +losing +loving +lowering +lucking +lumbering +luodi +lurching +lurking +making +managing +maopao +marching +marking +matching +mating +mattering +melding +melting +memorizing +mending +merging +messing +migrating +mimicking +mingling +mining +minting +mirroring +mixing +moaning +modeling +moderating +modifying +molding +molting +monitoring +mooring +morphing +moseying +mounting +moving +mowing +moyu +muddling +muffling +multiplying +mumbling +murmuring +musing +mutating +muttering +nagging +nailing +naming +napping +narrating +narrowing +navigating +needing +nesting +nestling +netting +nodding +nominating +normalizing +notching +noticing +noting +nourishing +nuancing +nudging +nullifying +numbing +nursing +obeying +objecting +obscuring +observing +obtaining +occupying +occurring +offering +officiating +oiling +omitting +opening +operating +opposing +opting +orbiting +ordering +organizing +orienting +originating +ornamenting +ousting +outlining +outpacing +overcoming +overlapping +overriding +overseeing +owing +owning +pacing +packing +paddling +paging +painting +palming +parading +paralleling +parking +parsing +parting +passing +pasting +patrolling +pausing +paving +paying +peaking +pecking +pedaling +peeking +peeling +peering +pelting +pending +penetrating +perceiving +perfecting +performing +perking +permitting +persisting +persuading +perturbing +perusing +picking +picturing +piercing +piling +piloting +pinching +pinging +pinning +pioneering +pirouetting +pivoting +placing +planning +planting +plastering +playing +pleading +pleasing +plodding +plotting +plowing +plucking +plugging +plumbing +plummeting +plunging +pointing +poising +poking +polishing +polling +pondering +popping +poring +porting +posing +positioning +possessing +posting +pounding +pouring +pouting +prancing +praying +preaching +preceding +predicting +preferring +preparing +prescribing +presenting +preserving +pressing +pretending +prevailing +preventing +probing +proceeding +processing +proclaiming +producing +professing +programming +progressing +projecting +promising +promoting +prompting +propping +protesting +providing +prowling +pruning +prying +puffing +pulling +pulsating +pulsing +pumping +punching +puncturing +purchasing +purring +pursuing +pushing +putting +puzzling +qianshui +quaking +qualifying +quartering +questioning +quieting +quilting +quivering +qujing +quoting +racing +radiating +rafting +raging +raining +raising +raking +rallying +rambling +ramping +ranking +ranting +rapping +rasping +rating +rattling +raving +reaching +reacting +reading +reaping +rearing +reasoning +reassuring +rebounding +rebuilding +recalling +receiving +reciting +reckoning +reclaiming +recoiling +recording +recovering +recruiting +recycling +reddening +redeeming +reducing +reeling +referencing +refining +reflecting +reforming +refracting +refreshing +refunding +refusing +regaining +regaling +regarding +registering +regressing +regretting +reigning +reining +rejecting +rejoicing +relaxing +releasing +relying +remaining +remarking +reminding +removing +rendering +renewing +rensong +renting +repairing +repeating +repelling +replacing +replying +reporting +reposing +representing +requesting +requiring +rescuing +researching +resembling +reserving +resetting +residing +resigning +resisting +resolving +resonating +resorting +respecting +responding +resting +restoring +restraining +resulting +resuming +retaining +retiring +retreating +retrieving +returning +reusing +revealing +reveling +revering +reversing +reverting +reviewing +reviving +revolving +rewarding +rewinding +rhyming +riding +rifting +ringing +rinsing +ripening +rising +risking +roaming +roaring +roasting +rocking +rolling +romping +rooting +roping +rounding +rousing +routing +roving +rowing +rubbing +ruffling +ruining +ruling +rumbling +rummaging +rumpling +running +rushing +rustling +sailing +saluting +sampling +sanding +sapping +satisfying +saving +savoring +sawing +saying +scaling +scampering +scanning +scaring +scattering +scenting +scheduling +scheming +schooling +scoffing +scolding +scooping +scooting +scorching +scoring +scouring +scowling +scrambling +scraping +scratching +screening +scribbling +scrubbing +scuffing +sculpting +scurrying +sealing +searching +seasoning +seating +seconding +seeing +seeking +seeming +seeping +selecting +selling +sending +sensing +separating +serving +setting +settling +severing +sewing +shading +shaidan +shaking +shaming +shangan +shaping +sharing +sharpening +shattering +shaving +shearing +shedding +shelling +sheltering +shielding +shifting +shimmering +shining +shipping +shivering +shocking +shooting +shopping +shortening +shouting +shoveling +showering +showing +shredding +shrilling +shrinking +shrouding +shrugging +shuffling +shuiqun +shunning +shutting +siding +sifting +sighing +signaling +signing +silencing +simmering +simplifying +singing +sinking +sipping +sitting +sizing +skating +sketching +skiing +skimming +skipping +skirting +skulking +slacking +slamming +slanting +slapping +slashing +sledding +sleeping +slicing +sliding +slinging +slinking +slipping +slitting +slogging +sloping +sloshing +slowing +slumbering +slumping +slurping +smacking +smearing +smelling +smiling +smirking +smoking +smoldering +smoothing +smothering +smudging +snaking +snapping +snaring +snarling +sneaking +sneering +sneezing +snickering +sniffing +sniping +snooping +snoozing +snoring +snorting +snowing +soaking +soaring +sobbing +socializing +softening +soiling +soldering +solving +soothing +soring +sorting +sounding +souring +sowing +spacing +spanking +sparking +sparkling +sparring +spattering +spawning +speaking +spearing +specializing +speeding +spelling +spending +spilling +spinning +spiraling +spitting +splashing +splaying +splicing +splitting +spoiling +sponging +spooking +spooling +spooning +sporting +spotting +spouting +sprawling +spraying +spreading +springing +sprinkling +sprinting +sprouting +spurring +spying +squalling +squaring +squashing +squeaking +squealing +squeezing +squinting +squirming +squishing +stacking +staffing +staggering +staining +staking +stalking +stalling +stamping +standing +staring +starting +starving +stating +staying +stealing +steaming +steering +stemming +stepping +sticking +stiffening +stifling +stilling +stimulating +stinging +stinking +stirring +stitching +stocking +stomping +stoning +stooping +stopping +storming +straddling +straggling +straightening +straining +stranding +strapping +straying +streaking +streaming +strengthening +stressing +stretching +striking +stringing +stripping +striving +stroking +strolling +struggling +studying +stuffing +stumbling +stumping +stunning +stunting +stuttering +subduing +submitting +subtracting +subverting +succeeding +succumbing +sucking +suggesting +suiting +sulfuring +sulking +summoning +sunning +supervising +supplying +supporting +supposing +suppressing +surfacing +surfing +surging +surmising +surpassing +surprising +surrendering +surrounding +surveying +surviving +suspecting +suspending +sustaining +swallowing +swamping +swapping +swarming +swathing +swaying +swearing +sweating +sweeping +swelling +swerving +swimming +swinging +swiping +swirling +swishing +switching +swiveling +swooning +swooping +synthesizing +tacking +tagging +tailing +taking +talking +tallying +taming +tangling +tangping +tapping +tarrying +tasting +taunting +taxiing +teaching +tearing +teasing +teeming +telling +tempering +tending +tensing +tenting +terminating +terming +terrifying +testing +tethering +thanking +thawing +thickening +thieving +thinning +thirsting +throbbing +thronging +throwing +thrusting +thudding +thumping +thundering +ticking +tickling +tidying +tieing +tightening +tilting +timing +tingling +tinkering +tinkling +tipping +tiptoeing +tiring +toasting +toiling +tolerating +tolling +toning +tooling +tooting +toppling +tossing +tottering +touching +touring +towing +tracing +tracking +trading +trailing +training +traipsing +tramping +trampling +transcending +transferring +transforming +transitioning +translating +transmitting +transporting +trapping +traveling +treading +treasuring +treating +trembling +trending +tricking +trickling +trimming +tripping +trotting +troubling +trouncing +trudging +trumping +truncating +trussing +trusting +trying +tucao +tucking +tugging +tumbling +tuning +turning +tutoring +twanging +tweaking +tweeting +twinkling +twirling +twisting +twitching +twittering +typing +unbinding +uncovering +undergoing +underlining +understanding +undertaking +undulating +unfolding +unifying +uniting +unloading +unlocking +unpacking +unraveling +unrolling +untying +upbraiding +upending +upgrading +upholding +uplifting +uprising +upsetting +urging +ushering +using +utilizing +uttering +vacating +varying +vaulting +veering +vending +venting +venturing +verifying +vexing +vibrating +viewing +vindicating +violating +visiting +visualizing +voicing +voiding +volleying +voting +vowing +waddling +wading +wafting +wagering +wagging +wailing +waiting +waking +walking +wallowing +wandering +waning +wanting +warming +warning +warping +washing +wasting +watching +watering +wavering +waving +waxing +weakening +wearying +weaving +wedding +wedging +weeping +weighing +weighting +welcoming +welling +welting +wetting +whacking +wheeling +wheezing +whimpering +whining +whipping +whirling +whispering +whistling +whittling +whooping +widening +wielding +wiggling +wilting +winning +wintering +wiping +wishing +withering +withstanding +wobbling +wondering +wooding +wooing +working +worrying +worshiping +wounding +wrangling +wrapping +wrecking +wrenching +wrestling +wriggling +wringing +wrinkling +writing +wronging +xiuxian +yammering +yangyuan +yanking +yawning +yearning +yelling +yelping +yielding +yingye +yingyuan +yodeling +zapping +zeroing +zhenghuo +zhongcao +zhuashou +zigzagging +zipping +zooming From 3bbf152964aa8b61f248b57924c678db08d8736c Mon Sep 17 00:00:00 2001 From: Ragnar Rova Date: Tue, 16 Jun 2026 17:46:52 +0200 Subject: [PATCH 04/10] fix(kimi-code): harden --worktree startup cleanup and name validation --- apps/kimi-code/src/cli/run-prompt.ts | 5 + apps/kimi-code/src/main.ts | 32 ++-- apps/kimi-code/src/utils/git/worktree.ts | 161 +++++++++++++++--- .../kimi-code/test/utils/git/worktree.test.ts | 131 +++++++++++++- docs/en/reference/kimi-command.md | 12 +- docs/zh/reference/kimi-command.md | 12 +- 6 files changed, 306 insertions(+), 47 deletions(-) diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index c643941fc..b52f7547b 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -294,6 +294,11 @@ async function resolvePromptSession( 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 { diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 9a184d6b5..192d9bc22 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -27,7 +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 { 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'; @@ -39,10 +39,6 @@ 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); @@ -66,12 +62,11 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom throw error; } - if (opts.worktree !== undefined && !worktreeCreated) { + if (opts.worktree !== undefined) { try { const { worktreePath, parentRepoPath } = await prepareWorktree(opts.worktree); opts.worktreePath = worktreePath; opts.parentRepoPath = parentRepoPath; - worktreeCreated = true; } catch (error) { if (error instanceof WorktreeError) { process.stderr.write(`error: ${error.message}\n`); @@ -89,12 +84,25 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom process.exit(0); } - if (validated.uiMode === 'print') { - await runPrompt(validated.options, version); - return; - } + try { + if (validated.uiMode === 'print') { + await runPrompt(validated.options, version); + return; + } - await runShell(validated.options, version); + await runShell(validated.options, version); + } catch (error) { + // If the runner failed during startup after we created a worktree, the + // worktree is still empty (no session ran), so clean it up to avoid leaks. + if (opts.worktreePath !== undefined && opts.parentRepoPath !== undefined) { + try { + removeWorktree(opts.parentRepoPath, opts.worktreePath); + } catch { + // Best-effort cleanup only. + } + } + throw error; + } } /** `kimi migrate`: launch the migration screen only, then exit. */ diff --git a/apps/kimi-code/src/utils/git/worktree.ts b/apps/kimi-code/src/utils/git/worktree.ts index 2e8af0447..071872736 100644 --- a/apps/kimi-code/src/utils/git/worktree.ts +++ b/apps/kimi-code/src/utils/git/worktree.ts @@ -3,16 +3,25 @@ * * Mirrors the upstream kimi-cli worktree feature: * - Worktrees are created under /.kimi/worktrees/ - * - Default name is kimi- + * - Default name is a random three-word slug from the worktree name database + * (e.g. amber-drifting-cloud, moyu-qianshui-xiongmao) * - Default checkout is detached HEAD at current HEAD */ +import { randomInt } from 'node:crypto'; import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import ADJECTIVES_RAW from './worktree-adjectives.txt?raw'; +import VERBS_RAW from './worktree-verbs.txt?raw'; +import NOUNS_RAW from './worktree-nouns.txt?raw'; const GIT_TIMEOUT_MS = 30_000; const WORKTREE_SUBDIR = '.kimi/worktrees'; +const MAX_SLUG_LENGTH = 64; +const VALID_SLUG_SEGMENT = /^[A-Za-z0-9._-]+$/; +const PR_REF_PREFIX = /^#(\d+)$/; +const NAME_RETRY_ATTEMPTS = 10; export class WorktreeError extends Error { constructor( @@ -54,15 +63,112 @@ function isInsideGitRepo(cwd: string): boolean { return status === 0 && stdout === 'true'; } +function parseWordList(raw: string): readonly string[] { + return raw + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); +} + +const ADJECTIVES = parseWordList(ADJECTIVES_RAW); +const VERBS = parseWordList(VERBS_RAW); +const NOUNS = parseWordList(NOUNS_RAW); + +function pick(list: readonly T[]): T { + if (list.length === 0) { + throw new WorktreeError('Worktree name word list is empty.'); + } + return list[randomInt(list.length)]!; +} + 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}`; + return `${pick(ADJECTIVES)}-${pick(VERBS)}-${pick(NOUNS)}`; +} + +/** + * Validates and normalizes a user-supplied worktree name. + * + * Rules: + * - Non-empty after trimming. + * - At most 64 characters. + * - No forward slashes. + * - May contain only letters, digits, '.', '_', and '-'. + * - The names '.' and '..' are rejected. + * - A leading '#' followed by digits is normalized to "pr-". + */ +export function normalizeWorktreeName(input: string): string { + const trimmed = input.trim(); + + if (trimmed.length === 0) { + throw new WorktreeError('Worktree name cannot be empty.'); + } + + const prMatch = PR_REF_PREFIX.exec(trimmed); + const name = prMatch !== null ? `pr-${prMatch[1]}` : trimmed; + + if (name.length > MAX_SLUG_LENGTH) { + throw new WorktreeError(`Worktree name must be ${MAX_SLUG_LENGTH} characters or fewer.`); + } + + if (name === '.' || name === '..') { + throw new WorktreeError(`Worktree name cannot be "." or "..": ${name}`); + } + + if (name.includes('/')) { + throw new WorktreeError(`Worktree name cannot contain "/": ${name}`); + } + + if (!VALID_SLUG_SEGMENT.test(name)) { + throw new WorktreeError( + `Worktree name contains invalid characters (allowed: letters, digits, '.', '_', '-'): ${name}`, + ); + } + + return name; +} + +function generateUniqueWorktreeName(worktreesDir: string): string { + for (let attempt = 0; attempt < NAME_RETRY_ATTEMPTS; attempt++) { + const name = generateDefaultWorktreeName(); + const worktreePath = resolve(worktreesDir, name); + if (!existsSync(worktreePath)) { + return name; + } + } + throw new WorktreeError( + `Failed to generate a unique worktree name after ${NAME_RETRY_ATTEMPTS} attempts.`, + ); +} + +function realpathOrNull(filePath: string): string | null { + try { + return realpathSync(filePath); + } catch { + return null; + } +} + +function isRegisteredWorktree(repoRoot: string, worktreePath: string): boolean | null { + const worktrees = listWorktrees(repoRoot); + if (worktrees === null) { + return null; + } + const target = realpathOrNull(worktreePath); + if (target === null) { + return false; + } + return worktrees.some((info) => { + const registeredPath = realpathOrNull(info.path); + return registeredPath !== null && registeredPath === target; + }); +} + +function ensureWorktreeStorageIgnored(worktreesDir: string): void { + const gitignorePath = resolve(worktreesDir, '.gitignore'); + if (existsSync(gitignorePath)) { + return; + } + writeFileSync(gitignorePath, '*\n', { encoding: 'utf8' }); } export function createWorktree(repoRoot: string, name?: string): string { @@ -70,8 +176,11 @@ export function createWorktree(repoRoot: string, name?: string): string { 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 worktreeName = + name !== undefined && name.trim().length > 0 + ? normalizeWorktreeName(name) + : generateUniqueWorktreeName(worktreesDir); const worktreePath = resolve(worktreesDir, worktreeName); if (resolve(worktreePath) === resolve(repoRoot)) { @@ -89,6 +198,7 @@ export function createWorktree(repoRoot: string, name?: string): string { // Ensure parent directory exists; git does not create nested parent dirs. mkdirSync(worktreesDir, { recursive: true }); + ensureWorktreeStorageIgnored(worktreesDir); const { stderr, status } = runGit(repoRoot, ['worktree', 'add', '--detach', worktreePath]); if (status !== 0) { @@ -113,27 +223,34 @@ export function removeWorktree(repoRoot: string, worktreePath: string): void { return; } + const registered = isRegisteredWorktree(canonicalRepoRoot, worktreePath); + + // Only fall back to rm for worktrees that are proven not to be registered + // with git. If registration status is unknown (list failed) or the worktree + // is registered, run git worktree remove, which fails safe on dirty/locked + // worktrees instead of bypassing the safety check with force-rm. + if (registered === false) { + rmSync(worktreePath, { recursive: true, force: true }); + runGit(canonicalRepoRoot, ['worktree', 'prune']); + 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 }); - // 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, - ); - } + 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[] { +export function listWorktrees(repoRoot: string): WorktreeInfo[] | null { const { stdout, status } = runGit(repoRoot, ['worktree', 'list', '--porcelain']); if (status !== 0) { - return []; + return null; } const worktrees: WorktreeInfo[] = []; diff --git a/apps/kimi-code/test/utils/git/worktree.test.ts b/apps/kimi-code/test/utils/git/worktree.test.ts index 2573e1702..60829bf7e 100644 --- a/apps/kimi-code/test/utils/git/worktree.test.ts +++ b/apps/kimi-code/test/utils/git/worktree.test.ts @@ -5,7 +5,14 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { createWorktree, findGitRoot, listWorktrees, removeWorktree, WorktreeError } from '#/utils/git/worktree'; +import { + createWorktree, + findGitRoot, + listWorktrees, + normalizeWorktreeName, + removeWorktree, + WorktreeError, +} from '#/utils/git/worktree'; function initRepo(path: string): void { execSync('git init', { cwd: path, stdio: 'ignore' }); @@ -52,7 +59,7 @@ describe('createWorktree', () => { expect(branch.trim()).toBe(''); }); - it('auto-generates a kimi-prefixed name when none is given', () => { + it('auto-generates a three-word slug when none is given', () => { const dir = makeTempDir('kimi-auto-wt-'); initRepo(dir); @@ -60,7 +67,7 @@ describe('createWorktree', () => { expect(existsSync(wt)).toBe(true); const baseName = wt.split('/').pop(); - expect(baseName).toMatch(/^kimi-\d{8}-\d{6}$/); + expect(baseName).toMatch(/^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$/); }); it('raises when the worktree directory already exists', () => { @@ -76,6 +83,103 @@ describe('createWorktree', () => { const dir = makeTempDir('kimi-no-git-'); expect(() => createWorktree(dir, 'x')).toThrow(WorktreeError); }); + + it('rejects names with invalid characters', () => { + const dir = makeTempDir('kimi-invalid-wt-'); + initRepo(dir); + + expect(() => createWorktree(dir, 'hello world')).toThrow(WorktreeError); + expect(() => createWorktree(dir, 'foo:bar')).toThrow(WorktreeError); + expect(() => createWorktree(dir, 'foo@bar')).toThrow(WorktreeError); + }); + + it('rejects names with path separators', () => { + const dir = makeTempDir('kimi-sep-wt-'); + initRepo(dir); + + expect(() => createWorktree(dir, 'foo/bar')).toThrow(WorktreeError); + expect(() => createWorktree(dir, '/foo')).toThrow(WorktreeError); + }); + + it('rejects names with dot segments', () => { + const dir = makeTempDir('kimi-dot-wt-'); + initRepo(dir); + + expect(() => createWorktree(dir, '.')).toThrow(WorktreeError); + expect(() => createWorktree(dir, '..')).toThrow(WorktreeError); + expect(() => createWorktree(dir, 'foo/./bar')).toThrow(WorktreeError); + }); + + it('rejects names longer than 64 characters', () => { + const dir = makeTempDir('kimi-long-wt-'); + initRepo(dir); + + const longName = 'a'.repeat(65); + expect(() => createWorktree(dir, longName)).toThrow(WorktreeError); + expect(() => createWorktree(dir, longName)).toThrow('64 characters'); + }); + + it('can create multiple auto-generated worktrees in the same repo', () => { + const dir = makeTempDir('kimi-multi-wt-'); + initRepo(dir); + + const wt1 = createWorktree(dir); + const wt2 = createWorktree(dir); + + expect(existsSync(wt1)).toBe(true); + expect(existsSync(wt2)).toBe(true); + expect(wt1).not.toBe(wt2); + }); + + it('keeps the worktree storage out of the parent git index', () => { + const dir = makeTempDir('kimi-clean-wt-'); + initRepo(dir); + + createWorktree(dir, 'feature-x'); + + expect(existsSync(join(dir, '.kimi', 'worktrees', '.gitignore'))).toBe(true); + const status = execSync('git status --short', { cwd: dir, encoding: 'utf8', stdio: 'pipe' }); + expect(status.trim()).toBe(''); + }); +}); + +describe('normalizeWorktreeName', () => { + it('trims whitespace', () => { + expect(normalizeWorktreeName(' feature-x ')).toBe('feature-x'); + }); + + it('normalizes #123 to pr-123', () => { + expect(normalizeWorktreeName('#123')).toBe('pr-123'); + expect(normalizeWorktreeName(' #42 ')).toBe('pr-42'); + }); + + it('accepts letters, digits, dots, underscores, and hyphens', () => { + expect(normalizeWorktreeName('feature_2.1-x')).toBe('feature_2.1-x'); + }); + + it('rejects empty names', () => { + expect(() => normalizeWorktreeName('')).toThrow(WorktreeError); + expect(() => normalizeWorktreeName(' ')).toThrow(WorktreeError); + }); + + it('rejects names with slashes', () => { + expect(() => normalizeWorktreeName('foo/bar')).toThrow(WorktreeError); + }); + + it('rejects dot segments', () => { + expect(() => normalizeWorktreeName('.')).toThrow(WorktreeError); + expect(() => normalizeWorktreeName('..')).toThrow(WorktreeError); + }); + + it('rejects invalid characters', () => { + expect(() => normalizeWorktreeName('foo bar')).toThrow(WorktreeError); + expect(() => normalizeWorktreeName('foo:bar')).toThrow(WorktreeError); + expect(() => normalizeWorktreeName('foo@bar')).toThrow(WorktreeError); + }); + + it('rejects names longer than 64 characters', () => { + expect(() => normalizeWorktreeName('a'.repeat(65))).toThrow(WorktreeError); + }); }); describe('removeWorktree', () => { @@ -95,7 +199,23 @@ describe('removeWorktree', () => { initRepo(dir); const missing = join(dir, '.kimi', 'worktrees', 'ghost'); - expect(() => removeWorktree(dir, missing)).not.toThrow(); + expect(() => { + removeWorktree(dir, missing); + }).not.toThrow(); + }); + + it('does not delete a dirty registered worktree', () => { + const dir = makeTempDir('kimi-rm-dirty-'); + initRepo(dir); + const wt = createWorktree(dir, 'dirty'); + const dirtyFile = join(wt, 'dirty-file.txt'); + execSync('touch dirty-file.txt', { cwd: wt, stdio: 'ignore' }); + + expect(() => { + removeWorktree(dir, wt); + }).toThrow(WorktreeError); + expect(existsSync(wt)).toBe(true); + expect(existsSync(dirtyFile)).toBe(true); }); }); @@ -107,7 +227,8 @@ describe('listWorktrees', () => { const wt2 = createWorktree(dir, 'wt2'); const list = listWorktrees(dir); - const paths = list.map((w) => w.path); + expect(list).not.toBeNull(); + const paths = list!.map((w) => w.path); expect(paths).toContain(wt1); expect(paths).toContain(wt2); diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 8d6faf9c3..aa87cb3e1 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -23,7 +23,7 @@ All flags are optional — run `kimi` directly to enter an interactive session: | `--yolo` | `-y` | Auto-approve regular tool calls, skipping approval requests | | `--auto` | | Start with auto permission mode; tool approvals are handled automatically and the Agent will not ask the user questions | | `--plan` | | Start a new session in Plan mode — the AI will prioritize read-only tools for exploration and planning | -| `--worktree [name]` | `-w` | Create a new git worktree for this session. When no name is given, a `kimi-` name is generated. The worktree is created in detached HEAD at the current commit | +| `--worktree [name]` | `-w` | Create a new git worktree for this session. When no name is given, a three-word slug is generated from the bundled name database (e.g. `amber-drifting-cloud`). The worktree is created in detached HEAD at the current commit | | `--skills-dir ` | | Load Skills from the specified directory, replacing the automatically discovered user and project directories. Can be repeated | `-r` / `--resume` is a hidden alias for `--session`; `--yes` and `--auto-approve` are hidden aliases for `--yolo` and are not shown in help output. @@ -88,15 +88,19 @@ kimi --plan Start a session in a fresh git worktree to avoid interfering with the main working tree or other active sessions: ```sh -# Auto-generate a kimi- worktree name +# Auto-generate a three-word slug like amber-drifting-cloud kimi -w -# Specify a custom worktree name -kimi --worktree refactor-auth +# Specify a custom worktree name (use = to avoid the next token being consumed) +kimi --worktree=refactor-auth ``` The worktree is created under `/.kimi/worktrees/` in a detached HEAD checkout at the current commit. Empty worktree sessions are cleaned up automatically on exit; sessions with content are left in place so work is preserved. +Worktree names are validated slugs: at most 64 characters, no `/`, and each part may contain only letters, digits, `.`, `_`, and `-`. The names `.` and `..` are rejected. A leading `#123` is normalized to `pr-123`. + +Because `--worktree` accepts an optional name, a trailing positional argument can be misread as the worktree name. Prefer `--worktree=` or `--worktree ` with the name immediately after the flag. + ### Custom Skills Directories There are two ways to specify Skills directories, with different semantics: diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index af5163c54..261bf2f7a 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -23,7 +23,7 @@ kimi [options] | `--yolo` | `-y` | 自动批准普通工具调用,跳过审批请求 | | `--auto` | | 以 auto 权限模式启动;工具审批自动处理,Agent 不会向用户提问 | | `--plan` | | 以 Plan 模式启动新会话,AI 会优先使用只读工具进行探索和规划 | -| `--worktree [name]` | `-w` | 为本次会话创建一个新的 git worktree。省略名称时自动生成 `kimi-`;工作区以 detached HEAD 方式基于当前 commit 创建 | +| `--worktree [name]` | `-w` | 为本次会话创建一个新的 git worktree。省略名称时从内置名称库自动生成一个三词 slug(如 `amber-drifting-cloud`);工作区以 detached HEAD 方式基于当前 commit 创建 | | `--skills-dir ` | | 从指定目录加载 Skills,替换自动发现的用户和项目目录。可重复传入 | `-r` / `--resume` 是 `--session` 的隐藏别名;`--yes` 和 `--auto-approve` 是 `--yolo` 的隐藏别名,在帮助信息中不显示。 @@ -88,15 +88,19 @@ kimi --plan 在全新的 git worktree 中启动会话,避免干扰主工作区或其他正在运行的会话: ```sh -# 自动生成 kimi- 名称的 worktree +# 自动生成类似 amber-drifting-cloud 的三词 slug kimi -w -# 指定自定义 worktree 名称 -kimi --worktree refactor-auth +# 指定自定义 worktree 名称(建议用 = 避免下一个参数被误读为名称) +kimi --worktree=refactor-auth ``` 工作区创建在 `/.kimi/worktrees/`,以 detached HEAD 方式基于当前 commit 检出。空的 worktree 会话在退出时会自动清理;包含内容的会话会保留,以免丢失工作成果。 +Worktree 名称是受限的 slug:最多 64 个字符,不能包含 `/`,每个分段只能包含字母、数字、`.`、`_` 和 `-`。`.` 和 `..` 被禁用。以 `#123` 开头的名称会被规范化为 `pr-123`。 + +由于 `--worktree` 的名称是可选的,末尾的位置参数可能会被误解析为 worktree 名称。建议优先使用 `--worktree=`,或在 flag 后紧跟名称。 + ### 自定义 Skills 目录 有两种方式指定 Skills 目录,语义不同: From a74eb192b78ccb1e66d4d189c3f1d312d865e74d Mon Sep 17 00:00:00 2001 From: Ragnar Rova Date: Tue, 16 Jun 2026 18:00:16 +0200 Subject: [PATCH 05/10] fix(kimi-code): correct --worktree cleanup and prepareWorktree signature - Make prepareWorktree synchronous; it performs no async work. - Log best-effort worktree cleanup failures instead of swallowing them. --- apps/kimi-code/src/cli/run-shell.ts | 5 +++-- apps/kimi-code/src/main.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 5e18b4d2c..af0f53a3b 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -144,8 +144,9 @@ export async function runShell( if (!hasContent && opts.worktreePath !== undefined && opts.parentRepoPath !== undefined) { try { removeWorktree(opts.parentRepoPath, opts.worktreePath); - } catch { - // Best-effort cleanup only. + } 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); } } diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 192d9bc22..36c67ed1d 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -39,7 +39,7 @@ import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; -async function prepareWorktree(worktreeName: string): Promise<{ worktreePath: string; parentRepoPath: string }> { +function prepareWorktree(worktreeName: string): { worktreePath: string; parentRepoPath: string } { const cwd = process.cwd(); const repoRoot = findGitRoot(cwd); if (repoRoot === null) { @@ -97,8 +97,9 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom if (opts.worktreePath !== undefined && opts.parentRepoPath !== undefined) { try { removeWorktree(opts.parentRepoPath, opts.worktreePath); - } catch { - // Best-effort cleanup only. + } 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; From 91be1d7b8382d726919bfecb2265d3feb2885fc3 Mon Sep 17 00:00:00 2001 From: Ragnar Rova Date: Tue, 16 Jun 2026 18:23:34 +0200 Subject: [PATCH 06/10] feat(agent-core): surface worktree metadata in agent system prompt --- .changeset/add-worktree-flag.md | 3 ++- packages/agent-core/src/agent/index.ts | 1 + packages/agent-core/src/profile/context.ts | 21 +++++++++++++++++-- .../agent-core/src/profile/default/system.md | 4 ++++ packages/agent-core/src/profile/resolve.ts | 14 +++++++++++++ packages/agent-core/src/profile/types.ts | 13 ++++++++++++ packages/agent-core/src/session/index.ts | 13 +++++++++++- .../agent-core/src/session/subagent-host.ts | 6 +++++- .../test/profile/agent-profile-loader.test.ts | 21 +++++++++++++++++++ 9 files changed, 91 insertions(+), 5 deletions(-) diff --git a/.changeset/add-worktree-flag.md b/.changeset/add-worktree-flag.md index 8e51fbe1e..e814d5a2d 100644 --- a/.changeset/add-worktree-flag.md +++ b/.changeset/add-worktree-flag.md @@ -1,5 +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. +Add `-w, --worktree [name]` flag to create a new git worktree for the session, and surface the worktree metadata in the agent system prompt. diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 392c1d4ea..e3439bd6a 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -288,6 +288,7 @@ export class Agent { skills: this.skills?.registry, cwdListing: context?.cwdListing, agentsMd: context?.agentsMd, + worktreeInfo: context?.worktreeInfo, }); this.config.update({ profileName: profile.name, systemPrompt }); this.tools.setActiveTools(profile.tools); diff --git a/packages/agent-core/src/profile/context.ts b/packages/agent-core/src/profile/context.ts index 49d8d8105..1c79a3c3b 100644 --- a/packages/agent-core/src/profile/context.ts +++ b/packages/agent-core/src/profile/context.ts @@ -3,7 +3,9 @@ import { dirname, join } from 'pathe'; import type { Kaos } from '@moonshot-ai/kaos'; import { listDirectory } from '../tools/support/list-directory'; -import type { SystemPromptContext } from './types'; +import type { SystemPromptContext, WorktreeInfo } from './types'; + +export type { WorktreeInfo }; const AGENTS_MD_MAX_BYTES = 32 * 1024; const AGENTS_MD_TRUNCATION_MARKER = @@ -11,7 +13,10 @@ const AGENTS_MD_TRUNCATION_MARKER = const S_IFMT = 0o170000; const S_IFREG = 0o100000; -export type PreparedSystemPromptContext = Pick; +export type PreparedSystemPromptContext = Pick< + SystemPromptContext, + 'cwdListing' | 'agentsMd' | 'worktreeInfo' +>; export async function prepareSystemPromptContext( kaos: Kaos, @@ -24,6 +29,18 @@ export async function prepareSystemPromptContext( return { cwdListing, agentsMd }; } +export function getWorktreeInfoFromSessionMetadata(metadata: { + readonly custom: Record; +}): WorktreeInfo | undefined { + const custom = metadata.custom; + const worktreePath = custom?.['worktreePath']; + const parentRepoPath = custom?.['parentRepoPath']; + if (typeof worktreePath === 'string' && typeof parentRepoPath === 'string') { + return { worktreePath, parentRepoPath }; + } + return undefined; +} + export async function loadAgentsMd(kaos: Kaos, brandHome?: string): Promise { const workDir = kaos.getcwd(); const projectRoot = await findProjectRoot(kaos, workDir); diff --git a/packages/agent-core/src/profile/default/system.md b/packages/agent-core/src/profile/default/system.md index d3b0084cc..1e3a6e2fa 100644 --- a/packages/agent-core/src/profile/default/system.md +++ b/packages/agent-core/src/profile/default/system.md @@ -85,6 +85,10 @@ The current date and time in ISO format is `{{ KIMI_NOW }}`. This is only a refe The current working directory is `{{ KIMI_WORK_DIR }}`. This should be considered as the project root if you are instructed to perform tasks on the project. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. Tools may require absolute paths for some parameters, IF SO, YOU MUST use absolute paths for these parameters. +{% if KIMI_WORKTREE_INFO %} +{{ KIMI_WORKTREE_INFO }} + +{% endif %} Use this as your basic understanding of the project structure. The tree only shows the first two levels for normal directories; entries marked "... and N more" indicate additional contents. Hidden directories are shown as entries only; their contents are intentionally omitted to reduce noise. If the task requires inspecting hidden paths, use `Glob` to discover them (for example `.*`, `.github/**`, `.agents/**`, or `.git/**`), use `Read` for known non-sensitive hidden files, and use `Grep` to search hidden file contents. `Grep` searches hidden files by default but excludes VCS metadata and sensitive files such as `.env`, credential stores, and SSH keys. Use `Bash` only for raw listings like `ls -A` when a dedicated tool is not appropriate. diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index 001f7d19f..ac285ebc5 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -5,6 +5,7 @@ import type { ResolvedAgentProfile, SystemPromptContext, SystemPromptRenderer, + WorktreeInfo, } from './types'; interface MergedAgentProfile { @@ -160,11 +161,24 @@ function buildTemplateVars( KIMI_AGENTS_MD: context.agentsMd ?? '', KIMI_SKILLS: skills, KIMI_ADDITIONAL_DIRS_INFO: context.additionalDirsInfo ?? '', + KIMI_WORKTREE_INFO: renderWorktreeInfo(context.worktreeInfo), ROLE_ADDITIONAL: context.roleAdditional ?? promptVars['ROLE_ADDITIONAL'] ?? promptVars['roleAdditional'] ?? '', }; } +function renderWorktreeInfo(worktreeInfo?: WorktreeInfo): string { + if (worktreeInfo === undefined) { + return ''; + } + return [ + 'You are running inside a git worktree that was created for this session.', + `Worktree path: ${worktreeInfo.worktreePath}`, + `Parent repository: ${worktreeInfo.parentRepoPath}`, + 'Treat the worktree as the active project workspace; all relative paths and shell commands run from this directory unless the user explicitly changes scope.', + ].join('\n'); +} + function applySubagentDescriptions( mergedProfiles: Map, resolvedProfiles: Map, diff --git a/packages/agent-core/src/profile/types.ts b/packages/agent-core/src/profile/types.ts index 27d407c3b..537c1aad6 100644 --- a/packages/agent-core/src/profile/types.ts +++ b/packages/agent-core/src/profile/types.ts @@ -25,6 +25,18 @@ export const RawAgentProfileSchema = z.object({ export type RawAgentProfile = z.infer; +/** + * Information about a git worktree the agent is running in. + * + * Populated when the session was launched with `--worktree`. The agent sees + * this in its system prompt so it knows it is in an isolated checkout and + * where the parent repository lives. + */ +export interface WorktreeInfo { + readonly worktreePath: string; + readonly parentRepoPath: string; +} + /** * Runtime context supplied to a system prompt renderer. * @@ -42,6 +54,7 @@ export interface SystemPromptContext { readonly skills?: SkillRegistry | string; readonly additionalDirsInfo?: string; readonly roleAdditional?: string; + readonly worktreeInfo?: WorktreeInfo; } export type SystemPromptRenderer = (context: SystemPromptContext) => string; diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index ca8c531a9..038d33715 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -23,9 +23,11 @@ import type { EnabledPluginSessionStart } from '../plugin'; import { DEFAULT_AGENT_PROFILES, DEFAULT_INIT_PROMPT, + getWorktreeInfoFromSessionMetadata, loadAgentsMd, prepareSystemPromptContext, type ResolvedAgentProfile, + type WorktreeInfo, } from '../profile'; import type { ProviderManager } from './provider-manager'; import { @@ -395,6 +397,15 @@ export class Session { return (await this.resumeAgent(id)).agent; } + /** + * Returns the git worktree metadata stored on the session, if any. + * + * Populated when the session was launched with `--worktree`. + */ + getWorktreeInfo(): WorktreeInfo | undefined { + return getWorktreeInfoFromSessionMetadata(this.metadata); + } + /** * Applies a profile's derived config — cwd, system prompt, active tools — to * an agent. Fresh creation and resume-of-an-incomplete-wire both route @@ -408,7 +419,7 @@ export class Session { this.systemContextKaos(agent.kaos.getcwd()), this.options.kimiHomeDir, ); - agent.useProfile(profile, context); + agent.useProfile(profile, { ...context, worktreeInfo: this.getWorktreeInfo() }); } async generateAgentsMd(): Promise { diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index b47e1cd68..2272d9229 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -12,6 +12,7 @@ import { InMemoryAgentRecordPersistence } from '../agent/records'; import { isAbortError } from '../loop/errors'; import { DEFAULT_AGENT_PROFILES, + getWorktreeInfoFromSessionMetadata, prepareSystemPromptContext, type ResolvedAgentProfile, } from '../profile'; @@ -366,7 +367,10 @@ export class SessionSubagentHost { this.session.systemContextKaos(child.kaos.getcwd()), this.session.options.kimiHomeDir, ); - child.useProfile(profile, context); + child.useProfile(profile, { + ...context, + worktreeInfo: getWorktreeInfoFromSessionMetadata(this.session.metadata), + }); child.tools.inheritUserTools(parent.tools); } diff --git a/packages/agent-core/test/profile/agent-profile-loader.test.ts b/packages/agent-core/test/profile/agent-profile-loader.test.ts index 6f305a6a9..dc1c8f0ef 100644 --- a/packages/agent-core/test/profile/agent-profile-loader.test.ts +++ b/packages/agent-core/test/profile/agent-profile-loader.test.ts @@ -233,6 +233,27 @@ describe('default agent profiles', () => { expect(second).toContain('/workspace/two'); expect(second).not.toContain('/workspace/one'); }); + + it('renders worktree info in the default prompt when provided', () => { + const prompt = DEFAULT_AGENT_PROFILES['agent']?.systemPrompt({ + ...promptContext, + worktreeInfo: { + worktreePath: '/repo/.kimi/worktrees/test-worktree', + parentRepoPath: '/repo', + }, + }); + + expect(prompt).toContain('You are running inside a git worktree'); + expect(prompt).toContain('Worktree path: /repo/.kimi/worktrees/test-worktree'); + expect(prompt).toContain('Parent repository: /repo'); + }); + + it('omits worktree info from the default prompt when not provided', () => { + const prompt = DEFAULT_AGENT_PROFILES['agent']?.systemPrompt(promptContext); + + expect(prompt).not.toContain('You are running inside a git worktree'); + expect(prompt).not.toContain('Worktree path:'); + }); }); async function write(fileName: string, content: string): Promise { From 6a86ada5cb2c3730051d224a4aba78e9c1dc070b Mon Sep 17 00:00:00 2001 From: Ragnar Rova Date: Tue, 16 Jun 2026 19:16:46 +0200 Subject: [PATCH 07/10] fix(kimi-code): address Codex review feedback on --worktree flag - Keep .kimi/ out of the parent git index via .git/info/exclude. - Preserve the caller's subdirectory when entering a worktree session. - Delay worktree creation until after update preflight returns continue. - Skip prompt-mode worktree cleanup on turn failures; leave worktrees inspectable. - Track lifetime session content so /new does not delete earlier-session worktrees. - Emit resume hints with cd '' for worktree sessions. --- apps/kimi-code/src/cli/run-prompt.ts | 6 ++- apps/kimi-code/src/cli/run-shell.ts | 11 ++-- apps/kimi-code/src/main.ts | 36 ++++++++----- apps/kimi-code/src/tui/kimi-tui.ts | 19 ++++++- apps/kimi-code/src/utils/git/worktree.ts | 38 +++++++++++++- apps/kimi-code/test/cli/run-prompt.test.ts | 13 +++++ apps/kimi-code/test/cli/run-shell.test.ts | 52 +++++++++++++++++++ .../kimi-code/test/utils/git/worktree.test.ts | 13 +++++ 8 files changed, 169 insertions(+), 19 deletions(-) diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index b52f7547b..6a2ec7776 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -156,7 +156,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, @@ -598,8 +598,10 @@ function writeResumeHint( outputFormat: PromptOutputFormat, stdout: PromptOutput, stderr: PromptOutput, + workDir?: string, ): void { - const command = `kimi -r ${sessionId}`; + const command = + workDir !== undefined ? `cd "${workDir}" && kimi -r ${sessionId}` : `kimi -r ${sessionId}`; const content = `To resume this session: ${command}`; if (outputFormat === 'stream-json') { const message: PromptJsonResumeMetaMessage = { diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index af0f53a3b..90276a533 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -135,12 +135,13 @@ 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. + // 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); @@ -154,7 +155,11 @@ export async function runShell( 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 "${process.cwd()}" && kimi -r ${sessionId}` + : `kimi -r ${sessionId}`; + process.stderr.write(`\n${gutter}To resume this session: ${resumeCommand}\n`); } process.exit(exitCode); }; diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 36c67ed1d..cadffc0af 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -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 { 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'; @@ -46,7 +47,12 @@ function prepareWorktree(worktreeName: string): { worktreePath: string; parentRe throw new WorktreeError('--worktree requires the working directory to be inside a git repository.'); } const worktreePath = createWorktree(repoRoot, worktreeName || undefined); - process.chdir(worktreePath); + const relativeCwd = relative(repoRoot, cwd); + const targetCwd = + relativeCwd.length > 0 && relativeCwd !== '.' + ? resolve(worktreePath, relativeCwd) + : worktreePath; + process.chdir(targetCwd); return { worktreePath, parentRepoPath: repoRoot }; } @@ -62,6 +68,14 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom throw error; } + const preflightResult = await runUpdatePreflight( + version, + validated.uiMode === 'print' ? { track, isTTY: false } : { track }, + ); + if (preflightResult === 'exit') { + process.exit(0); + } + if (opts.worktree !== undefined) { try { const { worktreePath, parentRepoPath } = await prepareWorktree(opts.worktree); @@ -76,14 +90,6 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom } } - const preflightResult = await runUpdatePreflight( - version, - validated.uiMode === 'print' ? { track, isTTY: false } : { track }, - ); - if (preflightResult === 'exit') { - process.exit(0); - } - try { if (validated.uiMode === 'print') { await runPrompt(validated.options, version); @@ -92,9 +98,15 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom await runShell(validated.options, version); } catch (error) { - // If the runner failed during startup after we created a worktree, the - // worktree is still empty (no session ran), so clean it up to avoid leaks. - if (opts.worktreePath !== undefined && opts.parentRepoPath !== undefined) { + // 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) { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 968b7ccbd..6c31c6933 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -239,6 +239,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; @@ -1036,7 +1037,23 @@ export class KimiTUI { } hasSessionContent(): boolean { - return this.state.transcriptEntries.length > 0; + const hasContent = this.state.transcriptEntries.length > 0; + if (hasContent) { + this.everHadSessionContent = true; + } + return hasContent; + } + + /** + * 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(); + return this.everHadSessionContent; } async getStartupMcpMs(): Promise { diff --git a/apps/kimi-code/src/utils/git/worktree.ts b/apps/kimi-code/src/utils/git/worktree.ts index 071872736..38a5cf41c 100644 --- a/apps/kimi-code/src/utils/git/worktree.ts +++ b/apps/kimi-code/src/utils/git/worktree.ts @@ -10,7 +10,7 @@ import { randomInt } from 'node:crypto'; import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import ADJECTIVES_RAW from './worktree-adjectives.txt?raw'; import VERBS_RAW from './worktree-verbs.txt?raw'; @@ -171,6 +171,41 @@ function ensureWorktreeStorageIgnored(worktreesDir: string): void { writeFileSync(gitignorePath, '*\n', { encoding: 'utf8' }); } +/** + * Ensures the parent `.kimi/` directory does not dirty the repository checkout. + * + * We add `.kimi/` to `.git/info/exclude` (local to this clone) rather than + * modifying any tracked `.gitignore` file. This keeps the worktree storage + * entirely out of `git status` without polluting the repo with untracked + * ignore rules. + */ +function ensureKimiDirIgnored(repoRoot: string): void { + const gitDirResult = runGit(repoRoot, ['rev-parse', '--git-dir']); + if (gitDirResult.status !== 0 || gitDirResult.stdout.length === 0) { + return; + } + const excludePath = resolve(repoRoot, gitDirResult.stdout, 'info', 'exclude'); + const marker = '.kimi/'; + + let existing = ''; + if (existsSync(excludePath)) { + try { + existing = readFileSync(excludePath, { encoding: 'utf8' }); + const lines = existing.split('\n'); + if (lines.some((line) => line.trim() === marker)) { + return; + } + } catch { + // Fall through to best-effort append. + } + } + + mkdirSync(resolve(excludePath, '..'), { recursive: true }); + writeFileSync(excludePath, `${existing}${existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''}${marker}\n`, { + encoding: 'utf8', + }); +} + export function createWorktree(repoRoot: string, name?: string): string { if (!isInsideGitRepo(repoRoot)) { throw new WorktreeError(`Not a git repository: ${repoRoot}`); @@ -199,6 +234,7 @@ export function createWorktree(repoRoot: string, name?: string): string { // Ensure parent directory exists; git does not create nested parent dirs. mkdirSync(worktreesDir, { recursive: true }); ensureWorktreeStorageIgnored(worktreesDir); + ensureKimiDirIgnored(repoRoot); const { stderr, status } = runGit(repoRoot, ['worktree', 'add', '--detach', worktreePath]); if (status !== 0) { diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index a3620aa35..09b01e4e8 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -216,6 +216,19 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalled(); }); + it('includes cd in the resume hint for worktree sessions', async () => { + const stdout = writer(); + const stderr = writer(); + + await runPrompt( + opts({ worktreePath: '/repo/.kimi/worktrees/wt', parentRepoPath: '/repo' }), + '1.2.3-test', + { stdout, stderr }, + ); + + expect(stderr.text()).toBe(`To resume this session: cd "${process.cwd()}" && kimi -r ses_prompt\n`); + }); + it('stops prompt startup when session creation fails', async () => { const stdout = writer(); const stderr = writer(); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index bab4fb152..f4ee68374 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -47,6 +47,7 @@ const mocks = vi.hoisted(() => { tuiGetStartupMcpMs: vi.fn(async () => 0), tuiGetCurrentSessionId: vi.fn(() => ''), tuiHasSessionContent: vi.fn(() => false), + tuiHasEverHadSessionContent: vi.fn(() => false), createKimiDeviceId: vi.fn(() => 'device-1'), initializeTelemetry: vi.fn(), setCrashPhase: vi.fn(), @@ -128,6 +129,7 @@ vi.mock('../../src/tui/index', () => ({ getStartupMcpMs = mocks.tuiGetStartupMcpMs; getCurrentSessionId = mocks.tuiGetCurrentSessionId; hasSessionContent = mocks.tuiHasSessionContent; + hasEverHadSessionContent = mocks.tuiHasEverHadSessionContent; }, })); @@ -154,6 +156,7 @@ describe('runShell', () => { mocks.tuiGetStartupMcpMs.mockResolvedValue(0); mocks.tuiGetCurrentSessionId.mockReturnValue(''); mocks.tuiHasSessionContent.mockReturnValue(false); + mocks.tuiHasEverHadSessionContent.mockReturnValue(false); mocks.createKimiDeviceId.mockImplementation(() => 'device-1'); mocks.resolveKimiHome.mockImplementation( (homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home', @@ -557,6 +560,7 @@ describe('runShell', () => { mocks.tuiStart.mockResolvedValue(undefined); mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); mocks.tuiHasSessionContent.mockReturnValue(true); + mocks.tuiHasEverHadSessionContent.mockReturnValue(true); const stdout = captureProcessWrite('stdout'); const stderr = captureProcessWrite('stderr'); @@ -602,6 +606,54 @@ describe('runShell', () => { } }); + it('includes cd in the resume hint for worktree sessions', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + mocks.tuiGetCurrentSessionId.mockReturnValue('ses-wt'); + mocks.tuiHasSessionContent.mockReturnValue(true); + mocks.tuiHasEverHadSessionContent.mockReturnValue(true); + + const stdout = captureProcessWrite('stdout'); + const stderr = captureProcessWrite('stderr'); + const exitSpy = mockProcessExit(); + + try { + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + worktreePath: '/repo/.kimi/worktrees/wt', + parentRepoPath: '/repo', + }, + '1.2.3-test', + ); + const [tui] = mocks.kimiTuiConstructor.mock.calls[0]!; + + await expect((tui as { onExit: () => Promise }).onExit()).rejects.toBeInstanceOf( + ExitCalled, + ); + + expect(stderr.text()).toContain( + ` To resume this session: cd "${process.cwd()}" && kimi -r ses-wt`, + ); + } finally { + exitSpy.mockRestore(); + stdout.restore(); + stderr.restore(); + } + }); + it('surfaces an invalid target config as an error for kimi migrate, not silently', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/utils/git/worktree.test.ts b/apps/kimi-code/test/utils/git/worktree.test.ts index 60829bf7e..fc73a507f 100644 --- a/apps/kimi-code/test/utils/git/worktree.test.ts +++ b/apps/kimi-code/test/utils/git/worktree.test.ts @@ -141,6 +141,19 @@ describe('createWorktree', () => { const status = execSync('git status --short', { cwd: dir, encoding: 'utf8', stdio: 'pipe' }); expect(status.trim()).toBe(''); }); + + it('adds .kimi/ to .git/info/exclude so the parent checkout stays clean', () => { + const dir = makeTempDir('kimi-exclude-wt-'); + initRepo(dir); + + createWorktree(dir, 'feature-x'); + + const excludePath = join(dir, '.git', 'info', 'exclude'); + expect(existsSync(excludePath)).toBe(true); + const exclude = execSync('git check-ignore -v .kimi/', { cwd: dir, encoding: 'utf8', stdio: 'pipe' }); + expect(exclude).toContain('.git/info/exclude'); + expect(exclude).toContain('.kimi/'); + }); }); describe('normalizeWorktreeName', () => { From ab83d9f66c14b1e35d1254ac2dcdce71d7e9581e Mon Sep 17 00:00:00 2001 From: Ragnar Rova Date: Tue, 16 Jun 2026 19:25:36 +0200 Subject: [PATCH 08/10] fix(kimi-code): preserve worktree metadata for /new sessions When a user ran /new inside a worktree session, createSessionFromCurrentState() did not pass the startup metadata, so the replacement session lost worktreePath and parentRepoPath. The TUI then no longer surfaced worktree context in the system prompt or subagents. Forward the startup metadata into replacement sessions and add a test covering the /new path. --- apps/kimi-code/src/tui/kimi-tui.ts | 1 + .../test/tui/kimi-tui-message-flow.test.ts | 41 ++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 6c31c6933..bd82f81ce 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1117,6 +1117,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, }); } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index da8df93ce..e4327ee65 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -87,7 +87,7 @@ interface ModelSelectorDriver extends MessageDriver { ): Promise<{ alias: string; thinking: boolean } | undefined>; } -function makeStartupInput(): KimiTUIStartupInput { +function makeStartupInput(overrides: Partial = {}): KimiTUIStartupInput { return { cliOptions: { session: undefined, @@ -108,6 +108,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, version: '0.0.0-test', workDir: '/tmp/proj-a', + ...overrides, }; } @@ -240,13 +241,14 @@ function makeHarness(session = makeSession(), overrides: Record async function makeDriver( session = makeSession(), harnessOverrides: Record = {}, + startupInputOverrides: Partial = {}, ): Promise<{ driver: MessageDriver; session: ReturnType; harness: ReturnType; }> { const harness = makeHarness(session, harnessOverrides); - const driver = new KimiTUI(harness as never, makeStartupInput()) as unknown as MessageDriver; + const driver = new KimiTUI(harness as never, makeStartupInput(startupInputOverrides)) as unknown as MessageDriver; vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); driver.persistInputHistory = vi.fn(async () => {}); @@ -594,6 +596,41 @@ command = "vim" expect(stripSgr(renderTranscript(driver))).not.toContain('Post-create setup failed'); }); + it('preserves worktree metadata when /new creates a replacement session', async () => { + const session = makeSession({ id: 'ses-1' }); + const nextSession = makeSession({ id: 'ses-2' }); + const { driver, harness } = await makeDriver(session, {}, { + cliOptions: { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + worktreePath: '/repo/.kimi/worktrees/wt', + parentRepoPath: '/repo', + }, + }); + harness.createSession.mockResolvedValueOnce(nextSession); + harness.createSession.mockClear(); + + driver.handleUserInput('/new'); + + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { + worktreePath: '/repo/.kimi/worktrees/wt', + parentRepoPath: '/repo', + }, + }), + ); + }); + }); + it('keeps the new session subscribed when post-create setup fails', async () => { const initialSession = makeSession({ id: 'ses-initial' }); const failedSession = makeSession({ From 47100743701a858bb37a254b350e30526a7c2361 Mon Sep 17 00:00:00 2001 From: Ragnar Rova Date: Tue, 16 Jun 2026 19:30:56 +0200 Subject: [PATCH 09/10] fix(kimi-code): address second round of Codex review feedback - Set everHadSessionContent when entries are appended/pushed and before the transcript is cleared, so /new after an earlier session does not delete the worktree. - Fall back to the worktree root when the mirrored caller subdirectory does not exist in the detached worktree; clean up the worktree if chdir still fails. - Use quoteShellArg for the cd path in worktree resume hints so paths with shell metacharacters are safe to paste. --- apps/kimi-code/src/cli/run-prompt.ts | 5 ++++- apps/kimi-code/src/cli/run-shell.ts | 3 ++- apps/kimi-code/src/main.ts | 16 +++++++++++++++- apps/kimi-code/src/tui/kimi-tui.ts | 9 ++++++++- apps/kimi-code/test/cli/run-prompt.test.ts | 2 +- apps/kimi-code/test/cli/run-shell.test.ts | 2 +- 6 files changed, 31 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 6a2ec7776..9131a6794 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -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; @@ -601,7 +602,9 @@ function writeResumeHint( workDir?: string, ): void { const command = - workDir !== undefined ? `cd "${workDir}" && kimi -r ${sessionId}` : `kimi -r ${sessionId}`; + 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 = { diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 90276a533..b03a58d04 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { removeWorktree } from '#/utils/git/worktree'; +import { quoteShellArg } from '#/utils/shell-quote'; import { setCrashPhase, @@ -157,7 +158,7 @@ export async function runShell( if (sessionId !== '' && hasContent) { const resumeCommand = opts.worktreePath !== undefined - ? `cd "${process.cwd()}" && kimi -r ${sessionId}` + ? `cd ${quoteShellArg(process.cwd())} && kimi -r ${sessionId}` : `kimi -r ${sessionId}`; process.stderr.write(`\n${gutter}To resume this session: ${resumeCommand}\n`); } diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index cadffc0af..3ea188c71 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -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 { 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'; @@ -52,7 +53,20 @@ function prepareWorktree(worktreeName: string): { worktreePath: string; parentRe relativeCwd.length > 0 && relativeCwd !== '.' ? resolve(worktreePath, relativeCwd) : worktreePath; - process.chdir(targetCwd); + + // 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 }; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index bd82f81ce..5dab3dcba 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1014,6 +1014,7 @@ export class KimiTUI { pushTranscriptEntry(entry: TranscriptEntry): void { this.state.transcriptEntries.push(entry); + this.recordSessionContent(); } setExternalEditorRunning(running: boolean): void { @@ -1039,11 +1040,15 @@ export class KimiTUI { hasSessionContent(): boolean { const hasContent = this.state.transcriptEntries.length > 0; if (hasContent) { - this.everHadSessionContent = true; + this.recordSessionContent(); } return hasContent; } + private recordSessionContent(): void { + this.everHadSessionContent = true; + } + /** * Whether any session in this TUI lifetime has had transcript content. * @@ -1496,6 +1501,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); @@ -1550,6 +1556,7 @@ export class KimiTUI { } private clearTranscriptAndRedraw(): void { + this.recordSessionContent(); this.streamingUI.discardPending(); this.state.transcriptEntries = []; this.streamingUI.disposeActiveCompactionBlock(); diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 09b01e4e8..af8ea68bd 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -226,7 +226,7 @@ describe('runPrompt', () => { { stdout, stderr }, ); - expect(stderr.text()).toBe(`To resume this session: cd "${process.cwd()}" && kimi -r ses_prompt\n`); + expect(stderr.text()).toBe(`To resume this session: cd '${process.cwd()}' && kimi -r ses_prompt\n`); }); it('stops prompt startup when session creation fails', async () => { diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index f4ee68374..5ebfe3a90 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -645,7 +645,7 @@ describe('runShell', () => { ); expect(stderr.text()).toContain( - ` To resume this session: cd "${process.cwd()}" && kimi -r ses-wt`, + ` To resume this session: cd '${process.cwd()}' && kimi -r ses-wt`, ); } finally { exitSpy.mockRestore(); From 42902f6c8d040b3aadc37f0318279112cdb2a56e Mon Sep 17 00:00:00 2001 From: Ragnar Rova Date: Tue, 16 Jun 2026 20:42:20 +0200 Subject: [PATCH 10/10] fix(kimi-code): address third round of Codex review feedback - Resolve the worktree exclude via `git rev-parse --git-path info/exclude` instead of `--git-dir`. When the repo root is itself a linked worktree, `--git-dir` points at `.git/worktrees/`, but Git reads the local exclude from the common git dir; the old path wrote `.kimi/` to a file Git never consults, so `git status` in the parent worktree still showed `?? .kimi/`. - Carry the current/resumed session's worktree metadata into replacement sessions when startup metadata is absent. A worktree session resumed with `-r` (no `--worktree`) has undefined startup metadata, so `/new` previously dropped worktreePath/parentRepoPath and lost the worktree system-prompt context for agents/subagents. Both paths gain regression tests (linked-worktree exclude resolution; resumed session /new metadata carry-forward). --- apps/kimi-code/src/tui/kimi-tui.ts | 21 +++++++++++- apps/kimi-code/src/utils/git/worktree.ts | 10 ++++-- .../test/tui/kimi-tui-message-flow.test.ts | 34 +++++++++++++++++++ .../kimi-code/test/utils/git/worktree.test.ts | 21 ++++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 5dab3dcba..e2ce08327 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -206,6 +206,25 @@ function buildSessionMetadata(cliOptions: CLIOptions): JsonObject | undefined { }; } +// 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 ` 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[]; @@ -1122,7 +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, + metadata: this.options.startup.metadata ?? worktreeMetadataFromSession(this.session), }); } diff --git a/apps/kimi-code/src/utils/git/worktree.ts b/apps/kimi-code/src/utils/git/worktree.ts index 38a5cf41c..9e63766d0 100644 --- a/apps/kimi-code/src/utils/git/worktree.ts +++ b/apps/kimi-code/src/utils/git/worktree.ts @@ -180,11 +180,15 @@ function ensureWorktreeStorageIgnored(worktreesDir: string): void { * ignore rules. */ function ensureKimiDirIgnored(repoRoot: string): void { - const gitDirResult = runGit(repoRoot, ['rev-parse', '--git-dir']); - if (gitDirResult.status !== 0 || gitDirResult.stdout.length === 0) { + // Resolve the exclude file via `--git-path` so that when `repoRoot` is itself + // a linked worktree we target the common git dir's `info/exclude` (where Git + // actually reads it from) rather than `.git/worktrees//info/exclude`, + // which Git would never consult. + const excludePathResult = runGit(repoRoot, ['rev-parse', '--git-path', 'info/exclude']); + if (excludePathResult.status !== 0 || excludePathResult.stdout.length === 0) { return; } - const excludePath = resolve(repoRoot, gitDirResult.stdout, 'info', 'exclude'); + const excludePath = resolve(repoRoot, excludePathResult.stdout); const marker = '.kimi/'; let existing = ''; diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index e4327ee65..9f1afb9b3 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -631,6 +631,40 @@ command = "vim" }); }); + it('carries forward worktree metadata from a resumed session when /new has no --worktree flags', async () => { + // Resuming a worktree session via `-r ` passes no --worktree CLI flags, + // so startup.metadata is undefined. The worktree paths must still be + // recovered from the resumed session's metadata so the replacement session + // stays in the same worktree checkout. + const session = makeSession({ + id: 'ses-1', + summary: { + title: null, + metadata: { + worktreePath: '/repo/.kimi/worktrees/wt', + parentRepoPath: '/repo', + }, + }, + }); + const nextSession = makeSession({ id: 'ses-2' }); + const { driver, harness } = await makeDriver(session); + harness.createSession.mockResolvedValueOnce(nextSession); + harness.createSession.mockClear(); + + driver.handleUserInput('/new'); + + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { + worktreePath: '/repo/.kimi/worktrees/wt', + parentRepoPath: '/repo', + }, + }), + ); + }); + }); + it('keeps the new session subscribed when post-create setup fails', async () => { const initialSession = makeSession({ id: 'ses-initial' }); const failedSession = makeSession({ diff --git a/apps/kimi-code/test/utils/git/worktree.test.ts b/apps/kimi-code/test/utils/git/worktree.test.ts index fc73a507f..bcedf40a1 100644 --- a/apps/kimi-code/test/utils/git/worktree.test.ts +++ b/apps/kimi-code/test/utils/git/worktree.test.ts @@ -154,6 +154,27 @@ describe('createWorktree', () => { expect(exclude).toContain('.git/info/exclude'); expect(exclude).toContain('.kimi/'); }); + + it('excludes .kimi/ via the common git dir when repoRoot is a linked worktree', () => { + const dir = makeTempDir('kimi-exclude-mainwt-'); + initRepo(dir); + // From a linked worktree, `git rev-parse --git-dir` points at + // `.git/worktrees/`, but Git reads info/exclude from the common dir. + const linked = join(makeTempDir('kimi-linkedwt-'), 'linked'); + execSync(`git worktree add ${linked}`, { cwd: dir, stdio: 'ignore' }); + + createWorktree(linked, 'feature-x'); + + // check-ignore from inside the linked worktree only matches if .kimi/ was + // written to the common info/exclude (not the per-worktree git dir). + const exclude = execSync('git check-ignore -v .kimi/', { + cwd: linked, + encoding: 'utf8', + stdio: 'pipe', + }); + expect(exclude).toContain('info/exclude'); + expect(exclude).toContain('.kimi/'); + }); }); describe('normalizeWorktreeName', () => {