Просмотр исходного кода

Parallel agents (#3355)

* add parallel mode

* add --existing-branch option

* dedupe determineBranch

* add telemetry

* remove leftover changes

* prep workspace for impl

* finishParallelMode checkpoint

* finalize parallel cleanup

* commit changes before disposing

* avoid circular ref errors

* avoid circular ref errors part 2

* improve readability

* reduce default timeout to 30s

* prevent double-disposes

* improve message

* increase timeout to 60s

* show "original cwd (worktree)" in the status bar

* clean slate for telemetry impl

* make it clear it's a git worktree

* lengthen line when printing parallel info

* improve telemetry

* stop fiddling with line length

* add parallel mode info message

* improve parallel mode welcome message

* exit code 1 in case disposing fails

* add tests for new git utils

* add status bar test

* simplify telemetry

* dry agent commit instruction

* improve error messaging in determineBranch.ts

* add tests

* remove lefotvers from old impl

* handle no prompt

* add changeset

* fix failing test

* delete unused interfaces

* add isGitWorktree

* use isGitWorktree to show whether a folder is a worktree, regardless of parallel mode

* update test

* disable input once parallel mode starts committing

* fix test

* default prompt to empty string if missing
Igor Šćekić 3 месяцев назад
Родитель
Сommit
e366e4ce61

+ 5 - 0
.changeset/modern-kings-tie.md

@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": patch
+---
+
+add parallel mode support

+ 36 - 8
cli/src/cli.ts

@@ -14,6 +14,8 @@ import { loadHistoryAtom } from "./state/atoms/history.js"
 import { getTelemetryService, getIdentityManager } from "./services/telemetry/index.js"
 import { notificationsAtom, notificationsErrorAtom, notificationsLoadingAtom } from "./state/atoms/notifications.js"
 import { fetchKilocodeNotifications } from "./utils/notifications.js"
+import { finishParallelMode } from "./parallel/parallel.js"
+import { isGitWorktree } from "./utils/git.js"
 
 export interface CLIOptions {
 	mode?: string
@@ -22,6 +24,8 @@ export interface CLIOptions {
 	json?: boolean
 	prompt?: string
 	timeout?: number
+	parallel?: boolean
+	worktreeBranch?: string | undefined
 }
 
 /**
@@ -53,8 +57,9 @@ export class CLI {
 		try {
 			logs.info("Initializing Kilo Code CLI...", "CLI")
 
-			// Set terminal title
-			const folderName = basename(this.options.workspace || process.cwd())
+			// Set terminal title - use process.cwd() in parallel mode to show original directory
+			const titleWorkspace = this.options.parallel ? process.cwd() : this.options.workspace || process.cwd()
+			const folderName = `${basename(titleWorkspace)}${(await isGitWorktree(this.options.workspace || "")) ? " (git worktree)" : ""}`
 			process.stdout.write(`\x1b]0;Kilo Code - ${folderName}\x07`)
 
 			// Create Jotai store
@@ -161,6 +166,7 @@ export class CLI {
 		// Disable stdin for Ink when in CI mode or when stdin is piped (not a TTY)
 		// This prevents the "Raw mode is not supported" error
 		const shouldDisableStdin = this.options.ci || !process.stdin.isTTY
+
 		this.ui = render(
 			React.createElement(App, {
 				store: this.store,
@@ -171,6 +177,8 @@ export class CLI {
 					json: this.options.json || false,
 					prompt: this.options.prompt || "",
 					...(this.options.timeout !== undefined && { timeout: this.options.timeout }),
+					parallel: this.options.parallel || false,
+					worktreeBranch: this.options.worktreeBranch || undefined,
 				},
 				onExit: () => this.dispose(),
 			}),
@@ -186,6 +194,8 @@ export class CLI {
 		await this.ui.waitUntilExit()
 	}
 
+	private isDisposing = false
+
 	/**
 	 * Dispose the application and clean up resources
 	 * - Unmounts UI
@@ -193,12 +203,22 @@ export class CLI {
 	 * - Cleans up store
 	 */
 	async dispose(): Promise<void> {
+		if (this.isDisposing) {
+			logs.info("Already disposing, ignoring duplicate dispose call", "CLI")
+
+			return
+		}
+
+		this.isDisposing = true
+
+		// Determine exit code based on CI mode and exit reason
+		let exitCode = 0
+
+		let beforeExit = () => {}
+
 		try {
 			logs.info("Disposing Kilo Code CLI...", "CLI")
 
-			// Determine exit code based on CI mode and exit reason
-			let exitCode = 0
-
 			if (this.options.ci && this.store) {
 				// Check exit reason from CI atoms
 				const exitReason = this.store.get(ciExitReasonAtom)
@@ -219,6 +239,11 @@ export class CLI {
 				}
 			}
 
+			// In parallel mode, we need to do manual git worktree cleanup
+			if (this.options.parallel) {
+				beforeExit = await finishParallelMode(this, this.options.workspace!, this.options.worktreeBranch!)
+			}
+
 			// Shutdown telemetry service before exiting
 			const telemetryService = getTelemetryService()
 			await telemetryService.shutdown()
@@ -241,12 +266,15 @@ export class CLI {
 
 			this.isInitialized = false
 			logs.info("Kilo Code CLI disposed", "CLI")
+		} catch (error) {
+			logs.error("Error disposing CLI", "CLI", { error })
+
+			exitCode = 1
+		} finally {
+			beforeExit()
 
 			// Exit process with appropriate code
 			process.exit(exitCode)
-		} catch (error) {
-			logs.error("Error disposing CLI", "CLI", { error })
-			process.exit(1)
 		}
 	}
 

+ 2 - 0
cli/src/commands/core/types.ts

@@ -40,6 +40,8 @@ export interface CommandContext {
 	clearTask: () => Promise<void>
 	setMode: (mode: string) => void
 	exit: () => void
+	setCommittingParallelMode: (isCommitting: boolean) => void
+	isParallelMode: boolean
 	// Model-related context
 	routerModels: RouterModels | null
 	currentProvider: ProviderConfig | null

+ 8 - 2
cli/src/commands/exit.ts

@@ -7,12 +7,18 @@ import type { Command } from "./core/types.js"
 export const exitCommand: Command = {
 	name: "exit",
 	aliases: ["quit", "q"],
-	description: "Exit the CLI",
+	description: "Exit the CLI - in parallel mode, will commit changes before exiting",
 	usage: "/exit",
 	examples: ["/exit"],
 	category: "system",
 	handler: async (context) => {
-		const { exit } = context
+		const { exit, setCommittingParallelMode, isParallelMode } = context
+
+		// In parallel mode, set the committing state before exit
+		if (isParallelMode) {
+			setCommittingParallelMode(true)
+		}
+
 		exit()
 	},
 }

+ 40 - 3
cli/src/index.ts

@@ -13,6 +13,7 @@ import { Package } from "./constants/package.js"
 import openConfigFile from "./config/openConfig.js"
 import authWizard from "./utils/authWizard.js"
 import { configExists } from "./config/persistence.js"
+import { getParallelModeParams } from "./parallel/parallel.js"
 
 const program = new Command()
 let cli: CLI | null = null
@@ -29,6 +30,11 @@ program
 	.option("-a, --auto", "Run in autonomous mode (non-interactive)", false)
 	.option("-j, --json", "Output messages as JSON (requires --auto)", false)
 	.option("-t, --timeout <seconds>", "Timeout in seconds for autonomous mode (requires --auto)", parseInt)
+	.option(
+		"-p, --parallel",
+		"Run in parallel mode - the agent will create a separate git branch, unless you provide the --existing-branch option",
+	)
+	.option("-eb, --existing-branch <branch>", "(Parallel mode only) Instructs the agent to work on an existing branch")
 	.argument("[prompt]", "The prompt or command to execute")
 	.action(async (prompt, options) => {
 		// Validate mode if provided
@@ -37,6 +43,12 @@ program
 			process.exit(1)
 		}
 
+		// Validate that --existing-branch requires --parallel
+		if (options.existingBranch && !options.parallel) {
+			console.error("Error: --existing-branch option requires --parallel flag to be enabled")
+			process.exit(1)
+		}
+
 		// Validate workspace path exists
 		if (!existsSync(options.workspace)) {
 			console.error(`Error: Workspace path does not exist: ${options.workspace}`)
@@ -56,7 +68,7 @@ program
 		}
 
 		// Read from stdin if no prompt argument is provided and stdin is piped
-		let finalPrompt = prompt
+		let finalPrompt = prompt || ""
 		if (!finalPrompt && !process.stdin.isTTY) {
 			// Read from stdin
 			const chunks: Buffer[] = []
@@ -68,7 +80,9 @@ program
 
 		// Validate that autonomous mode requires a prompt
 		if (options.auto && !finalPrompt) {
-			console.error("Error: autonomous mode (--auto) requires a prompt argument or piped input")
+			console.error(
+				"Error: autonomous mode (--auto) and parallel mode (--parallel) require a prompt argument or piped input",
+			)
 			process.exit(1)
 		}
 
@@ -95,13 +109,36 @@ program
 			await authWizard()
 		}
 
+		let finalWorkspace = options.workspace
+		let worktreeBranch
+
+		if (options.parallel) {
+			const parallelParams = await getParallelModeParams({
+				cwd: options.workspace,
+				prompt: finalPrompt,
+				timeout: options.timeout,
+				existingBranch: options.existingBranch,
+			})
+
+			finalWorkspace = parallelParams.worktreePath
+			worktreeBranch = parallelParams.worktreeBranch
+
+			getTelemetryService().trackParallelModeStarted(
+				!!options.existingBranch,
+				finalPrompt.length,
+				options.timeout,
+			)
+		}
+
 		cli = new CLI({
 			mode: options.mode,
-			workspace: options.workspace,
+			workspace: finalWorkspace,
 			ci: options.auto,
 			json: options.json,
 			prompt: finalPrompt,
 			timeout: options.timeout,
+			parallel: options.parallel,
+			worktreeBranch,
 		})
 		await cli.start()
 		await cli.dispose()

+ 308 - 0
cli/src/parallel/__tests__/determineBranch.test.ts

@@ -0,0 +1,308 @@
+/**
+ * Tests for determineParallelBranch function
+ */
+
+import { describe, it, expect, beforeEach, vi } from "vitest"
+import type { DetermineParallelBranchInput } from "../determineBranch.js"
+
+// Create mock for simpleGit using vi.hoisted
+const mockGitRaw = vi.hoisted(() => vi.fn())
+
+// Mock dependencies
+vi.mock("../../utils/git.js", () => ({
+	getGitInfo: vi.fn(),
+	generateBranchName: vi.fn(),
+	branchExists: vi.fn(),
+}))
+
+vi.mock("../../services/logs.js", () => ({
+	logs: {
+		info: vi.fn(),
+		error: vi.fn(),
+	},
+}))
+
+vi.mock("simple-git", () => ({
+	default: vi.fn(() => ({
+		raw: mockGitRaw,
+	})),
+}))
+
+// Import after mocks are set up
+import { determineParallelBranch } from "../determineBranch.js"
+import { getGitInfo, generateBranchName, branchExists } from "../../utils/git.js"
+
+describe("determineParallelBranch", () => {
+	const mockCwd = "/test/repo"
+	const mockPrompt = "Add new feature"
+	const mockBranch = "main"
+
+	beforeEach(() => {
+		vi.clearAllMocks()
+	})
+
+	describe("git repository validation", () => {
+		it("should throw error when directory is not a git repository", async () => {
+			vi.mocked(getGitInfo).mockResolvedValue({
+				isRepo: false,
+				branch: null,
+				isClean: true,
+			})
+
+			const input: DetermineParallelBranchInput = {
+				cwd: mockCwd,
+				prompt: mockPrompt,
+			}
+
+			await expect(determineParallelBranch(input)).rejects.toThrow(
+				"Error: parallel mode requires the current working directory to be a git repository",
+			)
+		})
+
+		it("should throw error when current branch cannot be determined", async () => {
+			vi.mocked(getGitInfo).mockResolvedValue({
+				isRepo: true,
+				branch: null,
+				isClean: true,
+			})
+
+			const input: DetermineParallelBranchInput = {
+				cwd: mockCwd,
+				prompt: mockPrompt,
+			}
+
+			await expect(determineParallelBranch(input)).rejects.toThrow("Could not determine current git branch")
+		})
+	})
+
+	describe("existing branch handling", () => {
+		beforeEach(() => {
+			vi.mocked(getGitInfo).mockResolvedValue({
+				isRepo: true,
+				branch: mockBranch,
+				isClean: true,
+			})
+		})
+
+		it("should use existing branch when it exists", async () => {
+			const existingBranch = "feature/existing"
+			vi.mocked(branchExists).mockResolvedValue(true)
+			mockGitRaw.mockResolvedValue("")
+
+			const input: DetermineParallelBranchInput = {
+				cwd: mockCwd,
+				prompt: mockPrompt,
+				existingBranch,
+			}
+
+			const result = await determineParallelBranch(input)
+
+			expect(branchExists).toHaveBeenCalledWith(mockCwd, existingBranch)
+			expect(result.worktreeBranch).toBe(existingBranch)
+		})
+
+		it("should throw error when existing branch does not exist", async () => {
+			const existingBranch = "feature/nonexistent"
+			vi.mocked(branchExists).mockResolvedValue(false)
+
+			const input: DetermineParallelBranchInput = {
+				cwd: mockCwd,
+				prompt: mockPrompt,
+				existingBranch,
+			}
+
+			await expect(determineParallelBranch(input)).rejects.toThrow(
+				`Error: Branch "${existingBranch}" does not exist`,
+			)
+
+			expect(branchExists).toHaveBeenCalledWith(mockCwd, existingBranch)
+		})
+	})
+
+	describe("new branch generation", () => {
+		beforeEach(() => {
+			vi.mocked(getGitInfo).mockResolvedValue({
+				isRepo: true,
+				branch: mockBranch,
+				isClean: true,
+			})
+		})
+
+		it("should generate new branch name from prompt when no existing branch provided", async () => {
+			const generatedBranch = "add-new-feature-1234567890"
+			vi.mocked(generateBranchName).mockReturnValue(generatedBranch)
+			mockGitRaw.mockResolvedValue("")
+
+			const input: DetermineParallelBranchInput = {
+				cwd: mockCwd,
+				prompt: mockPrompt,
+			}
+
+			const result = await determineParallelBranch(input)
+
+			expect(generateBranchName).toHaveBeenCalledWith(mockPrompt)
+			expect(result.worktreeBranch).toBe(generatedBranch)
+		})
+	})
+
+	describe("worktree creation", () => {
+		beforeEach(() => {
+			vi.mocked(getGitInfo).mockResolvedValue({
+				isRepo: true,
+				branch: mockBranch,
+				isClean: true,
+			})
+		})
+
+		it("should create worktree with new branch", async () => {
+			const generatedBranch = "add-new-feature-1234567890"
+			vi.mocked(generateBranchName).mockReturnValue(generatedBranch)
+
+			let capturedArgs: string[] = []
+			mockGitRaw.mockImplementation(async (args: string[]) => {
+				capturedArgs = args
+				return ""
+			})
+
+			const input: DetermineParallelBranchInput = {
+				cwd: mockCwd,
+				prompt: mockPrompt,
+			}
+
+			const result = await determineParallelBranch(input)
+
+			expect(result.worktreeBranch).toBe(generatedBranch)
+			expect(result.worktreePath).toContain(`kilocode-worktree-${generatedBranch}`)
+			expect(capturedArgs).toEqual([
+				"worktree",
+				"add",
+				"-b",
+				generatedBranch,
+				expect.stringContaining(`kilocode-worktree-${generatedBranch}`),
+			])
+		})
+
+		it("should create worktree with existing branch", async () => {
+			const existingBranch = "feature/existing"
+			vi.mocked(branchExists).mockResolvedValue(true)
+
+			let capturedArgs: string[] = []
+			mockGitRaw.mockImplementation(async (args: string[]) => {
+				capturedArgs = args
+				return ""
+			})
+
+			const input: DetermineParallelBranchInput = {
+				cwd: mockCwd,
+				prompt: mockPrompt,
+				existingBranch,
+			}
+
+			const result = await determineParallelBranch(input)
+
+			expect(result.worktreeBranch).toBe(existingBranch)
+			expect(result.worktreePath).toContain(`kilocode-worktree-${existingBranch}`)
+			expect(capturedArgs).toEqual([
+				"worktree",
+				"add",
+				expect.stringContaining(`kilocode-worktree-${existingBranch}`),
+				existingBranch,
+			])
+			expect(capturedArgs).not.toContain("-b") // Should not have -b flag for existing branch
+		})
+
+		it("should create worktree path in OS temp directory", async () => {
+			const generatedBranch = "add-new-feature-1234567890"
+			vi.mocked(generateBranchName).mockReturnValue(generatedBranch)
+			mockGitRaw.mockResolvedValue("")
+
+			const input: DetermineParallelBranchInput = {
+				cwd: mockCwd,
+				prompt: mockPrompt,
+			}
+
+			const result = await determineParallelBranch(input)
+
+			// Worktree path should be in temp directory
+			expect(result.worktreePath).toMatch(/^\/.*\/kilocode-worktree-/)
+			expect(result.worktreePath).toContain(generatedBranch)
+		})
+
+		it("should throw error when worktree creation fails", async () => {
+			const generatedBranch = "add-new-feature-1234567890"
+			vi.mocked(generateBranchName).mockReturnValue(generatedBranch)
+
+			const errorMessage = "fatal: 'add-new-feature-1234567890' is already checked out"
+			mockGitRaw.mockRejectedValue(new Error(errorMessage))
+
+			const input: DetermineParallelBranchInput = {
+				cwd: mockCwd,
+				prompt: mockPrompt,
+			}
+
+			await expect(determineParallelBranch(input)).rejects.toThrow(errorMessage)
+		})
+
+		it("should execute git command in the correct directory", async () => {
+			const generatedBranch = "add-new-feature-1234567890"
+			vi.mocked(generateBranchName).mockReturnValue(generatedBranch)
+			mockGitRaw.mockResolvedValue("")
+
+			const input: DetermineParallelBranchInput = {
+				cwd: mockCwd,
+				prompt: mockPrompt,
+			}
+
+			await determineParallelBranch(input)
+
+			// simpleGit is initialized with the cwd, so it will execute in that directory
+			// The mock is called with the git instance created from simpleGit(cwd)
+			expect(mockGitRaw).toHaveBeenCalled()
+		})
+	})
+
+	describe("return value", () => {
+		beforeEach(() => {
+			vi.mocked(getGitInfo).mockResolvedValue({
+				isRepo: true,
+				branch: mockBranch,
+				isClean: true,
+			})
+			mockGitRaw.mockResolvedValue("")
+		})
+
+		it("should return object with worktreeBranch and worktreePath", async () => {
+			const generatedBranch = "add-new-feature-1234567890"
+			vi.mocked(generateBranchName).mockReturnValue(generatedBranch)
+
+			const input: DetermineParallelBranchInput = {
+				cwd: mockCwd,
+				prompt: mockPrompt,
+			}
+
+			const result = await determineParallelBranch(input)
+
+			expect(result).toHaveProperty("worktreeBranch")
+			expect(result).toHaveProperty("worktreePath")
+			expect(typeof result.worktreeBranch).toBe("string")
+			expect(typeof result.worktreePath).toBe("string")
+		})
+
+		it("should return consistent branch name in both fields", async () => {
+			const existingBranch = "feature/existing"
+			vi.mocked(branchExists).mockResolvedValue(true)
+			mockGitRaw.mockResolvedValue("")
+
+			const input: DetermineParallelBranchInput = {
+				cwd: mockCwd,
+				prompt: mockPrompt,
+				existingBranch,
+			}
+
+			const result = await determineParallelBranch(input)
+
+			expect(result.worktreeBranch).toBe(existingBranch)
+			expect(result.worktreePath).toContain(existingBranch)
+		})
+	})
+})

+ 84 - 0
cli/src/parallel/determineBranch.ts

@@ -0,0 +1,84 @@
+import { getGitInfo, generateBranchName, branchExists } from "../utils/git.js"
+import { logs } from "../services/logs.js"
+import path from "path"
+import os from "os"
+import simpleGit from "simple-git"
+
+export interface DetermineParallelBranchInput {
+	cwd: string
+	prompt: string
+	existingBranch?: string
+}
+
+export interface DetermineParallelBranchResult {
+	worktreeBranch: string
+	worktreePath: string
+}
+
+/**
+ * Determine the branch and worktree path for parallel mode
+ * Validates git repository, creates or uses existing branch, and sets up worktree
+ */
+export async function determineParallelBranch({
+	cwd,
+	prompt,
+	existingBranch,
+}: DetermineParallelBranchInput): Promise<DetermineParallelBranchResult> {
+	const { isRepo, branch } = await getGitInfo(cwd)
+
+	if (!isRepo) {
+		const errorMessage = "Error: parallel mode requires the current working directory to be a git repository"
+		logs.error(errorMessage, "ParallelMode")
+		throw new Error(errorMessage)
+	}
+
+	if (!branch) {
+		const errorMessage = "Could not determine current git branch"
+		logs.error(errorMessage, "ParallelMode")
+		throw new Error(errorMessage)
+	}
+
+	// Determine the branch to use
+	let worktreeBranch: string
+
+	if (existingBranch) {
+		// Check if the existing branch exists
+		const exists = await branchExists(cwd, existingBranch)
+
+		if (!exists) {
+			const errorMessage = `Error: Branch "${existingBranch}" does not exist`
+			logs.error(errorMessage, "ParallelMode")
+			throw new Error(errorMessage)
+		}
+
+		worktreeBranch = existingBranch
+
+		logs.info(`Using existing branch: ${worktreeBranch}`, "ParallelMode")
+	} else {
+		// Generate branch name from prompt
+		worktreeBranch = generateBranchName(prompt)
+
+		logs.info(`Creating worktree with branch: ${worktreeBranch}`, "ParallelMode")
+	}
+
+	// Create worktree directory path in OS temp directory
+	const tempDir = os.tmpdir()
+	const worktreePath = path.join(tempDir, `kilocode-worktree-${worktreeBranch}`)
+
+	// Create worktree with appropriate git command
+	try {
+		const git = simpleGit(cwd)
+		const args = existingBranch
+			? ["worktree", "add", worktreePath, worktreeBranch]
+			: ["worktree", "add", "-b", worktreeBranch, worktreePath]
+
+		await git.raw(args)
+		logs.info(`Created worktree at: ${worktreePath}`, "ParallelMode")
+	} catch (error) {
+		logs.error("Failed to create worktree", "ParallelMode", { error })
+
+		throw error
+	}
+
+	return { worktreeBranch, worktreePath }
+}

+ 188 - 0
cli/src/parallel/parallel.ts

@@ -0,0 +1,188 @@
+import { determineParallelBranch } from "./determineBranch.js"
+import { logs } from "../services/logs.js"
+import type { CLI } from "../cli.js"
+import { simpleGit } from "simple-git"
+import { getTelemetryService } from "../services/telemetry/index.js"
+
+/**
+ * Helper function to commit changes with a fallback message
+ */
+async function commitWithFallback(cwd: string): Promise<void> {
+	const fallbackMessage = "chore: parallel mode task completion"
+	const git = simpleGit(cwd)
+
+	await git.commit(fallbackMessage)
+
+	logs.info("Changes committed with fallback message", "ParallelMode")
+}
+
+export const commitCompletionTimeout = 40000
+
+/**
+ * Poll git status to check if commit is complete
+ * Returns true if commit was made, false if timeout reached
+ */
+async function waitForCommitCompletion(cwd: string): Promise<boolean> {
+	const pollIntervalMs = 1000
+	const startTime = Date.now()
+	const git = simpleGit(cwd)
+
+	while (Date.now() - startTime < commitCompletionTimeout) {
+		try {
+			const stagedDiff = await git.diff(["--staged"])
+
+			// If no staged changes, commit was successful
+			if (!stagedDiff.trim()) {
+				return true
+			}
+
+			// Wait before next poll
+			await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
+		} catch (error) {
+			logs.error("Error checking commit status", "ParallelMode", {
+				error: error instanceof Error ? error.message : String(error),
+			})
+
+			return false
+		}
+	}
+
+	return false
+}
+
+export type Input = {
+	cwd: string
+	prompt: string
+	timeout?: number
+	existingBranch?: string
+}
+
+/**
+ * Get parameters for parallel mode execution
+ */
+export async function getParallelModeParams({ cwd, prompt, existingBranch }: Input) {
+	// Determine branch and worktree path
+	const { worktreeBranch, worktreePath } = await determineParallelBranch({
+		cwd,
+		prompt,
+		...(existingBranch && { existingBranch }),
+	})
+
+	return {
+		worktreeBranch,
+		worktreePath,
+	}
+}
+
+const agentCommitInstruction =
+	"Inspect the git diff and commit all staged changes with a proper conventional commit message (e.g., 'feat:', 'fix:', 'chore:', etc.). Use execute_command to run 'git diff --staged', then commit with an appropriate message using 'git commit -m \"your-message\"'."
+
+/**
+ * Finish parallel mode by having the extension agent generate a commit message and committing changes,
+ * then cleaning up the git worktree
+ * This function should be called from the CLI dispose method when in parallel mode
+ * Since it's part of the dispose flow, this function must never throw an error
+ */
+export async function finishParallelMode(cli: CLI, worktreePath: string, worktreeBranch: string) {
+	const git = simpleGit(worktreePath)
+
+	let beforeExit = () => {}
+
+	try {
+		const status = await git.status()
+
+		if (!status.isClean()) {
+			logs.info("Staging all changes...", "ParallelMode")
+
+			await git.add("-A")
+
+			const diff = await git.diff(["--staged"])
+
+			if (!diff.trim()) {
+				logs.warn("No staged changes found after git add", "ParallelMode")
+			} else {
+				const service = cli.getService()
+
+				if (!service) {
+					logs.error("Extension service not available, using fallback commit", "ParallelMode")
+
+					await commitWithFallback(worktreePath)
+				} else {
+					logs.info("Instructing extension agent to inspect diff and commit changes...", "ParallelMode")
+
+					await service.sendWebviewMessage({
+						type: "askResponse",
+						askResponse: agentCommitInstruction,
+						text: agentCommitInstruction,
+					})
+
+					logs.info("Waiting for agent to commit changes...", "ParallelMode")
+
+					const commitCompleted = await waitForCommitCompletion(worktreePath)
+
+					if (!commitCompleted) {
+						logs.warn("Agent did not complete commit within timeout, using fallback", "ParallelMode")
+
+						await commitWithFallback(worktreePath)
+					} else {
+						logs.info("Agent successfully committed changes", "ParallelMode")
+					}
+				}
+			}
+		} else {
+			logs.info("No changes to commit", "ParallelMode")
+		}
+
+		// delegate printing the message just before exit
+		beforeExit = () => {
+			const green = "\x1b[32m"
+			const cyan = "\x1b[36m"
+			const yellow = "\x1b[33m"
+			const bold = "\x1b[1m"
+			const reset = "\x1b[0m"
+
+			console.log("\n" + cyan + "─".repeat(113) + reset)
+			console.log(
+				`${green}✓${reset} ${bold}Parallel mode complete!${reset} Changes committed to: ${cyan}${worktreeBranch}${reset}`,
+			)
+			console.log(`\n${bold}Review and merge changes:${reset}`)
+			console.log(`  ${yellow}git diff ${worktreeBranch}${reset}`)
+			console.log(`  ${yellow}git merge ${worktreeBranch}${reset}`)
+			console.log(`\n${bold}💡 Tip:${reset} Resume work with ${yellow}--existing-branch${reset}:`)
+			console.log(`  ${yellow}kilocode --parallel --existing-branch ${worktreeBranch} "<prompt>"${reset}`)
+			console.log(cyan + "─".repeat(113) + reset + "\n")
+		}
+
+		getTelemetryService().trackParallelModeCompleted()
+	} catch (error) {
+		const errorMessage = error instanceof Error ? error.message : String(error)
+
+		logs.error("Failed to commit changes", "ParallelMode", {
+			error: errorMessage,
+		})
+
+		// Track parallel mode error
+		getTelemetryService().trackParallelModeErrored(errorMessage)
+	}
+
+	try {
+		logs.info(`Removing worktree at: ${worktreePath}`, "ParallelMode")
+
+		const git = simpleGit(process.cwd())
+
+		await git.raw(["worktree", "remove", worktreePath])
+
+		logs.info("Worktree removed successfully", "ParallelMode")
+	} catch (error) {
+		const errorMessage = error instanceof Error ? error.message : String(error)
+
+		logs.warn("Failed to remove worktree", "ParallelMode", {
+			error: errorMessage,
+		})
+
+		// Track parallel mode error
+		getTelemetryService().trackParallelModeErrored(errorMessage)
+	}
+
+	return beforeExit
+}

+ 42 - 0
cli/src/services/telemetry/TelemetryService.ts

@@ -559,6 +559,48 @@ export class TelemetryService {
 		})
 	}
 
+	// ============================================================================
+	// Parallel Mode Tracking
+	// ============================================================================
+
+	public parallelModeStart = 0
+
+	public trackParallelModeStarted(isExistingBranch: boolean, promptLength: number, timeoutSeconds?: number): void {
+		if (!this.client) return
+
+		this.parallelModeStart = Date.now()
+
+		this.client.capture(TelemetryEvent.PARALLEL_MODE_STARTED, {
+			mode: this.currentMode,
+			ciMode: this.currentCIMode,
+			isExistingBranch,
+			promptLength,
+			timeoutSeconds,
+		})
+	}
+
+	public trackParallelModeCompleted(): void {
+		if (!this.client) return
+
+		const duration = Date.now() - this.parallelModeStart
+
+		this.client.capture(TelemetryEvent.PARALLEL_MODE_COMPLETED, {
+			mode: this.currentMode,
+			ciMode: this.currentCIMode,
+			duration,
+		})
+	}
+
+	public trackParallelModeErrored(errorMessage: string): void {
+		if (!this.client) return
+
+		this.client.capture(TelemetryEvent.PARALLEL_MODE_ERRORED, {
+			mode: this.currentMode,
+			ciMode: this.currentCIMode,
+			errorMessage,
+		})
+	}
+
 	// ============================================================================
 	// MCP Tracking
 	// ============================================================================

+ 5 - 229
cli/src/services/telemetry/events.ts

@@ -53,6 +53,11 @@ export enum TelemetryEvent {
 	CI_MODE_COMPLETED = "cli_ci_mode_completed",
 	CI_MODE_TIMEOUT = "cli_ci_mode_timeout",
 
+	// Parallel Mode Events
+	PARALLEL_MODE_STARTED = "cli_parallel_mode_started",
+	PARALLEL_MODE_COMPLETED = "cli_parallel_mode_completed",
+	PARALLEL_MODE_ERRORED = "cli_parallel_mode_errored",
+
 	// Error Events
 	ERROR_OCCURRED = "cli_error_occurred",
 	EXCEPTION_CAUGHT = "cli_exception_caught",
@@ -100,232 +105,3 @@ export interface BaseProperties {
 	cliUserId: string
 	kilocodeUserId?: string
 }
-
-/**
- * Session event properties
- */
-export interface SessionEventProperties extends BaseProperties {
-	// Initialization arguments
-	initialMode?: string
-	initialWorkspace?: string
-	hasPrompt: boolean
-	hasTimeout: boolean
-	timeoutSeconds?: number
-}
-
-/**
- * Command execution event properties
- */
-export interface CommandEventProperties extends BaseProperties {
-	commandType: string
-	commandArgs?: string[]
-	executionTime: number
-	success: boolean
-	errorMessage?: string
-}
-
-/**
- * Message event properties
- */
-export interface MessageEventProperties extends BaseProperties {
-	messageLength: number
-	hasImages: boolean
-	imageCount?: number
-	isFollowup: boolean
-	taskId?: string
-}
-
-/**
- * Task event properties
- */
-export interface TaskEventProperties extends BaseProperties {
-	taskId: string
-	taskDuration?: number
-	messageCount?: number
-	toolUsageCount?: number
-	approvalCount?: number
-	errorCount?: number
-	completionReason?: string
-}
-
-/**
- * Configuration event properties
- */
-export interface ConfigEventProperties extends BaseProperties {
-	configVersion: string
-	providerCount: number
-	selectedProvider: string
-	selectedModel?: string
-	telemetryEnabled: boolean
-	autoApprovalEnabled: boolean
-}
-
-/**
- * Provider change event properties
- */
-export interface ProviderChangeEventProperties extends BaseProperties {
-	previousProvider?: string
-	newProvider: string
-	previousModel?: string
-	newModel?: string
-}
-
-/**
- * Tool usage event properties
- */
-export interface ToolEventProperties extends BaseProperties {
-	toolName: string
-	toolCategory: string
-	executionTime: number
-	success: boolean
-	isOutsideWorkspace?: boolean
-	isProtected?: boolean
-	errorMessage?: string
-}
-
-/**
- * MCP event properties
- */
-export interface MCPEventProperties extends BaseProperties {
-	serverName: string
-	toolName?: string
-	resourceUri?: string
-	executionTime: number
-	success: boolean
-	errorMessage?: string
-}
-
-/**
- * Approval event properties
- */
-export interface ApprovalEventProperties extends BaseProperties {
-	approvalType: string // tool, command, followup, retry
-	toolName?: string
-	commandName?: string
-	autoApproved: boolean
-	autoRejected: boolean
-	responseTime?: number
-	isOutsideWorkspace?: boolean
-	isProtected?: boolean
-}
-
-/**
- * CI mode event properties
- */
-export interface CIModeEventProperties extends BaseProperties {
-	promptLength: number
-	timeoutSeconds?: number
-	exitReason: string
-	totalDuration: number
-	taskCompleted: boolean
-	approvalCount: number
-	autoApprovalCount: number
-	autoRejectionCount: number
-}
-
-/**
- * Error event properties
- */
-export interface ErrorEventProperties extends BaseProperties {
-	errorType: string
-	errorMessage: string
-	errorStack?: string
-	errorContext?: string
-	isFatal: boolean
-}
-
-/**
- * Performance metrics properties
- */
-export interface PerformanceMetricsProperties extends BaseProperties {
-	// Memory metrics (in bytes)
-	memoryHeapUsed: number
-	memoryHeapTotal: number
-	memoryRSS: number
-	memoryExternal: number
-
-	// CPU metrics
-	cpuUsagePercent?: number
-
-	// Timing metrics (in milliseconds)
-	averageCommandTime?: number
-	averageApiResponseTime?: number
-	averageToolExecutionTime?: number
-
-	// Operation counts
-	totalCommands: number
-	totalMessages: number
-	totalToolExecutions: number
-	totalApiRequests: number
-	totalFileOperations: number
-}
-
-/**
- * API request event properties
- */
-export interface APIRequestProperties extends BaseProperties {
-	provider: string
-	model: string
-	requestType: string
-	responseTime: number
-	inputTokens?: number
-	outputTokens?: number
-	cacheReadTokens?: number
-	cacheWriteTokens?: number
-	cost?: number
-	success: boolean
-	errorMessage?: string
-}
-
-/**
- * Extension communication event properties
- */
-export interface ExtensionEventProperties extends BaseProperties {
-	messageType: string
-	direction: "sent" | "received"
-	processingTime?: number
-	success: boolean
-	errorMessage?: string
-}
-
-/**
- * Authentication event properties
- */
-export interface AuthEventProperties extends BaseProperties {
-	authMethod: string
-	success: boolean
-	errorMessage?: string
-}
-
-/**
- * Workflow pattern event properties
- */
-export interface WorkflowPatternProperties extends BaseProperties {
-	patternType: string
-	commandSequence?: string[]
-	frequency: number
-	duration: number
-}
-
-/**
- * Feature usage event properties
- */
-export interface FeatureUsageProperties extends BaseProperties {
-	featureName: string
-	usageCount: number
-	firstUsed: boolean
-}
-
-/**
- * Type guard to check if properties are valid
- */
-export function isValidEventProperties(properties: any): properties is BaseProperties {
-	return (
-		typeof properties === "object" &&
-		properties !== null &&
-		typeof properties.cliVersion === "string" &&
-		typeof properties.sessionId === "string" &&
-		typeof properties.mode === "string" &&
-		typeof properties.ciMode === "boolean"
-	)
-}

+ 5 - 0
cli/src/state/atoms/extension.ts

@@ -77,6 +77,11 @@ export const mcpServersAtom = atom<McpServer[]>([])
  */
 export const cwdAtom = atom<string | null>(null)
 
+/**
+ * Atom to track if we're in parallel mode
+ */
+export const isParallelModeAtom = atom(false)
+
 /**
  * Derived atom to get the extension version
  */

+ 3 - 0
cli/src/state/atoms/index.ts

@@ -42,6 +42,7 @@ export {
 	customModesAtom,
 	mcpServersAtom,
 	cwdAtom,
+	isParallelModeAtom,
 
 	// Derived extension state atoms
 	extensionVersionAtom,
@@ -168,6 +169,8 @@ export {
 	messagesAtom,
 	isStreamingAtom,
 	errorAtom,
+	isCommittingParallelModeAtom,
+	commitCountdownSecondsAtom,
 
 	// Autocomplete state atoms
 	showAutocompleteAtom,

+ 14 - 0
cli/src/state/atoms/ui.ts

@@ -4,12 +4,14 @@
  */
 
 import { atom } from "jotai"
+import { atomWithReset } from "jotai/utils"
 import type { CliMessage } from "../../types/cli.js"
 import type { ExtensionChatMessage } from "../../types/messages.js"
 import type { CommandSuggestion, ArgumentSuggestion } from "../../services/autocomplete.js"
 import { chatMessagesAtom } from "./extension.js"
 import { splitMessages } from "../../ui/messages/utils/messageCompletion.js"
 import { textBufferStringAtom, textBufferCursorAtom, setTextAtom, clearTextAtom } from "./textBuffer.js"
+import { commitCompletionTimeout } from "../../parallel/parallel.js"
 
 /**
  * Unified message type that can represent both CLI and extension messages
@@ -46,6 +48,18 @@ export const messageCutoffTimestampAtom = atom<number>(0)
  */
 export const errorAtom = atom<string | null>(null)
 
+/**
+ * Atom to track when parallel mode is committing changes
+ * Used to disable input and show "Committing your changes..." message
+ */
+export const isCommittingParallelModeAtom = atom<boolean>(false)
+
+/**
+ * Atom to track countdown timer for parallel mode commit (in seconds)
+ * Starts at 60 and counts down to 0
+ */
+export const commitCountdownSecondsAtom = atomWithReset<number>(commitCompletionTimeout / 1000)
+
 /**
  * Derived atom to check if the extension is currently streaming/processing
  * This mimics the webview's isStreaming logic from ChatView.tsx (lines 550-592)

+ 16 - 2
cli/src/state/hooks/useCommandContext.ts

@@ -7,9 +7,15 @@ import { useSetAtom, useAtomValue } from "jotai"
 import { useCallback } from "react"
 import type { CommandContext } from "../../commands/core/types.js"
 import type { CliMessage } from "../../types/cli.js"
-import { addMessageAtom, clearMessagesAtom, replaceMessagesAtom, setMessageCutoffTimestampAtom } from "../atoms/ui.js"
+import {
+	addMessageAtom,
+	clearMessagesAtom,
+	replaceMessagesAtom,
+	setMessageCutoffTimestampAtom,
+	isCommittingParallelModeAtom,
+} from "../atoms/ui.js"
 import { setModeAtom, providerAtom, updateProviderAtom } from "../atoms/config.js"
-import { routerModelsAtom, extensionStateAtom } from "../atoms/extension.js"
+import { routerModelsAtom, extensionStateAtom, isParallelModeAtom } from "../atoms/extension.js"
 import { requestRouterModelsAtom } from "../atoms/actions.js"
 import { profileDataAtom, balanceDataAtom, profileLoadingAtom, balanceLoadingAtom } from "../atoms/profile.js"
 import { useWebviewMessage } from "./useWebviewMessage.js"
@@ -60,6 +66,7 @@ export function useCommandContext(): UseCommandContextReturn {
 	const updateProvider = useSetAtom(updateProviderAtom)
 	const refreshRouterModels = useSetAtom(requestRouterModelsAtom)
 	const setMessageCutoffTimestamp = useSetAtom(setMessageCutoffTimestampAtom)
+	const setCommittingParallelMode = useSetAtom(isCommittingParallelModeAtom)
 	const { sendMessage, clearTask } = useWebviewMessage()
 
 	// Get read-only state
@@ -67,6 +74,7 @@ export function useCommandContext(): UseCommandContextReturn {
 	const currentProvider = useAtomValue(providerAtom)
 	const extensionState = useAtomValue(extensionStateAtom)
 	const kilocodeDefaultModel = extensionState?.kilocodeDefaultModel || ""
+	const isParallelMode = useAtomValue(isParallelModeAtom)
 
 	// Get profile state
 	const profileData = useAtomValue(profileDataAtom)
@@ -105,6 +113,10 @@ export function useCommandContext(): UseCommandContextReturn {
 				exit: () => {
 					onExit()
 				},
+				setCommittingParallelMode: (isCommitting: boolean) => {
+					setCommittingParallelMode(isCommitting)
+				},
+				isParallelMode,
 				// Model-related context
 				routerModels,
 				currentProvider: currentProvider || null,
@@ -150,6 +162,8 @@ export function useCommandContext(): UseCommandContextReturn {
 			balanceData,
 			profileLoading,
 			balanceLoading,
+			setCommittingParallelMode,
+			isParallelMode,
 		],
 	)
 

+ 4 - 0
cli/src/types/cli.ts

@@ -5,6 +5,10 @@ export interface WelcomeMessageOptions {
 	showInstructions?: boolean
 	// Content customization
 	instructions?: string[] // Custom instruction lines
+	// Parallel mode branch name
+	worktreeBranch?: string | undefined
+	// Workspace directory
+	workspace?: string | undefined
 }
 
 export interface CliMessage {

+ 5 - 1
cli/src/ui/App.tsx

@@ -3,7 +3,9 @@ import { Provider as JotaiProvider } from "jotai"
 import { UI } from "./UI.js"
 import { KeyboardProvider } from "./providers/KeyboardProvider.js"
 
-type JotaiStore = any
+import type { createStore } from "jotai"
+
+type JotaiStore = ReturnType<typeof createStore>
 
 export interface AppOptions {
 	mode?: string
@@ -12,6 +14,8 @@ export interface AppOptions {
 	json?: boolean
 	prompt?: string
 	timeout?: number
+	parallel?: boolean
+	worktreeBranch?: string | undefined
 }
 
 export interface AppProps {

+ 15 - 1
cli/src/ui/UI.tsx

@@ -9,6 +9,7 @@ import { useAtomValue, useSetAtom } from "jotai"
 import { isStreamingAtom, errorAtom, addMessageAtom, messageResetCounterAtom } from "../state/atoms/ui.js"
 import { setCIModeAtom } from "../state/atoms/ci.js"
 import { configValidationAtom } from "../state/atoms/config.js"
+import { isParallelModeAtom } from "../state/atoms/index.js"
 import { addToHistoryAtom, resetHistoryNavigationAtom, exitHistoryModeAtom } from "../state/atoms/history.js"
 import { MessageDisplay } from "./messages/MessageDisplay.js"
 import { JsonRenderer } from "./JsonRenderer.js"
@@ -55,6 +56,7 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
 	const addToHistory = useSetAtom(addToHistoryAtom)
 	const resetHistoryNavigation = useSetAtom(resetHistoryNavigationAtom)
 	const exitHistoryMode = useSetAtom(exitHistoryModeAtom)
+	const setIsParallelMode = useSetAtom(isParallelModeAtom)
 
 	// Use specialized hooks for command and message handling
 	const { executeCommand, isExecuting: isExecutingCommand } = useCommandHandler()
@@ -97,6 +99,13 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
 		}
 	}, [options.ci, options.timeout, setCIMode])
 
+	// Set parallel mode flag
+	useEffect(() => {
+		if (options.parallel) {
+			setIsParallelMode(true)
+		}
+	}, [options.parallel, setIsParallelMode])
+
 	// Handle CI mode exit
 	useEffect(() => {
 		if (shouldExit && options.ci) {
@@ -164,10 +173,15 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
 					clearScreen: !options.ci && configValidation.valid,
 					showInstructions: !options.ci || !options.prompt,
 					instructions: createConfigErrorInstructions(configValidation),
+					...(options.parallel &&
+						options.worktreeBranch && {
+							worktreeBranch: options.worktreeBranch,
+							workspace: options.workspace,
+						}),
 				}),
 			)
 		}
-	}, [options.ci, options.prompt, addMessage, configValidation])
+	}, [addMessage, options.ci, configValidation, options.prompt, options.parallel, options.worktreeBranch])
 
 	useEffect(() => {
 		const checkVersion = async () => {

+ 54 - 8
cli/src/ui/components/CommandInput.tsx

@@ -5,9 +5,9 @@
 
 import React, { useEffect } from "react"
 import { Box, Text } from "ink"
-import { useSetAtom, useAtomValue } from "jotai"
+import { useSetAtom, useAtomValue, useAtom } from "jotai"
 import { submissionCallbackAtom } from "../../state/atoms/keyboard.js"
-import { selectedIndexAtom } from "../../state/atoms/ui.js"
+import { selectedIndexAtom, isCommittingParallelModeAtom, commitCountdownSecondsAtom } from "../../state/atoms/ui.js"
 import { MultilineTextInput } from "./MultilineTextInput.js"
 import { useCommandInput } from "../../state/hooks/useCommandInput.js"
 import { useApprovalHandler } from "../../state/hooks/useApprovalHandler.js"
@@ -16,6 +16,7 @@ import { useTheme } from "../../state/hooks/useTheme.js"
 import { AutocompleteMenu } from "./AutocompleteMenu.js"
 import { ApprovalMenu } from "./ApprovalMenu.js"
 import { FollowupSuggestionsMenu } from "./FollowupSuggestionsMenu.js"
+import { useResetAtom } from "jotai/utils"
 
 interface CommandInputProps {
 	onSubmit: (value: string) => void
@@ -44,6 +45,31 @@ export const CommandInput: React.FC<CommandInputProps> = ({
 	// Setup centralized keyboard handler
 	const setSubmissionCallback = useSetAtom(submissionCallbackAtom)
 	const sharedSelectedIndex = useAtomValue(selectedIndexAtom)
+	const isCommittingParallelMode = useAtomValue(isCommittingParallelModeAtom)
+	const [countdownSeconds, setCountdownSeconds] = useAtom(commitCountdownSecondsAtom)
+	const resetCountdownSeconds = useResetAtom(commitCountdownSecondsAtom)
+
+	// Countdown timer effect for parallel mode commit
+	useEffect(() => {
+		if (!isCommittingParallelMode) {
+			resetCountdownSeconds()
+			return
+		}
+
+		resetCountdownSeconds()
+
+		const interval = setInterval(() => {
+			setCountdownSeconds((prev) => {
+				if (prev <= 1) {
+					clearInterval(interval)
+					return 0
+				}
+				return prev - 1
+			})
+		}, 1000)
+
+		return () => clearInterval(interval)
+	}, [isCommittingParallelMode, setCountdownSeconds, resetCountdownSeconds])
 
 	// Set the submission callback so keyboard handler can trigger onSubmit
 	useEffect(() => {
@@ -54,21 +80,41 @@ export const CommandInput: React.FC<CommandInputProps> = ({
 	const suggestionType =
 		commandSuggestions.length > 0 ? "command" : argumentSuggestions.length > 0 ? "argument" : "none"
 
-	// Determine if input should be disabled (during approval or when explicitly disabled)
-	const isInputDisabled = disabled || isApprovalPending
+	// Determine if input should be disabled (during approval, when explicitly disabled, or when committing parallel mode)
+	const isInputDisabled = disabled || isApprovalPending || isCommittingParallelMode
 
 	return (
 		<Box flexDirection="column">
 			{/* Input field */}
 			<Box
 				borderStyle="round"
-				borderColor={isApprovalPending ? theme.actions.pending : theme.ui.border.active}
+				borderColor={
+					isCommittingParallelMode
+						? theme.ui.border.active
+						: isApprovalPending
+							? theme.actions.pending
+							: theme.ui.border.active
+				}
 				paddingX={1}>
-				<Text color={isApprovalPending ? theme.actions.pending : theme.ui.border.active} bold>
-					{isApprovalPending ? "[!] " : "> "}
+				<Text
+					color={
+						isCommittingParallelMode
+							? theme.ui.border.active
+							: isApprovalPending
+								? theme.actions.pending
+								: theme.ui.border.active
+					}
+					bold>
+					{isCommittingParallelMode ? "⏳ " : isApprovalPending ? "[!] " : "> "}
 				</Text>
 				<MultilineTextInput
-					placeholder={isApprovalPending ? "Awaiting approval..." : placeholder}
+					placeholder={
+						isCommittingParallelMode
+							? `Committing your changes... (${countdownSeconds}s)`
+							: isApprovalPending
+								? "Awaiting approval..."
+								: placeholder
+					}
 					showCursor={!isInputDisabled}
 					maxLines={5}
 					width={Math.max(10, process.stdout.columns - 6)}

+ 37 - 2
cli/src/ui/components/StatusBar.tsx

@@ -2,11 +2,12 @@
  * StatusBar component - displays project info, git branch, mode, model, and context usage
  */
 
-import React, { useMemo } from "react"
+import React, { useEffect, useMemo, useState } from "react"
 import { Box, Text } from "ink"
 import { useAtomValue } from "jotai"
 import {
 	cwdAtom,
+	isParallelModeAtom,
 	extensionModeAtom,
 	apiConfigurationAtom,
 	chatMessagesAtom,
@@ -24,6 +25,7 @@ import {
 } from "../../constants/providers/models.js"
 import type { ProviderSettings } from "../../types/messages.js"
 import path from "path"
+import { isGitWorktree } from "../../utils/git.js"
 
 const MAX_MODEL_NAME_LENGTH = 40
 
@@ -93,6 +95,7 @@ export const StatusBar: React.FC = () => {
 
 	// Get data from atoms
 	const cwd = useAtomValue(cwdAtom)
+	const isParallelMode = useAtomValue(isParallelModeAtom)
 	const mode = useAtomValue(extensionModeAtom)
 	const apiConfig = useAtomValue(apiConfigurationAtom)
 	const messages = useAtomValue(chatMessagesAtom)
@@ -104,8 +107,40 @@ export const StatusBar: React.FC = () => {
 	// Calculate context usage
 	const contextUsage = useContextUsage(messages, apiConfig)
 
+	const [isWorktree, setIsWorktree] = useState(false)
+
+	useEffect(() => {
+		let latest = true
+
+		const checkWorktree = async () => {
+			if (!cwd) {
+				return
+			}
+
+			let result = false
+
+			try {
+				result = await isGitWorktree(cwd)
+			} catch {
+				/* empty */
+			} finally {
+				if (latest) {
+					setIsWorktree(result)
+				}
+			}
+		}
+
+		checkWorktree()
+
+		return () => {
+			latest = false
+		}
+	}, [cwd])
+
 	// Prepare display values
-	const projectName = getProjectName(cwd)
+	// In parallel mode, show the original directory (process.cwd()) instead of the worktree path
+	const displayCwd = isParallelMode ? process.cwd() : cwd
+	const projectName = `${getProjectName(displayCwd)}${isWorktree ? " (git worktree)" : ""}`
 	const modelName = useMemo(() => getModelDisplayName(apiConfig, routerModels), [apiConfig, routerModels])
 
 	// Get context color based on percentage using theme colors

+ 8 - 0
cli/src/ui/components/__tests__/CommandInput.test.tsx

@@ -10,9 +10,15 @@ vi.mock("jotai", async (importOriginal) => {
 		...actual,
 		useSetAtom: vi.fn(() => vi.fn()),
 		useAtomValue: vi.fn(() => 0),
+		useAtom: vi.fn(() => [0, vi.fn()]),
 	}
 })
 
+// Mock jotai/utils
+vi.mock("jotai/utils", () => ({
+	useResetAtom: vi.fn(() => vi.fn()),
+}))
+
 // Mock the hooks
 vi.mock("../../../state/hooks/useCommandInput.js", () => ({
 	useCommandInput: () => ({
@@ -70,6 +76,8 @@ vi.mock("../../../state/atoms/keyboard.js", () => ({
 
 vi.mock("../../../state/atoms/ui.js", () => ({
 	selectedIndexAtom: {},
+	isCommittingParallelModeAtom: {},
+	commitCountdownSecondsAtom: {},
 }))
 
 describe("CommandInput", () => {

+ 72 - 1
cli/src/ui/components/__tests__/StatusBar.test.tsx

@@ -3,7 +3,7 @@
  */
 
 import React from "react"
-import { describe, it, expect, vi, beforeEach } from "vitest"
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
 import { render } from "ink-testing-library"
 import { StatusBar } from "../StatusBar.js"
 import * as atoms from "../../../state/atoms/index.js"
@@ -16,6 +16,9 @@ vi.mock("jotai")
 
 vi.mock("../../../state/hooks/useGitInfo.js")
 vi.mock("../../../state/hooks/useContextUsage.js")
+vi.mock("../../../utils/git.js", () => ({
+	isGitWorktree: vi.fn(),
+}))
 
 describe("StatusBar", () => {
 	beforeEach(() => {
@@ -24,6 +27,7 @@ describe("StatusBar", () => {
 		// Setup default mock implementations
 		vi.mocked(useAtomValue).mockImplementation((atom: any) => {
 			if (atom === atoms.cwdAtom) return "/home/user/kilocode"
+			if (atom === atoms.isParallelModeAtom) return false
 			if (atom === atoms.extensionModeAtom) return "code"
 			if (atom === atoms.apiConfigurationAtom)
 				return {
@@ -202,4 +206,71 @@ describe("StatusBar", () => {
 		expect(frame).toBeTruthy()
 		expect(frame).toContain("test-project")
 	})
+
+	describe("parallel mode", () => {
+		let isGitWorktreeMock: any
+
+		beforeEach(async () => {
+			const gitModule = await import("../../../utils/git.js")
+			isGitWorktreeMock = vi.mocked(gitModule.isGitWorktree)
+		})
+		let originalCwd: () => string
+
+		beforeEach(() => {
+			// Store original process.cwd
+			originalCwd = process.cwd
+		})
+
+		afterEach(() => {
+			// Restore original process.cwd after each test
+			process.cwd = originalCwd
+		})
+
+		it("should render project name with git worktree suffix in parallel mode", async () => {
+			// Mock isGitWorktree to return true immediately
+			isGitWorktreeMock.mockResolvedValue(true)
+
+			// Mock process.cwd() to return the actual project directory
+			process.cwd = vi.fn(() => "/home/user/kilocode")
+
+			vi.mocked(useAtomValue).mockImplementation((atom: any) => {
+				if (atom === atoms.cwdAtom) return "/tmp/worktree/kilocode-task-123"
+				if (atom === atoms.isParallelModeAtom) return true
+				if (atom === atoms.extensionModeAtom) return "code"
+				if (atom === atoms.apiConfigurationAtom)
+					return {
+						apiProvider: "anthropic",
+						apiModelId: "claude-sonnet-4",
+					}
+				if (atom === atoms.chatMessagesAtom) return []
+				if (atom === atoms.routerModelsAtom) return null
+				return null
+			})
+
+			vi.mocked(useGitInfoHook.useGitInfo).mockReturnValue({
+				branch: "main",
+				isClean: true,
+				isRepo: true,
+				loading: false,
+			})
+
+			vi.mocked(useContextUsageHook.useContextUsage).mockReturnValue({
+				percentage: 45,
+				tokensUsed: 90000,
+				maxTokens: 200000,
+				reservedForOutput: 8192,
+				availableSize: 101808,
+			})
+
+			const { lastFrame } = render(<StatusBar />)
+
+			await vi.waitFor(
+				() => {
+					const frame = lastFrame()
+					expect(frame).toContain("kilocode (git worktree)")
+				},
+				{ timeout: 1000, interval: 50 },
+			)
+		})
+	})
 })

+ 21 - 1
cli/src/ui/messages/cli/WelcomeMessage.tsx

@@ -19,7 +19,8 @@ export const WelcomeMessage: React.FC<WelcomeMessageProps> = ({ options = {} })
 	const showInstructions = options.showInstructions !== false
 	const instructions =
 		options.instructions && options.instructions.length > 0 ? options.instructions : DEFAULT_INSTRUCTIONS
-	const contentHeight = 12 + (showInstructions ? instructions.length : 0)
+	const showParallelMessage = !!options.worktreeBranch
+	const contentHeight = 12 + (showInstructions ? instructions.length : 0) + (showParallelMessage ? 1 : 0)
 	const marginTop = options.clearScreen ? Math.max(0, (stdout?.rows || 0) - contentHeight) : 0
 
 	return (
@@ -37,6 +38,25 @@ export const WelcomeMessage: React.FC<WelcomeMessageProps> = ({ options = {} })
 					))}
 				</Box>
 			)}
+
+			{/* Parallel mode message */}
+			{showParallelMessage && (
+				<Box flexDirection="column" gap={1}>
+					<Text color={theme.ui.text.primary}>
+						You are working on branch{" "}
+						<Text bold color={theme.ui.text.highlight}>
+							{options.worktreeBranch}
+						</Text>{" "}
+						in parallel mode. Changes will be committed when you /exit.
+					</Text>
+					<Box flexDirection="column">
+						<Text color={theme.ui.text.primary}>
+							In case of an error, your pending changes are saved in <Text bold>{options.workspace}</Text>
+						</Text>
+						<Text>Commits in that directory will be visible in your main repository directory.</Text>
+					</Box>
+				</Box>
+			)}
 		</Box>
 	)
 }

+ 183 - 1
cli/src/utils/__tests__/git.test.ts

@@ -3,7 +3,7 @@
  */
 
 import { describe, it, expect, vi, beforeEach } from "vitest"
-import { getGitInfo, getGitBranch } from "../git.js"
+import { getGitInfo, getGitBranch, branchExists, generateBranchName, isGitWorktree } from "../git.js"
 import simpleGit from "simple-git"
 
 // Mock simple-git
@@ -124,4 +124,186 @@ describe("git utilities", () => {
 			expect(result).toBeNull()
 		})
 	})
+
+	describe("branchExists", () => {
+		it("should return false for empty cwd", async () => {
+			const result = await branchExists("", "main")
+			expect(result).toBe(false)
+		})
+
+		it("should return false for empty branchName", async () => {
+			const result = await branchExists("/git/repo", "")
+			expect(result).toBe(false)
+		})
+
+		it("should return false for non-git directory", async () => {
+			const mockGit = {
+				checkIsRepo: vi.fn().mockResolvedValue(false),
+			}
+			vi.mocked(simpleGit).mockReturnValue(mockGit as any)
+
+			const result = await branchExists("/some/path", "main")
+			expect(result).toBe(false)
+		})
+
+		it("should return true when local branch exists", async () => {
+			const mockGit = {
+				checkIsRepo: vi.fn().mockResolvedValue(true),
+				branch: vi.fn().mockResolvedValue({
+					all: ["main", "develop", "feature-branch"],
+				}),
+			}
+			vi.mocked(simpleGit).mockReturnValue(mockGit as any)
+
+			const result = await branchExists("/git/repo", "feature-branch")
+			expect(result).toBe(true)
+		})
+
+		it("should return true when remote branch exists", async () => {
+			const mockGit = {
+				checkIsRepo: vi.fn().mockResolvedValue(true),
+				branch: vi.fn().mockResolvedValue({
+					all: ["main", "remotes/origin/feature-branch"],
+				}),
+			}
+			vi.mocked(simpleGit).mockReturnValue(mockGit as any)
+
+			const result = await branchExists("/git/repo", "feature-branch")
+			expect(result).toBe(true)
+		})
+
+		it("should return false when branch does not exist", async () => {
+			const mockGit = {
+				checkIsRepo: vi.fn().mockResolvedValue(true),
+				branch: vi.fn().mockResolvedValue({
+					all: ["main", "develop"],
+				}),
+			}
+			vi.mocked(simpleGit).mockReturnValue(mockGit as any)
+
+			const result = await branchExists("/git/repo", "nonexistent")
+			expect(result).toBe(false)
+		})
+
+		it("should handle errors gracefully", async () => {
+			const mockGit = {
+				checkIsRepo: vi.fn().mockRejectedValue(new Error("Git error")),
+			}
+			vi.mocked(simpleGit).mockReturnValue(mockGit as any)
+
+			const result = await branchExists("/git/repo", "main")
+			expect(result).toBe(false)
+		})
+	})
+
+	describe("generateBranchName", () => {
+		it("should generate branch name with lowercase and hyphens", () => {
+			const result = generateBranchName("Fix Bug in Auth")
+			expect(result).toMatch(/^fix-bug-in-auth-\d+$/)
+		})
+
+		it("should replace special characters with hyphens", () => {
+			const result = generateBranchName("Feature: Add @user support!")
+			expect(result).toMatch(/^feature-add-user-support-\d+$/)
+		})
+
+		it("should remove leading and trailing hyphens", () => {
+			const result = generateBranchName("---test---")
+			expect(result).toMatch(/^test-\d+$/)
+		})
+
+		it("should collapse multiple hyphens into one", () => {
+			const result = generateBranchName("fix   multiple   spaces")
+			expect(result).toMatch(/^fix-multiple-spaces-\d+$/)
+		})
+
+		it("should truncate to 50 characters", () => {
+			const longPrompt = "a".repeat(100)
+			const result = generateBranchName(longPrompt)
+			const withoutTimestamp = result.split("-").slice(0, -1).join("-")
+			expect(withoutTimestamp.length).toBeLessThanOrEqual(50)
+		})
+
+		it("should add timestamp for uniqueness", async () => {
+			const prompt = "test feature"
+			const result1 = generateBranchName(prompt)
+
+			await new Promise((resolve) => setTimeout(resolve, 5))
+
+			const result2 = generateBranchName(prompt)
+
+			expect(result1).not.toBe(result2)
+			expect(result1).toMatch(/^test-feature-\d+$/)
+			expect(result2).toMatch(/^test-feature-\d+$/)
+		})
+
+		it("should handle empty string", () => {
+			const result = generateBranchName("")
+			expect(result).toMatch(/^kilo-\d+$/)
+		})
+
+		it("should handle only special characters", () => {
+			const result = generateBranchName("!@#$%^&*()")
+			expect(result).toMatch(/^kilo-\d+$/)
+		})
+
+		it("should handle unicode characters", () => {
+			const result = generateBranchName("Add 日本語 support")
+			expect(result).toMatch(/^add-support-\d+$/)
+		})
+
+		it("should handle mixed case properly", () => {
+			const result = generateBranchName("FixBugInAuthSystem")
+			expect(result).toMatch(/^fixbuginauthsystem-\d+$/)
+		})
+	})
+
+	describe("isGitWorktree", () => {
+		it("should return false for empty cwd", async () => {
+			const result = await isGitWorktree("")
+			expect(result).toBe(false)
+		})
+
+		it("should return false for non-git directory", async () => {
+			const mockGit = {
+				checkIsRepo: vi.fn().mockResolvedValue(false),
+			}
+			vi.mocked(simpleGit).mockReturnValue(mockGit as any)
+
+			const result = await isGitWorktree("/some/path")
+			expect(result).toBe(false)
+		})
+
+		it("should return false for regular git repository", async () => {
+			const mockGit = {
+				checkIsRepo: vi.fn().mockResolvedValue(true),
+				revparse: vi.fn().mockResolvedValue(".git\n"),
+			}
+			vi.mocked(simpleGit).mockReturnValue(mockGit as any)
+
+			const result = await isGitWorktree("/git/repo")
+			expect(result).toBe(false)
+		})
+
+		it("should return true for git worktree", async () => {
+			const mockGit = {
+				checkIsRepo: vi.fn().mockResolvedValue(true),
+				revparse: vi.fn().mockResolvedValue("/path/to/.git/worktrees/feature-branch\n"),
+			}
+			vi.mocked(simpleGit).mockReturnValue(mockGit as any)
+
+			const result = await isGitWorktree("/git/worktree")
+			expect(result).toBe(true)
+		})
+
+		it("should handle errors gracefully", async () => {
+			const mockGit = {
+				checkIsRepo: vi.fn().mockRejectedValue(new Error("Git error")),
+			}
+			vi.mocked(simpleGit).mockReturnValue(mockGit as any)
+
+			const result = await isGitWorktree("/git/repo")
+			expect(result).toBe(false)
+		})
+	})
 })

+ 74 - 0
cli/src/utils/git.ts

@@ -78,3 +78,77 @@ export async function getGitBranch(cwd: string): Promise<string | null> {
 		return null
 	}
 }
+
+/**
+ * Check if a branch exists in the repository
+ * @param cwd - Current working directory path
+ * @param branchName - Name of the branch to check
+ * @returns True if branch exists, false otherwise
+ */
+export async function branchExists(cwd: string, branchName: string): Promise<boolean> {
+	if (!cwd || !branchName) {
+		return false
+	}
+
+	try {
+		const git: SimpleGit = simpleGit(cwd)
+		const isRepo = await git.checkIsRepo()
+		if (!isRepo) {
+			return false
+		}
+
+		// Get all branches (local and remote)
+		const branches = await git.branch()
+
+		// Check if branch exists in local branches
+		return branches.all.includes(branchName) || branches.all.includes(`remotes/origin/${branchName}`)
+	} catch (error) {
+		logs.debug("Failed to check if branch exists", "GitUtils", { error, cwd, branchName })
+		return false
+	}
+}
+
+/**
+ * Generate a valid git branch name from a prompt
+ * Sanitizes the prompt to create a safe branch name
+ */
+export function generateBranchName(prompt: string): string {
+	// Take first 50 chars, convert to lowercase, replace spaces and special chars with hyphens
+	const sanitized = prompt
+		.slice(0, 50)
+		.toLowerCase()
+		.replace(/[^a-z0-9]+/g, "-")
+		.replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens
+		.replace(/-+/g, "-") // Collapse multiple hyphens
+
+	// Add timestamp to ensure uniqueness
+	const timestamp = Date.now()
+	return `${sanitized || "kilo"}-${timestamp}`
+}
+
+/**
+ * Check if a directory is a git worktree
+ * @param cwd - Current working directory path
+ * @returns True if directory is a git worktree, false otherwise
+ */
+export async function isGitWorktree(cwd: string): Promise<boolean> {
+	if (!cwd) {
+		return false
+	}
+
+	try {
+		const git: SimpleGit = simpleGit(cwd)
+		const isRepo = await git.checkIsRepo()
+		if (!isRepo) {
+			return false
+		}
+
+		// In a worktree, --git-dir points to .git/worktrees/<name>
+		// In a normal repo, --git-dir points to .git
+		const gitDir = await git.revparse(["--git-dir"])
+		return gitDir.trim().includes("worktrees")
+	} catch (error) {
+		logs.debug("Failed to check if git worktree", "GitUtils", { error, cwd })
+		return false
+	}
+}

+ 1 - 0
package.json

@@ -36,6 +36,7 @@
 		"cli:build": "turbo run cli:build",
 		"cli:bundle": "turbo run cli:bundle --force",
 		"cli:deps": "pnpm --filter @kilocode/cli run deps:install",
+		"cli:dev": "pnpm --filter @kilocode/cli run dev",
 		"cli:run": "pnpm --filter @kilocode/cli run start",
 		"docs:start": "pnpm --filter kilocode-docs start",
 		"docs:build": "pnpm --filter kilocode-docs build"