mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
984967a60e
commit
b40ffb0714
@ -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 (
|
||||
|
||||
@ -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)]'
|
||||
|
||||
@ -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',
|
||||
|
||||
|
||||
@ -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': '任务进行中',
|
||||
|
||||
|
||||
@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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[] = []
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 显式覆盖成 workDir,preload.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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})()
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user