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

View File

@ -234,18 +234,6 @@ export function RepositoryLaunchControls({
return null return null
}, [context, selectedBranch, t, useWorktree]) }, [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) => { const selectBranch = (candidate: RepositoryBranchInfo) => {
onBranchChange(candidate.name) onBranchChange(candidate.name)
setBranchMenuOpen(false) setBranchMenuOpen(false)
@ -253,7 +241,6 @@ export function RepositoryLaunchControls({
} }
const selectWorktreeMode = (enabled: boolean) => { const selectWorktreeMode = (enabled: boolean) => {
if (!enabled && requiresIsolation) return
onUseWorktreeChange(enabled) onUseWorktreeChange(enabled)
setWorktreeMenuOpen(false) setWorktreeMenuOpen(false)
} }
@ -481,8 +468,6 @@ export function RepositoryLaunchControls({
type="button" type="button"
role="option" role="option"
aria-selected={!useWorktree} aria-selected={!useWorktree}
aria-disabled={requiresIsolation}
disabled={requiresIsolation}
onClick={() => selectWorktreeMode(false)} 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 ${ 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)]' !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.worktreeIsolated': 'Isolated worktree',
'repoLaunch.selectWorktree': 'Select worktree mode', 'repoLaunch.selectWorktree': 'Select worktree mode',
'repoLaunch.missingWorkdir': 'Working directory is missing.', '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.dirtyWarning': 'Uncommitted changes detected. Direct switching may be blocked; use 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.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 Input ──────────────────────────────────────
'chat.placeholder': 'Ask Claude to edit, debug or explain...', 'chat.placeholder': 'Ask Claude to edit, debug or explain...',
@ -1190,6 +1190,9 @@ export const en = {
// ─── Server Status Verbs ────────────────────────────────────── // ─── Server Status Verbs ──────────────────────────────────────
'serverVerb.Thinking': 'Thinking', 'serverVerb.Thinking': 'Thinking',
'serverVerb.Running': 'Running',
'serverVerb.Working': 'Working',
'serverVerb.Creating worktree': 'Creating worktree',
'serverVerb.Task started': 'Task started', 'serverVerb.Task started': 'Task started',
'serverVerb.Task in progress': 'Task in progress', 'serverVerb.Task in progress': 'Task in progress',

View File

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

View File

@ -418,7 +418,7 @@ describe('EmptySession', () => {
expect(branchButton.querySelector('span')?.className).toContain('truncate') 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({ mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({
currentBranch: null, currentBranch: null,
defaultBranch: 'main', defaultBranch: 'main',
@ -455,18 +455,20 @@ describe('EmptySession', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('main')).toBeInTheDocument() 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(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()
expect(await screen.findByRole('option', { name: 'Current worktree' })).toBeDisabled()
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 })) fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => { await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({ expect(mocks.createSession).toHaveBeenCalledWith({
workDir: '/workspace/project', 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 { const worktreeOption = isWorktreeModeEnabled() ? (options as {
worktree?: boolean | string; worktree?: boolean | string;
}).worktree : undefined; }).worktree : undefined;
const worktreeBaseRef = isWorktreeModeEnabled() ? (options as {
worktreeBaseRef?: string;
}).worktreeBaseRef : undefined;
let worktreeName = typeof worktreeOption === 'string' ? worktreeOption : undefined; let worktreeName = typeof worktreeOption === 'string' ? worktreeOption : undefined;
const worktreeEnabled = worktreeOption !== undefined; const worktreeEnabled = worktreeOption !== undefined;
@ -1940,7 +1943,7 @@ async function run(): Promise<CommanderCommand> {
const { const {
setup setup
} = await import('./setup.js'); } = 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 commandsPromise = worktreeEnabled ? null : getCommands(preSetupCwd);
const agentDefsPromise = worktreeEnabled ? null : getAgentDefinitionsWithOverrides(preSetupCwd); const agentDefsPromise = worktreeEnabled ? null : getAgentDefinitionsWithOverrides(preSetupCwd);
@ -3863,6 +3866,7 @@ async function run(): Promise<CommanderCommand> {
// Worktree flags // Worktree flags
program.option('-w, --worktree [name]', 'Create a new git worktree for this session (optionally specify a name)'); 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.'); 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()) { if (canUserConfigureAdvisor()) {
program.addOption(new Option('--advisor <model>', 'Enable the server-side advisor tool with the specified model (alias or full ID).').hideHelp()); 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('--effort')
expect(args).toContain('max') 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 fs from 'fs/promises'
import * as path from 'path' import * as path from 'path'
import * as os from 'os' import * as os from 'os'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { ConversationService, ConversationStartupError, conversationService } from '../services/conversationService.js' 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' import { ProviderService } from '../services/providerService.js'
async function rmWithRetry(targetPath: string): Promise<void> { async function rmWithRetry(targetPath: string): Promise<void> {
@ -456,6 +457,36 @@ describe('WebSocket Chat Integration', () => {
let wsUrl: string let wsUrl: string
let tmpDir: 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>( async function withMockInitMode<T>(
mode: string | undefined, mode: string | undefined,
callback: () => Promise<T>, callback: () => Promise<T>,
@ -817,6 +848,37 @@ describe('WebSocket Chat Integration', () => {
expect(statusMsgs[0].state).toBe('thinking') 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 () => { it('emits the derived session title before the first response completes', async () => {
const sessionId = `title-fast-${crypto.randomUUID()}` const sessionId = `title-fast-${crypto.randomUUID()}`
const messages: any[] = [] const messages: any[] = []

View File

@ -9,7 +9,10 @@ import * as path from 'node:path'
import * as os from 'node:os' import * as os from 'node:os'
import { SessionService } from '../services/sessionService.js' import { SessionService } from '../services/sessionService.js'
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 { conversationService } from '../services/conversationService.js'
import { clearCommandsCache } from '../../commands.js' import { clearCommandsCache } from '../../commands.js'
import { sanitizePath } from '../../utils/sessionStoragePortable.js' import { sanitizePath } from '../../utils/sessionStoragePortable.js'
@ -846,6 +849,60 @@ describe('SessionService', () => {
expect(workDir).toBe('/tmp/from-meta') 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 () => { it('should recover workDir from transcript cwd when session-meta is missing', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [ await writeSessionFile('-tmp-project', sessionId, [
@ -885,41 +942,43 @@ describe('SessionService', () => {
expect(entry.type).toBe('file-history-snapshot') 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 workDir = await createCleanGitRepo(tmpDir)
const { sessionId, workDir: sessionWorkDir } = await service.createSession( const { sessionId, workDir: sessionWorkDir } = await service.createSession(
workDir, workDir,
{ branch: 'feature/rail', worktree: true }, { branch: 'feature/rail', worktree: true },
) )
expect(sessionWorkDir).toContain(path.join('.claude', 'worktrees', 'desktop-feature-rail-')) expect(sessionWorkDir).toBe(await fs.realpath(workDir))
expect(git(sessionWorkDir, 'branch', '--show-current')).toStartWith('worktree-desktop-feature-rail-') expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
expect(await fs.readFile(path.join(sessionWorkDir, 'feature.txt'), 'utf-8')).toBe('feature\n')
expect(git(workDir, 'status', '--porcelain')).toBe('') 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 filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n') const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n')
const metadata = JSON.parse(lines[1]!) 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({ expect(metadata.repository).toMatchObject({
requestedWorkDir: await fs.realpath(workDir), requestedWorkDir: await fs.realpath(workDir),
branch: 'feature/rail', branch: 'feature/rail',
worktree: true, 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) const context = await getRepositoryContext(workDir)
expect(context.state).toBe('ok') expect(context.state).toBe('ok')
expect(context.branches.map((branch) => branch.name)).not.toContain( 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) 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 workDir = await createCleanGitRepo(tmpDir)
const { sessionId, workDir: sessionWorkDir } = await service.createSession( const { sessionId, workDir: sessionWorkDir } = await service.createSession(
workDir, workDir,
@ -927,8 +986,7 @@ describe('SessionService', () => {
) )
expect(sessionWorkDir).toBe(await fs.realpath(workDir)) expect(sessionWorkDir).toBe(await fs.realpath(workDir))
expect(git(workDir, 'branch', '--show-current')).toBe('feature/rail\n') expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
expect(await fs.readFile(path.join(workDir, 'feature.txt'), 'utf-8')).toBe('feature\n')
const sanitized = sanitizePath(await fs.realpath(workDir)) const sanitized = sanitizePath(await fs.realpath(workDir))
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`) 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 = await createCleanGitRepo(tmpDir)
const { workDir: firstWorktreeDir } = await service.createSession( const existingWorktree = path.join(tmpDir, `desktop-hidden-${Date.now()}`)
workDir, git(workDir, 'worktree', 'add', '-b', 'worktree-desktop-hidden', existingWorktree, 'feature/rail')
{ branch: 'feature/rail', worktree: true },
)
const { workDir: nestedWorktreeDir } = await service.createSession( expect(git(existingWorktree, 'branch', '--show-current')).toBe('worktree-desktop-hidden\n')
firstWorktreeDir,
{ worktree: true },
)
expect(nestedWorktreeDir).toContain(path.join('.claude', 'worktrees', 'desktop-')) const context = await getRepositoryContext(existingWorktree)
expect(git(nestedWorktreeDir, 'branch', '--show-current')).toStartWith('worktree-desktop-')
const context = await getRepositoryContext(firstWorktreeDir)
expect(context.state).toBe('ok') 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 === context.currentBranch)).toBe(false)
expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).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) const workDir = await createCleanGitRepo(tmpDir)
await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n') await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n')
await expect(service.createSession( const { sessionId } = await service.createSession(
workDir, workDir,
{ branch: 'feature/rail', worktree: false }, { branch: 'feature/rail', worktree: false },
)).rejects.toMatchObject({ code: 'REPOSITORY_DIRTY_WORKTREE' }) )
expect(git(workDir, 'branch', '--show-current')).toBe('main\n') expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8')) expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8'))
.toContain('local-pricing-edit') .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 workDir = await createCleanGitRepo(tmpDir)
const existingWorktree = path.join(tmpDir, `existing-feature-rail-${Date.now()}`) const existingWorktree = path.join(tmpDir, `existing-feature-rail-${Date.now()}`)
git(workDir, 'worktree', 'add', existingWorktree, 'feature/rail') git(workDir, 'worktree', 'add', existingWorktree, 'feature/rail')
await expect(service.createSession( const { sessionId } = await service.createSession(
workDir, workDir,
{ branch: 'feature/rail', worktree: false }, { branch: 'feature/rail', worktree: false },
)).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_CHECKED_OUT' }) )
expect(git(workDir, 'branch', '--show-current')).toBe('main\n') 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 () => { 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) 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 workDir = await createCleanGitRepo(tmpDir)
const createRes = await fetch(`${baseUrl}/api/sessions`, { const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST', method: 'POST',
@ -1292,8 +1352,8 @@ describe('Sessions API', () => {
const project = body.projects.find((candidate) => candidate.realPath === created.workDir) const project = body.projects.find((candidate) => candidate.realPath === created.workDir)
expect(project).toBeDefined() expect(project).toBeDefined()
expect(project?.projectName).toBe(path.basename(workDir)) expect(project?.projectName).toBe(path.basename(workDir))
expect(project?.branch).toBeNull() expect(project?.branch).toBe('main')
expect(project?.realPath).toContain('/.claude/worktrees/') expect(project?.realPath).toBe(await fs.realpath(workDir))
}) })
it('GET /api/sessions/:id should return session detail', async () => { 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 { ProviderService } from './providerService.js'
import { sessionService } from './sessionService.js' import { sessionService } from './sessionService.js'
import { diagnosticsService } from './diagnosticsService.js' import { diagnosticsService } from './diagnosticsService.js'
import {
prepareSessionWorkspace,
type PreparedSessionWorkspace,
} from './repositoryLaunchService.js'
import { import {
buildClaudeCliArgs, buildClaudeCliArgs,
resolveClaudeCliLauncher, resolveClaudeCliLauncher,
@ -90,8 +94,19 @@ export class ConversationService {
sdkUrl: string, sdkUrl: string,
shouldResume: boolean, shouldResume: boolean,
options?: SessionStartOptions, options?: SessionStartOptions,
repository?: PreparedSessionWorkspace['repository'],
): string[] { ): string[] {
const dangerousMode = process.env.CLAUDE_DANGEROUS_MODE === '1' 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([ return this.resolveCliArgs([
'--print', '--print',
'--verbose', '--verbose',
@ -106,6 +121,7 @@ export class ConversationService {
// server only sees the completed assistant message at turn end. // server only sees the completed assistant message at turn end.
'--include-partial-messages', '--include-partial-messages',
...(shouldResume ? ['--resume', sessionId] : ['--session-id', sessionId]), ...(shouldResume ? ['--resume', sessionId] : ['--session-id', sessionId]),
...worktreeArgs,
'--replay-user-messages', '--replay-user-messages',
...this.getRuntimeArgs(options), ...this.getRuntimeArgs(options),
...this.getPermissionArgs(options?.permissionMode, dangerousMode), ...this.getPermissionArgs(options?.permissionMode, dangerousMode),
@ -149,15 +165,40 @@ export class ConversationService {
await sessionService.clearSessionTranscript(sessionId, workDir) 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( const args = this.buildSessionCliArgs(
sessionId, sessionId,
sdkUrl, sdkUrl,
shouldResume, shouldResume,
options, options,
launchRepository,
) )
console.log( 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。 // IMPORTANT (Bug#5): 必须覆盖子进程继承的 CALLER_DIR / PWD。
@ -170,12 +211,12 @@ export class ConversationService {
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDirpreload.ts // 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDirpreload.ts
// chdir 后落到正确目录。 // chdir 后落到正确目录。
// //
const childEnv = await this.buildChildEnv(workDir, sdkUrl, options) const childEnv = await this.buildChildEnv(launchWorkDir, sdkUrl, options)
let proc: ReturnType<typeof Bun.spawn> let proc: ReturnType<typeof Bun.spawn>
try { try {
proc = Bun.spawn(args, { proc = Bun.spawn(args, {
cwd: workDir, cwd: launchWorkDir,
env: childEnv, env: childEnv,
stdin: 'pipe', stdin: 'pipe',
stdout: 'pipe', stdout: 'pipe',
@ -196,7 +237,7 @@ export class ConversationService {
}, },
}) })
throw new ConversationStartupError( throw new ConversationStartupError(
`Failed to spawn CLI in ${workDir}: ${ `Failed to spawn CLI in ${launchWorkDir}: ${
spawnErr instanceof Error ? spawnErr.message : String(spawnErr) spawnErr instanceof Error ? spawnErr.message : String(spawnErr)
}`, }`,
'CLI_SPAWN_FAILED', 'CLI_SPAWN_FAILED',
@ -206,7 +247,7 @@ export class ConversationService {
const session: SessionProcess = { const session: SessionProcess = {
proc, proc,
outputCallbacks: [], outputCallbacks: [],
workDir, workDir: launchWorkDir,
permissionMode: options?.permissionMode || 'default', permissionMode: options?.permissionMode || 'default',
sdkToken: this.getSdkTokenFromUrl(sdkUrl), sdkToken: this.getSdkTokenFromUrl(sdkUrl),
sdkSocket: null, sdkSocket: null,
@ -264,7 +305,7 @@ export class ConversationService {
code: startupError.code, code: startupError.code,
exitCode: startupExitCode, exitCode: startupExitCode,
retryable: startupError.retryable, retryable: startupError.retryable,
workDir, workDir: launchWorkDir,
permissionMode: options?.permissionMode || 'default', permissionMode: options?.permissionMode || 'default',
providerId: options?.providerId ?? null, providerId: options?.providerId ?? null,
model: options?.model ?? null, model: options?.model ?? null,
@ -279,8 +320,9 @@ export class ConversationService {
if (shouldReplacePlaceholder || !launchInfo) { if (shouldReplacePlaceholder || !launchInfo) {
await sessionService.appendSessionMetadata(sessionId, { await sessionService.appendSessionMetadata(sessionId, {
workDir, workDir: launchWorkDir,
customTitle: launchInfo?.customTitle ?? null, customTitle: launchInfo?.customTitle ?? null,
repository: launchRepository,
}) })
} }

View File

@ -78,6 +78,7 @@ export type PreparedSessionWorkspace = {
baseRef: string baseRef: string
worktreePath?: string worktreePath?: string
worktreeBranch?: string worktreeBranch?: string
worktreeSlug?: string
} }
} }
@ -421,6 +422,38 @@ async function createDesktopWorktree(
baseRef: branch.baseRef, baseRef: branch.baseRef,
worktreePath, worktreePath,
worktreeBranch: branchName, 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) ? createDesktopWorktree(context, branch, sessionId)
: switchExistingCheckout(context, branch) : 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' } from '../../utils/context.js'
import { getCanonicalName } from '../../utils/model/model.js' import { getCanonicalName } from '../../utils/model/model.js'
import { import {
prepareSessionWorkspace, resolveSessionWorkspaceLaunch,
type CreateSessionRepositoryOptions, type CreateSessionRepositoryOptions,
type PreparedSessionWorkspace,
} from './repositoryLaunchService.js' } from './repositoryLaunchService.js'
// ============================================================================ // ============================================================================
@ -47,6 +48,7 @@ export type SessionLaunchInfo = {
filePath: string filePath: string
projectDir: string projectDir: string
workDir: string workDir: string
repository?: PreparedSessionWorkspace['repository']
transcriptMessageCount: number transcriptMessageCount: number
customTitle: string | null customTitle: string | null
} }
@ -253,7 +255,8 @@ export class SessionService {
entries: RawEntry[], entries: RawEntry[],
fallbackProjectDir?: string, fallbackProjectDir?: string,
): string | null { ): 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') { if (entry.type === 'session-meta' && typeof (entry as Record<string, unknown>).workDir === 'string') {
return (entry as Record<string, unknown>).workDir as string return (entry as Record<string, unknown>).workDir as string
} }
@ -269,6 +272,24 @@ export class SessionService {
return fallbackProjectDir ? this.desanitizePath(fallbackProjectDir) : null 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 // Entry → MessageEntry conversion
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -1213,7 +1234,7 @@ export class SessionService {
// expand relative paths — in bundled sidecar mode the server's cwd is // expand relative paths — in bundled sidecar mode the server's cwd is
// typically '/'. Callers (IM adapters) already send absolute realPath, // typically '/'. Callers (IM adapters) already send absolute realPath,
// but we log here so cwd regressions are caught early. // but we log here so cwd regressions are caught early.
const preparedWorkspace = await prepareSessionWorkspace( const preparedWorkspace = await resolveSessionWorkspaceLaunch(
resolvedWorkDir, resolvedWorkDir,
repositoryOptions, repositoryOptions,
sessionId, sessionId,
@ -1358,26 +1379,21 @@ export class SessionService {
const entries = await this.readJsonlFile(found.filePath) const entries = await this.readJsonlFile(found.filePath)
const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd() const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd()
const repository = this.resolveRepositoryFromEntries(entries)
let customTitle: string | null = null let customTitle: string | null = null
let transcriptMessageCount = 0
for (const entry of entries) { for (const entry of entries) {
if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') {
customTitle = entry.customTitle 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 { return {
filePath: found.filePath, filePath: found.filePath,
projectDir: found.projectDir, projectDir: found.projectDir,
workDir, workDir,
repository,
transcriptMessageCount, transcriptMessageCount,
customTitle, customTitle,
} }
@ -1407,6 +1423,7 @@ export class SessionService {
const entries = await this.readJsonlFile(found.filePath) const entries = await this.readJsonlFile(found.filePath)
const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || fallbackWorkDir || process.cwd() const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || fallbackWorkDir || process.cwd()
const repository = this.resolveRepositoryFromEntries(entries)
const now = new Date().toISOString() const now = new Date().toISOString()
const initialEntry = { const initialEntry = {
@ -1424,6 +1441,7 @@ export class SessionService {
type: 'session-meta', type: 'session-meta',
isMeta: true, isMeta: true,
workDir, workDir,
repository,
timestamp: now, timestamp: now,
} }
@ -1436,15 +1454,22 @@ export class SessionService {
async appendSessionMetadata( async appendSessionMetadata(
sessionId: string, sessionId: string,
metadata: { workDir: string; customTitle?: string | null } metadata: {
workDir: string
customTitle?: string | null
repository?: PreparedSessionWorkspace['repository']
}
): Promise<void> { ): Promise<void> {
const found = await this.findSessionFile(sessionId) const found = await this.findSessionFile(sessionId)
if (!found) return if (!found) return
const entries = await this.readJsonlFile(found.filePath)
const repository = metadata.repository ?? this.resolveRepositoryFromEntries(entries)
await this.appendJsonlEntry(found.filePath, { await this.appendJsonlEntry(found.filePath, {
type: 'session-meta', type: 'session-meta',
isMeta: true, isMeta: true,
workDir: metadata.workDir, workDir: metadata.workDir,
repository,
timestamp: new Date().toISOString(), 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( async trimSessionMessagesFrom(
sessionId: string, sessionId: string,
startMessageId: string, startMessageId: string,

View File

@ -69,6 +69,22 @@ const prewarmedSessions = new Set<string>()
const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>() const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000 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 }> { export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> {
return sessionSlashCommands.get(sessionId) || [] return sessionSlashCommands.get(sessionId) || []
} }
@ -164,7 +180,7 @@ export const handleWebSocket = {
break break
case 'prewarm_session': case 'prewarm_session':
handlePrewarmSession(ws) void handlePrewarmSession(ws)
break break
case 'stop_generation': 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 const { sessionId } = ws.data
if (conversationService.hasSession(sessionId) || sessionStartupPromises.has(sessionId)) { if (conversationService.hasSession(sessionId) || sessionStartupPromises.has(sessionId)) {
return 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) prewarmPendingSessions.add(sessionId)
void ensureCliSessionStarted(ws, sessionId, 'prewarm_session') void ensureCliSessionStarted(ws, sessionId, 'prewarm_session')
.then(() => { .then(() => {
@ -752,6 +774,14 @@ function markPrewarmed(sessionId: string) {
function cacheSessionInitMetadata(sessionId: string, cliMsg: any) { function cacheSessionInitMetadata(sessionId: string, cliMsg: any) {
if (cliMsg?.type !== 'system' || cliMsg.subtype !== 'init') return 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)) { if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) {
sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({ sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({
name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''), name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''),
@ -837,6 +867,7 @@ async function ensureCliSessionStarted(
const sdkUrl = const sdkUrl =
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
`?token=${encodeURIComponent(crypto.randomUUID())}` `?token=${encodeURIComponent(crypto.randomUUID())}`
await sendRepositoryStartupStatus(ws, sessionId, reason)
console.log(`[WS] Starting CLI for ${sessionId} due to ${reason}`) console.log(`[WS] Starting CLI for ${sessionId} due to ${reason}`)
await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings) await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings)
})() })()

View File

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

View File

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