| import { z } from 'zod/v4' |
| import { |
| getOriginalCwd, |
| getProjectRoot, |
| setOriginalCwd, |
| setProjectRoot, |
| } from '../../bootstrap/state.js' |
| import { clearSystemPromptSections } from '../../constants/systemPromptSections.js' |
| import { logEvent } from '../../services/analytics/index.js' |
| import type { Tool } from '../../Tool.js' |
| import { buildTool, type ToolDef } from '../../Tool.js' |
| import { count } from '../../utils/array.js' |
| import { clearMemoryFileCaches } from '../../utils/claudemd.js' |
| import { execFileNoThrow } from '../../utils/execFileNoThrow.js' |
| import { updateHooksConfigSnapshot } from '../../utils/hooks/hooksConfigSnapshot.js' |
| import { lazySchema } from '../../utils/lazySchema.js' |
| import { getPlansDirectory } from '../../utils/plans.js' |
| import { setCwd } from '../../utils/Shell.js' |
| import { saveWorktreeState } from '../../utils/sessionStorage.js' |
| import { |
| cleanupWorktree, |
| getCurrentWorktreeSession, |
| keepWorktree, |
| killTmuxSession, |
| } from '../../utils/worktree.js' |
| import { EXIT_WORKTREE_TOOL_NAME } from './constants.js' |
| import { getExitWorktreeToolPrompt } from './prompt.js' |
| import { renderToolResultMessage, renderToolUseMessage } from './UI.js' |
|
|
| const inputSchema = lazySchema(() => |
| z.strictObject({ |
| action: z |
| .enum(['keep', 'remove']) |
| .describe( |
| '"keep" leaves the worktree and branch on disk; "remove" deletes both.', |
| ), |
| discard_changes: z |
| .boolean() |
| .optional() |
| .describe( |
| 'Required true when action is "remove" and the worktree has uncommitted files or unmerged commits. The tool will refuse and list them otherwise.', |
| ), |
| }), |
| ) |
| type InputSchema = ReturnType<typeof inputSchema> |
|
|
| const outputSchema = lazySchema(() => |
| z.object({ |
| action: z.enum(['keep', 'remove']), |
| originalCwd: z.string(), |
| worktreePath: z.string(), |
| worktreeBranch: z.string().optional(), |
| tmuxSessionName: z.string().optional(), |
| discardedFiles: z.number().optional(), |
| discardedCommits: z.number().optional(), |
| message: z.string(), |
| }), |
| ) |
| type OutputSchema = ReturnType<typeof outputSchema> |
| export type Output = z.infer<OutputSchema> |
|
|
| type ChangeSummary = { |
| changedFiles: number |
| commits: number |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function countWorktreeChanges( |
| worktreePath: string, |
| originalHeadCommit: string | undefined, |
| ): Promise<ChangeSummary | null> { |
| const status = await execFileNoThrow('git', [ |
| '-C', |
| worktreePath, |
| 'status', |
| '--porcelain', |
| ]) |
| if (status.code !== 0) { |
| return null |
| } |
| const changedFiles = count(status.stdout.split('\n'), l => l.trim() !== '') |
|
|
| if (!originalHeadCommit) { |
| |
| |
| return null |
| } |
|
|
| const revList = await execFileNoThrow('git', [ |
| '-C', |
| worktreePath, |
| 'rev-list', |
| '--count', |
| `${originalHeadCommit}..HEAD`, |
| ]) |
| if (revList.code !== 0) { |
| return null |
| } |
| const commits = parseInt(revList.stdout.trim(), 10) || 0 |
|
|
| return { changedFiles, commits } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function restoreSessionToOriginalCwd( |
| originalCwd: string, |
| projectRootIsWorktree: boolean, |
| ): void { |
| setCwd(originalCwd) |
| |
| |
| setOriginalCwd(originalCwd) |
| |
| |
| |
| |
| |
| if (projectRootIsWorktree) { |
| setProjectRoot(originalCwd) |
| |
| |
| |
| updateHooksConfigSnapshot() |
| } |
| saveWorktreeState(null) |
| clearSystemPromptSections() |
| clearMemoryFileCaches() |
| getPlansDirectory.cache.clear?.() |
| } |
|
|
| export const ExitWorktreeTool: Tool<InputSchema, Output> = buildTool({ |
| name: EXIT_WORKTREE_TOOL_NAME, |
| searchHint: 'exit a worktree session and return to the original directory', |
| maxResultSizeChars: 100_000, |
| async description() { |
| return 'Exits a worktree session created by EnterWorktree and restores the original working directory' |
| }, |
| async prompt() { |
| return getExitWorktreeToolPrompt() |
| }, |
| get inputSchema(): InputSchema { |
| return inputSchema() |
| }, |
| get outputSchema(): OutputSchema { |
| return outputSchema() |
| }, |
| userFacingName() { |
| return 'Exiting worktree' |
| }, |
| shouldDefer: true, |
| isDestructive(input) { |
| return input.action === 'remove' |
| }, |
| toAutoClassifierInput(input) { |
| return input.action |
| }, |
| async validateInput(input) { |
| |
| |
| |
| |
| |
| const session = getCurrentWorktreeSession() |
| if (!session) { |
| return { |
| result: false, |
| message: |
| 'No-op: there is no active EnterWorktree session to exit. This tool only operates on worktrees created by EnterWorktree in the current session — it will not touch worktrees created manually or in a previous session. No filesystem changes were made.', |
| errorCode: 1, |
| } |
| } |
|
|
| if (input.action === 'remove' && !input.discard_changes) { |
| const summary = await countWorktreeChanges( |
| session.worktreePath, |
| session.originalHeadCommit, |
| ) |
| if (summary === null) { |
| return { |
| result: false, |
| message: `Could not verify worktree state at ${session.worktreePath}. Refusing to remove without explicit confirmation. Re-invoke with discard_changes: true to proceed — or use action: "keep" to preserve the worktree.`, |
| errorCode: 3, |
| } |
| } |
| const { changedFiles, commits } = summary |
| if (changedFiles > 0 || commits > 0) { |
| const parts: string[] = [] |
| if (changedFiles > 0) { |
| parts.push( |
| `${changedFiles} uncommitted ${changedFiles === 1 ? 'file' : 'files'}`, |
| ) |
| } |
| if (commits > 0) { |
| parts.push( |
| `${commits} ${commits === 1 ? 'commit' : 'commits'} on ${session.worktreeBranch ?? 'the worktree branch'}`, |
| ) |
| } |
| return { |
| result: false, |
| message: `Worktree has ${parts.join(' and ')}. Removing will discard this work permanently. Confirm with the user, then re-invoke with discard_changes: true — or use action: "keep" to preserve the worktree.`, |
| errorCode: 2, |
| } |
| } |
| } |
|
|
| return { result: true } |
| }, |
| renderToolUseMessage, |
| renderToolResultMessage, |
| async call(input) { |
| const session = getCurrentWorktreeSession() |
| if (!session) { |
| |
| |
| throw new Error('Not in a worktree session') |
| } |
|
|
| |
| const { |
| originalCwd, |
| worktreePath, |
| worktreeBranch, |
| tmuxSessionName, |
| originalHeadCommit, |
| } = session |
|
|
| |
| |
| |
| |
| |
| |
| const projectRootIsWorktree = getProjectRoot() === getOriginalCwd() |
|
|
| |
| |
| |
| |
| const { changedFiles, commits } = (await countWorktreeChanges( |
| worktreePath, |
| originalHeadCommit, |
| )) ?? { changedFiles: 0, commits: 0 } |
|
|
| if (input.action === 'keep') { |
| await keepWorktree() |
| restoreSessionToOriginalCwd(originalCwd, projectRootIsWorktree) |
|
|
| logEvent('tengu_worktree_kept', { |
| mid_session: true, |
| commits, |
| changed_files: changedFiles, |
| }) |
|
|
| const tmuxNote = tmuxSessionName |
| ? ` Tmux session ${tmuxSessionName} is still running; reattach with: tmux attach -t ${tmuxSessionName}` |
| : '' |
| return { |
| data: { |
| action: 'keep' as const, |
| originalCwd, |
| worktreePath, |
| worktreeBranch, |
| tmuxSessionName, |
| message: `Exited worktree. Your work is preserved at ${worktreePath}${worktreeBranch ? ` on branch ${worktreeBranch}` : ''}. Session is now back in ${originalCwd}.${tmuxNote}`, |
| }, |
| } |
| } |
|
|
| |
| if (tmuxSessionName) { |
| await killTmuxSession(tmuxSessionName) |
| } |
| await cleanupWorktree() |
| restoreSessionToOriginalCwd(originalCwd, projectRootIsWorktree) |
|
|
| logEvent('tengu_worktree_removed', { |
| mid_session: true, |
| commits, |
| changed_files: changedFiles, |
| }) |
|
|
| const discardParts: string[] = [] |
| if (commits > 0) { |
| discardParts.push(`${commits} ${commits === 1 ? 'commit' : 'commits'}`) |
| } |
| if (changedFiles > 0) { |
| discardParts.push( |
| `${changedFiles} uncommitted ${changedFiles === 1 ? 'file' : 'files'}`, |
| ) |
| } |
| const discardNote = |
| discardParts.length > 0 ? ` Discarded ${discardParts.join(' and ')}.` : '' |
| return { |
| data: { |
| action: 'remove' as const, |
| originalCwd, |
| worktreePath, |
| worktreeBranch, |
| discardedFiles: changedFiles, |
| discardedCommits: commits, |
| message: `Exited and removed worktree at ${worktreePath}.${discardNote} Session is now back in ${originalCwd}.`, |
| }, |
| } |
| }, |
| mapToolResultToToolResultBlockParam({ message }, toolUseID) { |
| return { |
| type: 'tool_result', |
| content: message, |
| tool_use_id: toolUseID, |
| } |
| }, |
| } satisfies ToolDef<InputSchema, Output>) |
|
|