feat: align desktop repository sessions with CLI worktrees

Desktop repository launches now defer isolated worktree creation until the
first user turn so the CLI owns worktree setup, cwd initialization, and
session metadata. The chat UI surfaces the pre-startup Git phase so users see
when a session is creating a worktree or switching a branch before model
output begins.

Constraint: Desktop must preserve the selected source checkout until a user actually sends a message
Constraint: CLI setup is the canonical owner for worktree creation and cwd initialization
Rejected: Create the worktree eagerly in the desktop session picker | it diverges from CLI session startup and creates worktrees before a conversation exists
Confidence: high
Scope-risk: moderate
Directive: Keep repository session startup routed through CLI worktree flags; do not reintroduce eager desktop worktree creation without testing transcript cwd and cleanup behavior
Tested: bun test src/server/__tests__/sessions.test.ts src/server/__tests__/conversation-service.test.ts
Tested: bun test src/server/__tests__/conversations.test.ts
Tested: bun test src/server/__tests__/conversations.test.ts --test-name-pattern "worktree startup status"
Tested: cd desktop && bun run test -- src/pages/EmptySession.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: git diff --check
Tested: CLI init-only native worktree smoke from feature/rail
Tested: agent-browser UI flow with MiniMax-M2.7-highspeed in a /tmp repository
This commit is contained in:
程序员阿江(Relakkes) 2026-05-08 15:58:11 +08:00
parent 984967a60e
commit b40ffb0714
15 changed files with 470 additions and 89 deletions

View File

