Skip to content

Commit 1f34157

Browse files
authored
feat(ide): every CoDev agent launches in its own worktree (stage 1) (#12)
Stage 1 of worktree-per-agent: every embedded CoDev agent now launches in its own git worktree + branch instead of sharing the workspace's single clone, so two agents never edit the same tree. typecheck:web clean; 72 tests pass (touched modules + new helper tests). All new behaviour is isCodevEmbedded()-gated; stock Orca is unchanged. Needs a preview deploy of the merged bundle to verify end-to-end. Stage 2 (auto-merge on idle + Mission Control merge UI) follows as its own PR.
1 parent bf5fce6 commit 1f34157

8 files changed

Lines changed: 428 additions & 90 deletions

packages/ide/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTar
88
import { useDetectedAgents } from '@/hooks/useDetectedAgents'
99
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
1010
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
11+
import { maybeLaunchCodevAgentInOwnWorktree } from '@/web/codev-launch-agent-worktree'
1112
import type { TuiAgent } from '../../../../shared/types'
1213
import type { LaunchSource } from '../../../../shared/telemetry-events'
1314
import {
@@ -126,6 +127,21 @@ function QuickLaunchAgentMenuItemsInner({
126127
(agent: TuiAgent) => {
127128
const entry = getCatalogEntry(agent)
128129
const label = entry?.label ?? agent
130+
// CoDev-embedded: every agent gets its own worktree. The host creates the
131+
// tab, so focus follows the next session-tabs snapshot (as for paired
132+
// launches), and nothing local to focus here.
133+
if (
134+
maybeLaunchCodevAgentInOwnWorktree({
135+
agent,
136+
baseWorktreeId: worktreeId,
137+
...(prompt !== undefined ? { prompt } : {}),
138+
...(promptDelivery !== undefined ? { promptDelivery } : {}),
139+
...(launchSource !== undefined ? { launchSource } : {})
140+
})
141+
) {
142+
onPromptDelivered?.()
143+
return
144+
}
129145
const result = launchAgentInNewTab({
130146
agent,
131147
worktreeId,

packages/ide/src/renderer/src/components/tab-bar/TabBar.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
4141
import { useDetectedAgents } from '@/hooks/useDetectedAgents'
4242
import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTarget'
4343
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
44+
import { maybeLaunchCodevAgentInOwnWorktree } from '@/web/codev-launch-agent-worktree'
4445
import { normalizeRelativePath } from '@/lib/path'
4546
import {
4647
getWindowsTerminalCapabilityOwnerKey,
@@ -589,6 +590,18 @@ function TabBarInner({
589590
}
590591
const launchAgentFromNewTabEntry = (agent: TuiAgent): void => {
591592
const option = agentLaunchOptions.find((candidate) => candidate.agent === agent)
593+
// CoDev-embedded: isolate the agent in its own worktree; the host creates
594+
// the tab and focus follows the next session-tabs snapshot.
595+
if (
596+
maybeLaunchCodevAgentInOwnWorktree({
597+
agent,
598+
baseWorktreeId: worktreeId,
599+
launchSource: 'tab_bar_quick_launch'
600+
})
601+
) {
602+
queueNewActiveTerminalFocusAfterNewTabMenuClose()
603+
return
604+
}
592605
const result = launchAgentInNewTab({
593606
agent,
594607
worktreeId,

packages/ide/src/renderer/src/lib/launch-agent-in-new-tab.ts

Lines changed: 13 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { useAppStore } from '@/store'
22
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
3-
import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan'
4-
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
5-
import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform'
3+
import { resolveAgentLaunchStartup } from '@/lib/resolve-agent-launch-startup'
64
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
75
import { tuiAgentToAgentKind } from '@/lib/telemetry'
86
import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice'
@@ -13,21 +11,12 @@ import {
1311
import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode'
1412
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
1513
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
16-
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
1714
import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session'
1815
import { launchAgentInWebHostTab } from '@/lib/launch-agent-web-host-tab'
19-
import {
20-
resolveTuiAgentLaunchArgs,
21-
resolveTuiAgentLaunchEnv
22-
} from '../../../shared/tui-agent-launch-defaults'
23-
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
24-
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
25-
import { repoIsRemote } from '../../../shared/agent-launch-remote'
2616
import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt-status-seed'
2717
import type { TuiAgent } from '../../../shared/types'
2818
import type { LaunchSource } from '../../../shared/telemetry-events'
2919
import { getConnectionIdFromState } from '@/lib/connection-context'
30-
import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults'
3120
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
3221

3322
export type LaunchAgentInNewTabArgs = {
@@ -84,61 +73,20 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
8473
onPromptDelivered
8574
} = args
8675
const store = useAppStore.getState()
87-
const worktree = store.allWorktrees?.().find((entry: { id: string }) => entry.id === worktreeId)
88-
const repo = worktree ? store.repos?.find((entry) => entry.id === worktree.repoId) : null
89-
const resolvedLaunchPlatform =
90-
launchPlatform ??
91-
(repo
92-
? getAgentLaunchPlatformForRepo(
93-
repo,
94-
repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId)
95-
)
96-
: CLIENT_PLATFORM)
97-
// Why: SSH remotes deploy the shim as plain `orca`, so skip the Linux-only `codev` rename for remote launches.
98-
const isRemote = repo ? repoIsRemote(repo) : false
99-
const queuedShell = resolveLocalWindowsAgentStartupShell({
100-
platform: resolvedLaunchPlatform,
101-
isRemote,
102-
terminalWindowsShell: store.settings?.terminalWindowsShell
103-
})
104-
const cmdOverrides = store.settings?.agentCmdOverrides ?? {}
105-
const effectiveAgentArgs =
106-
agentArgs !== undefined
107-
? agentArgs
108-
: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs)
109-
// Tag the launch with the signed-in CoDev member so the host runs this
110-
// agent on *their* linked subscription. A shared workspace runs one
111-
// `orca serve`, so its own environment can only ever hold one member's
112-
// credential; the main process swaps this marker for the launching member's
113-
// real env at spawn (src/main/codev-member-agent-env.ts). An id, never a
114-
// credential — nothing secret passes through the browser.
115-
const agentEnv = {
116-
...resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv),
117-
...(typeof window !== 'undefined' && window.__CODEV_MEMBER_ID__
118-
? { CODEV_AGENT_MEMBER: window.__CODEV_MEMBER_ID__ }
119-
: {})
120-
}
121-
const startupPlanBase = {
76+
const {
77+
startupPlan,
78+
pasteDraftAfterLaunch,
79+
submitPastedPrompt,
80+
hasPrompt,
81+
trimmedPrompt,
82+
isFollowupPath
83+
} = resolveAgentLaunchStartup({
12284
agent,
123-
cmdOverrides,
124-
platform: resolvedLaunchPlatform,
125-
shell: queuedShell,
126-
isRemote,
127-
agentArgs: effectiveAgentArgs,
128-
agentEnv,
129-
sessionOptions: resolveNativeChatSessionOptionDefaults(
130-
store.settings?.nativeChatSessionOptions,
131-
agent
132-
)
133-
}
134-
const trimmedPrompt = prompt?.trim() ?? ''
135-
const hasPrompt = trimmedPrompt.length > 0
136-
const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
137-
const { startupPlan, pasteDraftAfterLaunch, submitPastedPrompt } = planLaunchAgentStartupPrompt({
138-
base: startupPlanBase,
139-
prompt: trimmedPrompt,
85+
worktreeId,
86+
prompt,
14087
promptDelivery,
141-
isFollowupPath
88+
...(agentArgs !== undefined ? { agentArgs } : {}),
89+
...(launchPlatform !== undefined ? { launchPlatform } : {})
14290
})
14391
let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined
14492

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { useAppStore } from '@/store'
2+
import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan'
3+
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
4+
import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform'
5+
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
6+
import {
7+
resolveTuiAgentLaunchArgs,
8+
resolveTuiAgentLaunchEnv
9+
} from '../../../shared/tui-agent-launch-defaults'
10+
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
11+
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
12+
import { repoIsRemote } from '../../../shared/agent-launch-remote'
13+
import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults'
14+
import type { TuiAgent } from '../../../shared/types'
15+
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
16+
17+
/**
18+
* Shared startup-plan resolution for launching a TUI agent: platform, shell,
19+
* argv/env defaults, the CoDev per-member credential marker, and the
20+
* prompt-delivery plan. Factored out of `launchAgentInNewTab` so a launch that
21+
* first creates its own worktree (`codev-launch-agent-worktree.ts`) builds the
22+
* exact same plan.
23+
*/
24+
export type ResolvedAgentLaunchStartup = {
25+
resolvedLaunchPlatform: NodeJS.Platform
26+
isRemote: boolean
27+
startupPlanBase: {
28+
agent: TuiAgent
29+
cmdOverrides: Record<string, string>
30+
platform: NodeJS.Platform
31+
shell: ReturnType<typeof resolveLocalWindowsAgentStartupShell>
32+
isRemote: boolean
33+
agentArgs: string | null
34+
agentEnv: Record<string, string>
35+
sessionOptions: ReturnType<typeof resolveNativeChatSessionOptionDefaults>
36+
}
37+
startupPlan: AgentStartupPlan | null
38+
pasteDraftAfterLaunch: string | null
39+
submitPastedPrompt: boolean
40+
hasPrompt: boolean
41+
trimmedPrompt: string
42+
effectiveAgentArgs: string | null
43+
isFollowupPath: boolean
44+
}
45+
46+
export function resolveAgentLaunchStartup(args: {
47+
agent: TuiAgent
48+
worktreeId: string
49+
prompt?: string
50+
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
51+
agentArgs?: string | null
52+
launchPlatform?: NodeJS.Platform
53+
}): ResolvedAgentLaunchStartup {
54+
const { agent, worktreeId, prompt, promptDelivery, agentArgs, launchPlatform } = args
55+
const store = useAppStore.getState()
56+
const worktree = store.allWorktrees?.().find((entry: { id: string }) => entry.id === worktreeId)
57+
const repo = worktree ? store.repos?.find((entry) => entry.id === worktree.repoId) : null
58+
const resolvedLaunchPlatform =
59+
launchPlatform ??
60+
(repo
61+
? getAgentLaunchPlatformForRepo(
62+
repo,
63+
repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId)
64+
)
65+
: CLIENT_PLATFORM)
66+
const isRemote = repo ? repoIsRemote(repo) : false
67+
const queuedShell = resolveLocalWindowsAgentStartupShell({
68+
platform: resolvedLaunchPlatform,
69+
isRemote,
70+
terminalWindowsShell: store.settings?.terminalWindowsShell
71+
})
72+
const cmdOverrides = store.settings?.agentCmdOverrides ?? {}
73+
const effectiveAgentArgs =
74+
agentArgs !== undefined
75+
? agentArgs
76+
: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs)
77+
// An id, never a credential — the host swaps this marker for the launching
78+
// member's real env at spawn (src/main/codev-member-agent-env.ts).
79+
const agentEnv = {
80+
...resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv),
81+
...(typeof window !== 'undefined' && window.__CODEV_MEMBER_ID__
82+
? { CODEV_AGENT_MEMBER: window.__CODEV_MEMBER_ID__ }
83+
: {})
84+
}
85+
const startupPlanBase = {
86+
agent,
87+
cmdOverrides,
88+
platform: resolvedLaunchPlatform,
89+
shell: queuedShell,
90+
isRemote,
91+
agentArgs: effectiveAgentArgs,
92+
agentEnv,
93+
sessionOptions: resolveNativeChatSessionOptionDefaults(
94+
store.settings?.nativeChatSessionOptions,
95+
agent
96+
)
97+
}
98+
const trimmedPrompt = prompt?.trim() ?? ''
99+
const hasPrompt = trimmedPrompt.length > 0
100+
const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
101+
const { startupPlan, pasteDraftAfterLaunch, submitPastedPrompt } = planLaunchAgentStartupPrompt({
102+
base: startupPlanBase,
103+
prompt: trimmedPrompt,
104+
promptDelivery,
105+
isFollowupPath
106+
})
107+
return {
108+
resolvedLaunchPlatform,
109+
isRemote,
110+
startupPlanBase,
111+
startupPlan,
112+
pasteDraftAfterLaunch,
113+
submitPastedPrompt,
114+
hasPrompt,
115+
trimmedPrompt,
116+
effectiveAgentArgs,
117+
isFollowupPath
118+
}
119+
}

packages/ide/src/renderer/src/web/codev-chat-provider-switch.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useAppStore } from '@/store'
33
import type { AgentType } from '../../../shared/agent-status-types'
44
import type { CodevDefaultChatAgent } from './codev-bootstrap'
55
import { isCodevEmbedded } from './codev-embedded'
6+
import { launchCodevAgentInOwnWorktree } from './codev-launch-agent-worktree'
67

78
function worktreeIdForTerminalTab(tabId: string): string | null {
89
const { tabsByWorktree } = useAppStore.getState()
@@ -58,12 +59,26 @@ export function switchCodevChatProvider(args: {
5859
return
5960
}
6061

61-
launchAgentInNewTab({
62-
agent: nextAgent,
63-
worktreeId,
64-
promptDelivery: 'draft',
65-
launchSource: 'new_workspace_composer'
66-
})
62+
// Every CoDev agent gets its own worktree, so switching provider starts the
63+
// new provider in a fresh worktree off the workspace's current checkout, not
64+
// in this agent's tree. The previous tab (and, once empty, its worktree) is
65+
// retired by the existing close path below.
66+
const createdInOwnWorktree = isCodevEmbedded()
67+
? launchCodevAgentInOwnWorktree({
68+
agent: nextAgent,
69+
baseWorktreeId: worktreeId,
70+
launchSource: 'new_workspace_composer'
71+
})
72+
: null
73+
74+
if (!createdInOwnWorktree) {
75+
launchAgentInNewTab({
76+
agent: nextAgent,
77+
worktreeId,
78+
promptDelivery: 'draft',
79+
launchSource: 'new_workspace_composer'
80+
})
81+
}
6782

6883
setTimeout(() => {
6984
const state = useAppStore.getState()

0 commit comments

Comments
 (0)