mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +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 { 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 (
|
||||||
|
|||||||
@ -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)]'
|
||||||
|
|||||||
@ -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',
|
||||||
|
|
||||||
|
|||||||
@ -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': '任务进行中',
|
||||||
|
|
||||||
|
|||||||
@ -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 },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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[] = []
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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 显式覆盖成 workDir,preload.ts
|
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
})()
|
})()
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user