@ -1,5 +1,6 @@
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation, type TranslationKey } from '../../i18n'
function formatElapsed(seconds: number): string {
if (seconds < 60) return `${seconds}s`
@ -8,7 +9,17 @@ function formatElapsed(seconds: number): string {
return `${m}m ${s}s`
}
function translateServerVerb(
t: (key: TranslationKey) => string,
verb: string,
): string {
const key = `serverVerb.${verb}` as TranslationKey
const translated = t(key)
return translated === key ? verb : translated
}
export function StreamingIndicator() {
const t = useTranslation()
const activeTabId = useTabStore((s) => s.activeTabId)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const chatState = sessionState?.chatState ?? 'idle'
@ -17,9 +28,13 @@ export function StreamingIndicator() {
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
let verb: string
if (statusVerb) {
verb = statusVerb
verb = translateServerVerb(t, statusVerb)
} else {
verb = chatState === 'thinking' ? 'Thinking' : chatState === 'tool_executing' ? 'Running' : 'Working'
verb = chatState === 'thinking'
? t('serverVerb.Thinking')
: chatState === 'tool_executing'
? t('serverVerb.Running')
: t('serverVerb.Working')
}
return (

View File

@ -234,18 +234,6 @@ export function RepositoryLaunchControls({
return null
}, [context, selectedBranch, t, useWorktree])
const requiresIsolation = useMemo(() => {
if (context?.state !== 'ok' || !selectedBranch) return false
if (selectedBranch.name === context.currentBranch) return false
return context.dirty || selectedBranch.checkedOut
}, [context, selectedBranch])
useEffect(() => {
if (requiresIsolation && !useWorktree) {
onUseWorktreeChange(true)
}
}, [onUseWorktreeChange, requiresIsolation, useWorktree])
const selectBranch = (candidate: RepositoryBranchInfo) => {
onBranchChange(candidate.name)
setBranchMenuOpen(false)
@ -253,7 +241,6 @@ export function RepositoryLaunchControls({
}
const selectWorktreeMode = (enabled: boolean) => {
if (!enabled && requiresIsolation) return
onUseWorktreeChange(enabled)
setWorktreeMenuOpen(false)
}
@ -481,8 +468,6 @@ export function RepositoryLaunchControls({
type="button"
role="option"
aria-selected={!useWorktree}
aria-disabled={requiresIsolation}
disabled={requiresIsolation}
onClick={() => selectWorktreeMode(false)}
className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-45 ${
!useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'

View File

@ -707,8 +707,8 @@ export const en = {
'repoLaunch.worktreeIsolated': 'Isolated worktree',
'repoLaunch.selectWorktree': 'Select worktree mode',
'repoLaunch.missingWorkdir': 'Working directory is missing.',
'repoLaunch.dirtyWarning': 'Uncommitted changes detected. Direct switching will be blocked; enable isolated worktree to continue without touching this folder.',
'repoLaunch.checkedOutWarning': 'This branch is already checked out elsewhere. Enable isolated worktree or choose another branch.',
'repoLaunch.dirtyWarning': 'Uncommitted changes detected. Direct switching may be blocked; use isolated worktree to continue without touching this folder.',
'repoLaunch.checkedOutWarning': 'Selected branch is already checked out in another worktree. Direct launch may be blocked by Git; use "Isolated worktree" to avoid changing directories.',
// ─── Chat Input ──────────────────────────────────────
'chat.placeholder': 'Ask Claude to edit, debug or explain...',
@ -1190,6 +1190,9 @@ export const en = {
// ─── Server Status Verbs ──────────────────────────────────────
'serverVerb.Thinking': 'Thinking',
'serverVerb.Running': 'Running',
'serverVerb.Working': 'Working',
'serverVerb.Creating worktree': 'Creating worktree',
'serverVerb.Task started': 'Task started',
'serverVerb.Task in progress': 'Task in progress',

View File

@ -709,8 +709,8 @@ export const zh: Record<TranslationKey, string> = {
'repoLaunch.worktreeIsolated': '独立工作树',
'repoLaunch.selectWorktree': '选择工作树模式',
'repoLaunch.missingWorkdir': '工作目录不存在。',
'repoLaunch.dirtyWarning': '检测到未提交变更,直接切换会被阻止。开启独立工作树即可继续,且不会改动当前目录。',
'repoLaunch.checkedOutWarning': '这个分支已在其他工作树中检出。请开启独立工作树或选择其他分支。',
'repoLaunch.dirtyWarning': '检测到未提交变更,直接切换可能会被阻止;使用独立工作树可以继续,且不会改动当前目录。',
'repoLaunch.checkedOutWarning': '选中的分支已在其他工作树中检出。直接启动可能会被 Git 阻止;使用“独立工作树”可以避免切换当前目录。',
// ─── Chat Input ──────────────────────────────────────
'chat.placeholder': '让 Claude 编辑、调试或解释代码...',
@ -1192,6 +1192,9 @@ export const zh: Record<TranslationKey, string> = {
// ─── Server Status Verbs ──────────────────────────────────────
'serverVerb.Thinking': '思考中',
'serverVerb.Running': '运行中',
'serverVerb.Working': '处理中',
'serverVerb.Creating worktree': '正在创建工作树',
'serverVerb.Task started': '任务已启动',
'serverVerb.Task in progress': '任务进行中',

View File

@ -418,7 +418,7 @@ describe('EmptySession', () => {
expect(branchButton.querySelector('span')?.className).toContain('truncate')
})
it('defaults to isolated worktree when the fallback branch is checked out elsewhere', async () => {
it('keeps current worktree selectable when the fallback branch is checked out elsewhere', async () => {
mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({
currentBranch: null,
defaultBranch: 'main',
@ -455,18 +455,20 @@ describe('EmptySession', () => {
await waitFor(() => {
expect(screen.getByText('main')).toBeInTheDocument()
expect(screen.getByText('Isolated worktree')).toBeInTheDocument()
expect(screen.getByText('Current worktree')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /Select worktree mode: Isolated worktree/ }))
expect(await screen.findByRole('option', { name: 'Current worktree' })).toBeDisabled()
expect(screen.getByText('Selected branch is already checked out in another worktree. Direct launch may be blocked by Git; use "Isolated worktree" to avoid changing directories.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Select worktree mode: Current worktree/ }))
expect(await screen.findByRole('option', { name: 'Current worktree' })).not.toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({
workDir: '/workspace/project',
repository: { branch: 'main', worktree: true },
repository: { branch: 'main', worktree: false },
})
})
})

View File

@ -1147,6 +1147,9 @@ async function run(): Promise<CommanderCommand> {
const worktreeOption = isWorktreeModeEnabled() ? (options as {
worktree?: boolean | string;
}).worktree : undefined;
const worktreeBaseRef = isWorktreeModeEnabled() ? (options as {
worktreeBaseRef?: string;
}).worktreeBaseRef : undefined;
let worktreeName = typeof worktreeOption === 'string' ? worktreeOption : undefined;
const worktreeEnabled = worktreeOption !== undefined;
@ -1940,7 +1943,7 @@ async function run(): Promise<CommanderCommand> {
const {
setup
} = await import('./setup.js');
await setup(preSetupCwd, permissionMode, allowDangerouslySkipPermissions, worktreeEnabled, worktreeName, tmuxEnabled, sessionId ? validateUuid(sessionId) : undefined, worktreePRNumber, messagingSocketPath);
await setup(preSetupCwd, permissionMode, allowDangerouslySkipPermissions, worktreeEnabled, worktreeName, tmuxEnabled, sessionId ? validateUuid(sessionId) : undefined, worktreePRNumber, worktreeBaseRef, messagingSocketPath);
})();
const commandsPromise = worktreeEnabled ? null : getCommands(preSetupCwd);
const agentDefsPromise = worktreeEnabled ? null : getAgentDefinitionsWithOverrides(preSetupCwd);
@ -3863,6 +3866,7 @@ async function run(): Promise<CommanderCommand> {
// Worktree flags
program.option('-w, --worktree [name]', 'Create a new git worktree for this session (optionally specify a name)');
program.addOption(new Option('--worktree-base-ref <ref>', 'Create --worktree from this git ref instead of the default branch').hideHelp());
program.option('--tmux', 'Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.');
if (canUserConfigureAdvisor()) {
program.addOption(new Option('--advisor <model>', 'Enable the server-side advisor tool with the specified model (alias or full ID).').hideHelp());

View File

@ -348,4 +348,27 @@ describe('ConversationService', () => {
expect(args).toContain('--effort')
expect(args).toContain('max')
})
test('buildSessionCliArgs starts pending desktop worktrees through the native CLI flag', () => {
const service = new ConversationService() as any
const args = service.buildSessionCliArgs(
'123e4567-e89b-12d3-a456-426614174000',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
false,
undefined,
{
requestedWorkDir: '/tmp/source-repo',
repoRoot: '/tmp/source-repo',
branch: 'feature/rail',
worktree: true,
baseRef: 'feature/rail',
worktreeSlug: 'desktop-feature-rail-123e4567',
},
) as string[]
expect(args).toContain('--worktree')
expect(args).toContain('desktop-feature-rail-123e4567')
expect(args).toContain('--worktree-base-ref')
expect(args).toContain('feature/rail')
})
})

View File

@ -9,9 +9,10 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { ConversationService, ConversationStartupError, conversationService } from '../services/conversationService.js'
import { SessionService } from '../services/sessionService.js'
import { SessionService, sessionService } from '../services/sessionService.js'
import { ProviderService } from '../services/providerService.js'
async function rmWithRetry(targetPath: string): Promise<void> {
@ -456,6 +457,36 @@ describe('WebSocket Chat Integration', () => {
let wsUrl: string
let tmpDir: string
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
})
}
async function createCleanGitRepo(): Promise<string> {
const workDir = path.join(
tmpDir,
`repo-${Date.now()}-${Math.random().toString(36).slice(2)}`,
)
await fs.mkdir(workDir, { recursive: true })
git(workDir, 'init')
git(workDir, 'config', 'user.email', 'conversations@example.com')
git(workDir, 'config', 'user.name', 'Conversations Test')
git(workDir, 'checkout', '-b', 'main')
await fs.writeFile(path.join(workDir, 'README.md'), 'main\n')
git(workDir, 'add', 'README.md')
git(workDir, 'commit', '-m', 'initial')
git(workDir, 'checkout', '-b', 'feature/rail')
await fs.writeFile(path.join(workDir, 'feature.txt'), 'feature\n')
git(workDir, 'add', 'feature.txt')
git(workDir, 'commit', '-m', 'feature')
git(workDir, 'checkout', 'main')
return workDir
}
async function withMockInitMode<T>(
mode: string | undefined,
callback: () => Promise<T>,
@ -817,6 +848,37 @@ describe('WebSocket Chat Integration', () => {
expect(statusMsgs[0].state).toBe('thinking')
})
it('emits a worktree startup status before launching a repository session', async () => {
const repoDir = await createCleanGitRepo()
const { sessionId } = await sessionService.createSession(repoDir, {
branch: 'feature/rail',
worktree: true,
})
const messages = await runTurn(sessionId, 'Hello from repository launch test')
const statusVerbs = messages
.filter((msg) => msg.type === 'status')
.map((msg) => msg.verb)
expect(statusVerbs).toContain('Creating worktree')
})
it('keeps the default startup status for current-worktree repository sessions', async () => {
const repoDir = await createCleanGitRepo()
const { sessionId } = await sessionService.createSession(repoDir, {
branch: 'main',
worktree: false,
})
const messages = await runTurn(sessionId, 'Hello from current worktree launch test')
const statusVerbs = messages
.filter((msg) => msg.type === 'status')
.map((msg) => msg.verb)
expect(statusVerbs).toContain('Thinking')
expect(statusVerbs).not.toContain('Creating worktree')
})
it('emits the derived session title before the first response completes', async () => {
const sessionId = `title-fast-${crypto.randomUUID()}`
const messages: any[] = []

View File

@ -9,7 +9,10 @@ import * as path from 'node:path'
import * as os from 'node:os'
import { SessionService } from '../services/sessionService.js'
import { sessionService } from '../services/sessionService.js'
import { getRepositoryContext } from '../services/repositoryLaunchService.js'
import {
getRepositoryContext,
prepareSessionWorkspace,
} from '../services/repositoryLaunchService.js'
import { conversationService } from '../services/conversationService.js'
import { clearCommandsCache } from '../../commands.js'
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
@ -846,6 +849,60 @@ describe('SessionService', () => {
expect(workDir).toBe('/tmp/from-meta')
})
it('should recover workDir from the latest session-meta entry', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [
makeSnapshotEntry(),
makeSessionMetaEntry('/tmp/old-worktree'),
makeUserEntry('Hello'),
makeSessionMetaEntry('/tmp/latest-worktree'),
])
const workDir = await service.getSessionWorkDir(sessionId)
expect(workDir).toBe('/tmp/latest-worktree')
})
it('should preserve repository metadata when replacing placeholder transcripts', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const { sessionId, workDir: sessionWorkDir } = await service.createSession(
workDir,
{ branch: 'feature/rail', worktree: true },
)
await service.clearSessionTranscript(sessionId, sessionWorkDir)
const launchInfo = await service.getSessionLaunchInfo(sessionId)
expect(launchInfo?.workDir).toBe(sessionWorkDir)
expect(launchInfo?.repository).toMatchObject({
requestedWorkDir: await fs.realpath(workDir),
worktree: true,
worktreePath: expect.stringContaining(path.join('.claude', 'worktrees', 'desktop-feature-rail-')),
})
})
it('should remove stale placeholder files after native CLI worktree startup', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const sourceFile = await writeSessionFile('-tmp-source', sessionId, [
makeSnapshotEntry(),
{ type: 'session-meta', isMeta: true, workDir: '/tmp/source', timestamp: '2026-01-01T00:00:00.000Z' },
{ type: 'session-meta', isMeta: true, workDir: '/tmp/source/.claude/worktrees/desktop-agent', timestamp: '2026-01-01T00:00:02.000Z' },
])
const worktreeFile = await writeSessionFile('-tmp-source--claude-worktrees-desktop-agent', sessionId, [
makeSnapshotEntry(),
{ type: 'session-meta', isMeta: true, workDir: '/tmp/source/.claude/worktrees/desktop-agent', timestamp: '2026-01-01T00:00:01.000Z' },
makeUserEntry('Hello from worktree'),
])
const removed = await service.deletePlaceholderSessionFiles(
sessionId,
'/tmp/source/.claude/worktrees/desktop-agent',
)
expect(removed).toBe(1)
await expect(fs.access(sourceFile)).rejects.toThrow()
await expect(fs.access(worktreeFile)).resolves.toBeNull()
})
it('should recover workDir from transcript cwd when session-meta is missing', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [
@ -885,41 +942,43 @@ describe('SessionService', () => {
expect(entry.type).toBe('file-history-snapshot')
})
it('should create a session in an isolated worktree from the selected branch', async () => {
it('should defer isolated worktree creation until CLI startup', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const { sessionId, workDir: sessionWorkDir } = await service.createSession(
workDir,
{ branch: 'feature/rail', worktree: true },
)
expect(sessionWorkDir).toContain(path.join('.claude', 'worktrees', 'desktop-feature-rail-'))
expect(git(sessionWorkDir, 'branch', '--show-current')).toStartWith('worktree-desktop-feature-rail-')
expect(await fs.readFile(path.join(sessionWorkDir, 'feature.txt'), 'utf-8')).toBe('feature\n')
expect(sessionWorkDir).toBe(await fs.realpath(workDir))
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
expect(git(workDir, 'status', '--porcelain')).toBe('')
expect(await fs.readFile(path.join(workDir, '.git', 'info', 'exclude'), 'utf-8'))
.toContain('.claude/worktrees/')
const sanitized = sanitizePath(await fs.realpath(sessionWorkDir))
const sanitized = sanitizePath(await fs.realpath(workDir))
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n')
const metadata = JSON.parse(lines[1]!)
expect(metadata.workDir).toBe(sessionWorkDir)
const plannedWorktreePath = metadata.repository.worktreePath as string
expect(metadata.workDir).toBe(await fs.realpath(workDir))
expect(metadata.repository).toMatchObject({
requestedWorkDir: await fs.realpath(workDir),
branch: 'feature/rail',
worktree: true,
worktreePath: sessionWorkDir,
baseRef: 'feature/rail',
worktreePath: expect.stringContaining(path.join('.claude', 'worktrees', 'desktop-feature-rail-')),
worktreeBranch: expect.stringContaining('worktree-desktop-feature-rail-'),
worktreeSlug: expect.stringContaining('desktop-feature-rail-'),
})
await expect(fs.access(plannedWorktreePath)).rejects.toThrow()
const context = await getRepositoryContext(workDir)
expect(context.state).toBe('ok')
expect(context.branches.map((branch) => branch.name)).not.toContain(
path.basename(sessionWorkDir).replace(/^desktop-/, 'worktree-desktop-'),
path.basename(plannedWorktreePath).replace(/^desktop-/, 'worktree-desktop-'),
)
expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false)
})
it('should switch a clean checkout to the selected branch when worktree isolation is disabled', async () => {
it('should defer direct branch switching until CLI startup when worktree isolation is disabled', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const { sessionId, workDir: sessionWorkDir } = await service.createSession(
workDir,
@ -927,8 +986,7 @@ describe('SessionService', () => {
)
expect(sessionWorkDir).toBe(await fs.realpath(workDir))
expect(git(workDir, 'branch', '--show-current')).toBe('feature/rail\n')
expect(await fs.readFile(path.join(workDir, 'feature.txt'), 'utf-8')).toBe('feature\n')
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
const sanitized = sanitizePath(await fs.realpath(workDir))
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
@ -943,53 +1001,55 @@ describe('SessionService', () => {
})
})
it('should not select hidden desktop worktree branches when no branch is requested', async () => {
it('should not list hidden desktop worktree branches', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const { workDir: firstWorktreeDir } = await service.createSession(
workDir,
{ branch: 'feature/rail', worktree: true },
)
const existingWorktree = path.join(tmpDir, `desktop-hidden-${Date.now()}`)
git(workDir, 'worktree', 'add', '-b', 'worktree-desktop-hidden', existingWorktree, 'feature/rail')
const { workDir: nestedWorktreeDir } = await service.createSession(
firstWorktreeDir,
{ worktree: true },
)
expect(git(existingWorktree, 'branch', '--show-current')).toBe('worktree-desktop-hidden\n')
expect(nestedWorktreeDir).toContain(path.join('.claude', 'worktrees', 'desktop-'))
expect(git(nestedWorktreeDir, 'branch', '--show-current')).toStartWith('worktree-desktop-')
const context = await getRepositoryContext(firstWorktreeDir)
const context = await getRepositoryContext(existingWorktree)
expect(context.state).toBe('ok')
expect(context.currentBranch).toStartWith('worktree-desktop-')
expect(context.currentBranch).toBe('worktree-desktop-hidden')
expect(context.branches.some((branch) => branch.name === context.currentBranch)).toBe(false)
expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false)
})
it('should reject direct branch launch from a dirty working tree with a stable error code', async () => {
it('should defer dirty direct branch launch validation until CLI startup', async () => {
const workDir = await createCleanGitRepo(tmpDir)
await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n')
await expect(service.createSession(
const { sessionId } = await service.createSession(
workDir,
{ branch: 'feature/rail', worktree: false },
)).rejects.toMatchObject({ code: 'REPOSITORY_DIRTY_WORKTREE' })
)
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8'))
.toContain('local-pricing-edit')
await expect(prepareSessionWorkspace(
workDir,
{ branch: 'feature/rail', worktree: false },
sessionId,
)).rejects.toMatchObject({ code: 'REPOSITORY_DIRTY_WORKTREE' })
})
it('should reject direct branch launch when the branch is checked out elsewhere', async () => {
it('should defer checked-out direct branch launch validation until CLI startup', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const existingWorktree = path.join(tmpDir, `existing-feature-rail-${Date.now()}`)
git(workDir, 'worktree', 'add', existingWorktree, 'feature/rail')
await expect(service.createSession(
const { sessionId } = await service.createSession(
workDir,
{ branch: 'feature/rail', worktree: false },
)).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_CHECKED_OUT' })
)
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
await expect(prepareSessionWorkspace(
workDir,
{ branch: 'feature/rail', worktree: false },
sessionId,
)).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_CHECKED_OUT' })
})
it('should reject branch launch outside Git repositories with a stable error code', async () => {
@ -1270,7 +1330,7 @@ describe('Sessions API', () => {
expect(body.worktrees.some((worktree) => worktree.path === realWorkDir && worktree.current)).toBe(true)
})
it('GET /api/sessions/recent-projects should hide internal desktop worktree branch metadata', async () => {
it('GET /api/sessions/recent-projects should keep pending repository launches on the source project', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
@ -1292,8 +1352,8 @@ describe('Sessions API', () => {
const project = body.projects.find((candidate) => candidate.realPath === created.workDir)
expect(project).toBeDefined()
expect(project?.projectName).toBe(path.basename(workDir))
expect(project?.branch).toBeNull()
expect(project?.realPath).toContain('/.claude/worktrees/')
expect(project?.branch).toBe('main')
expect(project?.realPath).toBe(await fs.realpath(workDir))
})
it('GET /api/sessions/:id should return session detail', async () => {

View File

@ -12,6 +12,10 @@ import * as path from 'node:path'
import { ProviderService } from './providerService.js'
import { sessionService } from './sessionService.js'
import { diagnosticsService } from './diagnosticsService.js'
import {
prepareSessionWorkspace,
type PreparedSessionWorkspace,
} from './repositoryLaunchService.js'
import {
buildClaudeCliArgs,
resolveClaudeCliLauncher,
@ -90,8 +94,19 @@ export class ConversationService {
sdkUrl: string,
shouldResume: boolean,
options?: SessionStartOptions,
repository?: PreparedSessionWorkspace['repository'],
): string[] {
const dangerousMode = process.env.CLAUDE_DANGEROUS_MODE === '1'
const worktreeArgs =
!shouldResume && repository?.worktree
? [
'--worktree',
repository.worktreeSlug || repository.worktreeBranch || repository.branch,
'--worktree-base-ref',
repository.baseRef,
]
: []
return this.resolveCliArgs([
'--print',
'--verbose',
@ -106,6 +121,7 @@ export class ConversationService {
// server only sees the completed assistant message at turn end.
'--include-partial-messages',
...(shouldResume ? ['--resume', sessionId] : ['--session-id', sessionId]),
...worktreeArgs,
'--replay-user-messages',
...this.getRuntimeArgs(options),
...this.getPermissionArgs(options?.permissionMode, dangerousMode),
@ -149,15 +165,40 @@ export class ConversationService {
await sessionService.clearSessionTranscript(sessionId, workDir)
}
let launchWorkDir = workDir
let launchRepository = launchInfo?.repository
if (!shouldResume && launchRepository?.worktree) {
launchWorkDir = launchRepository.requestedWorkDir || launchRepository.repoRoot || workDir
} else if (!shouldResume && launchRepository) {
const preparedWorkspace = await prepareSessionWorkspace(
workDir,
{
branch: launchRepository.branch,
worktree: false,
},
sessionId,
)
launchWorkDir = preparedWorkspace.workDir
launchRepository = preparedWorkspace.repository
}
if (!fs.existsSync(launchWorkDir) || !fs.statSync(launchWorkDir).isDirectory()) {
throw new ConversationStartupError(
`Working directory does not exist or is not a directory: ${launchWorkDir}`,
'WORKDIR_INVALID',
)
}
const args = this.buildSessionCliArgs(
sessionId,
sdkUrl,
shouldResume,
options,
launchRepository,
)
console.log(
`[ConversationService] Starting CLI for ${sessionId}, cwd: ${workDir} (process.cwd()=${process.cwd()}, CALLER_DIR will be pinned to workDir)`,
`[ConversationService] Starting CLI for ${sessionId}, cwd: ${launchWorkDir} (process.cwd()=${process.cwd()}, CALLER_DIR will be pinned to workDir)`,
)
// IMPORTANT (Bug#5): 必须覆盖子进程继承的 CALLER_DIR / PWD。
@ -170,12 +211,12 @@ export class ConversationService {
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDirpreload.ts
// chdir 后落到正确目录。
//
const childEnv = await this.buildChildEnv(workDir, sdkUrl, options)
const childEnv = await this.buildChildEnv(launchWorkDir, sdkUrl, options)
let proc: ReturnType<typeof Bun.spawn>
try {
proc = Bun.spawn(args, {
cwd: workDir,
cwd: launchWorkDir,
env: childEnv,
stdin: 'pipe',
stdout: 'pipe',
@ -196,7 +237,7 @@ export class ConversationService {
},
})
throw new ConversationStartupError(
`Failed to spawn CLI in ${workDir}: ${
`Failed to spawn CLI in ${launchWorkDir}: ${
spawnErr instanceof Error ? spawnErr.message : String(spawnErr)
}`,
'CLI_SPAWN_FAILED',
@ -206,7 +247,7 @@ export class ConversationService {
const session: SessionProcess = {
proc,
outputCallbacks: [],
workDir,
workDir: launchWorkDir,
permissionMode: options?.permissionMode || 'default',
sdkToken: this.getSdkTokenFromUrl(sdkUrl),
sdkSocket: null,
@ -264,7 +305,7 @@ export class ConversationService {
code: startupError.code,
exitCode: startupExitCode,
retryable: startupError.retryable,
workDir,
workDir: launchWorkDir,
permissionMode: options?.permissionMode || 'default',
providerId: options?.providerId ?? null,
model: options?.model ?? null,
@ -279,8 +320,9 @@ export class ConversationService {
if (shouldReplacePlaceholder || !launchInfo) {
await sessionService.appendSessionMetadata(sessionId, {
workDir,
workDir: launchWorkDir,
customTitle: launchInfo?.customTitle ?? null,
repository: launchRepository,
})
}

View File

@ -78,6 +78,7 @@ export type PreparedSessionWorkspace = {
baseRef: string
worktreePath?: string
worktreeBranch?: string
worktreeSlug?: string
}
}
@ -421,6 +422,38 @@ async function createDesktopWorktree(
baseRef: branch.baseRef,
worktreePath,
worktreeBranch: branchName,
worktreeSlug: slug,
},
}
}
function planIsolatedWorktree(
context: RepositoryContextResult,
branch: ResolvedBranch,
sessionId: string,
): PreparedSessionWorkspace {
if (context.state !== 'ok' || !context.repoRoot) {
throw repositoryBadRequest(
REPOSITORY_ERROR.notGit,
'Cannot create a worktree outside a Git repository',
)
}
const slug = safeWorktreeSlug(branch.name, sessionId)
const worktreePath = path.join(context.repoRoot, '.claude', 'worktrees', slug)
const branchName = worktreeBranchName(slug)
return {
workDir: context.workDir,
repository: {
requestedWorkDir: context.workDir,
repoRoot: context.repoRoot,
branch: branch.name,
worktree: true,
baseRef: branch.baseRef,
worktreePath,
worktreeBranch: branchName,
worktreeSlug: slug,
},
}
}
@ -529,3 +562,56 @@ export async function prepareSessionWorkspace(
? createDesktopWorktree(context, branch, sessionId)
: switchExistingCheckout(context, branch)
}
export async function resolveSessionWorkspaceLaunch(
workDir: string,
options: CreateSessionRepositoryOptions | undefined,
sessionId: string,
): Promise<PreparedSessionWorkspace> {
const absWorkDir = await resolveDirectory(workDir)
if (!options?.branch && !options?.worktree) {
return { workDir: absWorkDir }
}
const context = await getRepositoryContext(absWorkDir)
if (context.state !== 'ok') {
if (context.state === 'not_git_repo') {
throw repositoryBadRequest(
REPOSITORY_ERROR.notGit,
'Selected directory is not a Git repository',
)
}
if (context.state === 'missing_workdir') {
throw repositoryBadRequest(
REPOSITORY_ERROR.workdirMissing,
context.error || 'Working directory does not exist',
)
}
throw repositoryBadRequest(
REPOSITORY_ERROR.contextFailed,
context.error || 'Failed to inspect Git repository',
)
}
const branch = resolveBranch(context, options.branch)
if (!branch) {
throw repositoryBadRequest(
REPOSITORY_ERROR.branchNotFound,
`Branch not found: ${options.branch || 'default branch'}`,
)
}
return options.worktree
? planIsolatedWorktree(context, branch, sessionId)
: {
workDir: context.workDir,
repository: {
requestedWorkDir: context.workDir,
repoRoot: context.repoRoot,
branch: branch.name,
worktree: false,
baseRef: branch.baseRef,
},
}
}

View File

@ -20,8 +20,9 @@ import {
} from '../../utils/context.js'
import { getCanonicalName } from '../../utils/model/model.js'
import {
prepareSessionWorkspace,
resolveSessionWorkspaceLaunch,
type CreateSessionRepositoryOptions,
type PreparedSessionWorkspace,
} from './repositoryLaunchService.js'
// ============================================================================
@ -47,6 +48,7 @@ export type SessionLaunchInfo = {
filePath: string
projectDir: string
workDir: string
repository?: PreparedSessionWorkspace['repository']
transcriptMessageCount: number
customTitle: string | null
}
@ -253,7 +255,8 @@ export class SessionService {
entries: RawEntry[],
fallbackProjectDir?: string,
): string | null {
for (const entry of entries) {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]
if (entry.type === 'session-meta' && typeof (entry as Record<string, unknown>).workDir === 'string') {
return (entry as Record<string, unknown>).workDir as string
}
@ -269,6 +272,24 @@ export class SessionService {
return fallbackProjectDir ? this.desanitizePath(fallbackProjectDir) : null
}
private resolveRepositoryFromEntries(entries: RawEntry[]): PreparedSessionWorkspace['repository'] | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const repository = (entries[i] as Record<string, unknown>)?.repository
if (repository && typeof repository === 'object') {
return repository as PreparedSessionWorkspace['repository']
}
}
return undefined
}
private countTranscriptMessages(entries: RawEntry[]): number {
return entries.filter((entry) =>
!entry.isMeta &&
!!entry.message?.role &&
(entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system')
).length
}
// --------------------------------------------------------------------------
// Entry → MessageEntry conversion
// --------------------------------------------------------------------------
@ -1213,7 +1234,7 @@ export class SessionService {
// expand relative paths — in bundled sidecar mode the server's cwd is
// typically '/'. Callers (IM adapters) already send absolute realPath,
// but we log here so cwd regressions are caught early.
const preparedWorkspace = await prepareSessionWorkspace(
const preparedWorkspace = await resolveSessionWorkspaceLaunch(
resolvedWorkDir,
repositoryOptions,
sessionId,
@ -1358,26 +1379,21 @@ export class SessionService {
const entries = await this.readJsonlFile(found.filePath)
const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd()
const repository = this.resolveRepositoryFromEntries(entries)
let customTitle: string | null = null
let transcriptMessageCount = 0
for (const entry of entries) {
if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') {
customTitle = entry.customTitle
}
if (
!entry.isMeta &&
entry.message?.role &&
(entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system')
) {
transcriptMessageCount++
}
}
const transcriptMessageCount = this.countTranscriptMessages(entries)
return {
filePath: found.filePath,
projectDir: found.projectDir,
workDir,
repository,
transcriptMessageCount,
customTitle,
}
@ -1407,6 +1423,7 @@ export class SessionService {
const entries = await this.readJsonlFile(found.filePath)
const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || fallbackWorkDir || process.cwd()
const repository = this.resolveRepositoryFromEntries(entries)
const now = new Date().toISOString()
const initialEntry = {
@ -1424,6 +1441,7 @@ export class SessionService {
type: 'session-meta',
isMeta: true,
workDir,
repository,
timestamp: now,
}
@ -1436,15 +1454,22 @@ export class SessionService {
async appendSessionMetadata(
sessionId: string,
metadata: { workDir: string; customTitle?: string | null }
metadata: {
workDir: string
customTitle?: string | null
repository?: PreparedSessionWorkspace['repository']
}
): Promise<void> {
const found = await this.findSessionFile(sessionId)
if (!found) return
const entries = await this.readJsonlFile(found.filePath)
const repository = metadata.repository ?? this.resolveRepositoryFromEntries(entries)
await this.appendJsonlEntry(found.filePath, {
type: 'session-meta',
isMeta: true,
workDir: metadata.workDir,
repository,
timestamp: new Date().toISOString(),
})
@ -1457,6 +1482,38 @@ export class SessionService {
}
}
async deletePlaceholderSessionFiles(
sessionId: string,
keepWorkDir: string,
): Promise<number> {
if (!this.isValidSessionId(sessionId)) return 0
const projectsDir = this.getProjectsDir()
let projectDirs: import('node:fs').Dirent[]
try {
projectDirs = await fs.readdir(projectsDir, { withFileTypes: true })
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0
throw err
}
const keepProjectDir = this.sanitizePath(keepWorkDir)
let removed = 0
for (const projectDir of projectDirs) {
if (!projectDir.isDirectory()) continue
if (projectDir.name === keepProjectDir) continue
const filePath = path.join(projectsDir, projectDir.name, `${sessionId}.jsonl`)
const entries = await this.readJsonlFile(filePath)
if (entries.length === 0) continue
if (this.countTranscriptMessages(entries) > 0) continue
await fs.rm(filePath, { force: true })
removed += 1
}
return removed
}
async trimSessionMessagesFrom(
sessionId: string,
startMessageId: string,

View File

@ -69,6 +69,22 @@ const prewarmedSessions = new Set<string>()
const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000
async function sendRepositoryStartupStatus(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
reason: 'user_message' | 'prewarm_session',
): Promise<void> {
if (reason !== 'user_message') return
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
const repository = launchInfo?.repository
if (!repository) return
if (repository.worktree) {
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Creating worktree' })
}
}
export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> {
return sessionSlashCommands.get(sessionId) || []
}
@ -164,7 +180,7 @@ export const handleWebSocket = {
break
case 'prewarm_session':
handlePrewarmSession(ws)
void handlePrewarmSession(ws)
break
case 'stop_generation':
@ -368,12 +384,18 @@ async function handleDesktopClearCommand(
})
}
function handlePrewarmSession(ws: ServerWebSocket<WebSocketData>) {
async function handlePrewarmSession(ws: ServerWebSocket<WebSocketData>) {
const { sessionId } = ws.data
if (conversationService.hasSession(sessionId) || sessionStartupPromises.has(sessionId)) {
return
}
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
if (launchInfo?.repository) {
console.log(`[WS] Skipping prewarm for pending repository launch session ${sessionId}`)
return
}
prewarmPendingSessions.add(sessionId)
void ensureCliSessionStarted(ws, sessionId, 'prewarm_session')
.then(() => {
@ -752,6 +774,14 @@ function markPrewarmed(sessionId: string) {
function cacheSessionInitMetadata(sessionId: string, cliMsg: any) {
if (cliMsg?.type !== 'system' || cliMsg.subtype !== 'init') return
if (typeof cliMsg.cwd === 'string' && cliMsg.cwd.trim()) {
void (async () => {
await sessionService.appendSessionMetadata(sessionId, {
workDir: cliMsg.cwd,
})
await sessionService.deletePlaceholderSessionFiles(sessionId, cliMsg.cwd)
})()
}
if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) {
sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({
name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''),
@ -837,6 +867,7 @@ async function ensureCliSessionStarted(
const sdkUrl =
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
`?token=${encodeURIComponent(crypto.randomUUID())}`
await sendRepositoryStartupStatus(ws, sessionId, reason)
console.log(`[WS] Starting CLI for ${sessionId} due to ${reason}`)
await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings)
})()

View File

@ -62,6 +62,7 @@ export async function setup(
tmuxEnabled: boolean,
customSessionId?: string | null,
worktreePRNumber?: number,
worktreeBaseRef?: string,
messagingSocketPath?: string,
): Promise<void> {
logForDiagnosticsNoPII('info', 'setup_started')
@ -244,7 +245,12 @@ export async function setup(
getSessionId(),
slug,
tmuxSessionName,
worktreePRNumber ? { prNumber: worktreePRNumber } : undefined,
worktreePRNumber || worktreeBaseRef
? {
...(worktreePRNumber ? { prNumber: worktreePRNumber } : {}),
...(worktreeBaseRef ? { baseRef: worktreeBaseRef } : {}),
}
: undefined,
)
} catch (error) {
process.stderr.write(

View File

@ -265,7 +265,7 @@ function worktreePathFor(repoRoot: string, slug: string): string {
async function getOrCreateWorktree(
repoRoot: string,
slug: string,
options?: { prNumber?: number },
options?: { prNumber?: number; baseRef?: string },
): Promise<WorktreeCreateResult> {
const worktreePath = worktreePathFor(repoRoot, slug)
const worktreeBranch = worktreeBranchName(slug)
@ -305,6 +305,8 @@ async function getOrCreateWorktree(
)
}
baseBranch = 'FETCH_HEAD'
} else if (options?.baseRef) {
baseBranch = options.baseRef
} else {
// If origin/<branch> already exists locally, skip fetch. In large repos
// (210k files, 16M objects) fetch burns ~6-8s on a local commit-graph
@ -734,7 +736,7 @@ export async function createWorktreeForSession(
sessionId: string,
slug: string,
tmuxSessionName?: string,
options?: { prNumber?: number },
options?: { prNumber?: number; baseRef?: string },
): Promise<WorktreeSession> {
// Must run before the hook branch below — hooks receive the raw slug as an
// argument, and the git branch builds a path from it via path.join.