diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index a5c2bbdfe97..6f0409ba0d5 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1068,6 +1068,7 @@ async function readSandboxFile({ * @param {string} params.file_path * @param {string} params.workspace_id * @param {string} [params.workspace_instance_id] + * @param {boolean} [params.linked_worktrees] * @param {number} params.start_line * @param {number} params.max_lines * @param {string} params.codeApiBaseUrl @@ -1080,6 +1081,7 @@ async function readWorkspaceFile({ file_path, workspace_id, workspace_instance_id, + linked_worktrees, start_line, max_lines, codeApiBaseUrl, @@ -1093,6 +1095,7 @@ async function readWorkspaceFile({ }) { return executeWorkspaceTool({ baseURL: codeApiBaseUrl, + linkedWorktrees: linked_worktrees, maxQueueWaitMs, codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs, maxRequestTimeoutMs, @@ -1122,6 +1125,7 @@ async function readWorkspaceFile({ * @param {string} params.query * @param {string} params.workspace_id * @param {string} [params.workspace_instance_id] + * @param {boolean} [params.linked_worktrees] * @param {string} [params.path] * @param {number} params.max_results * @param {string} params.codeApiBaseUrl @@ -1134,6 +1138,7 @@ async function searchWorkspace({ query, workspace_id, workspace_instance_id, + linked_worktrees, path, max_results, codeApiBaseUrl, @@ -1147,6 +1152,7 @@ async function searchWorkspace({ }) { return executeWorkspaceTool({ baseURL: codeApiBaseUrl, + linkedWorktrees: linked_worktrees, maxQueueWaitMs, codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs, maxRequestTimeoutMs, @@ -1175,6 +1181,7 @@ async function searchWorkspace({ * @param {Object} params * @param {string} params.workspace_id * @param {string} [params.workspace_instance_id] + * @param {boolean} [params.linked_worktrees] * @param {string} [params.path] * @param {string} [params.after_path] * @param {number} params.max_results @@ -1187,6 +1194,7 @@ async function searchWorkspace({ async function listWorkspaceFiles({ workspace_id, workspace_instance_id, + linked_worktrees, path, after_path, max_results, @@ -1201,6 +1209,7 @@ async function listWorkspaceFiles({ }) { return executeWorkspaceTool({ baseURL: codeApiBaseUrl, + linkedWorktrees: linked_worktrees, maxQueueWaitMs, codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs, maxRequestTimeoutMs, @@ -1230,6 +1239,7 @@ async function writeWorkspaceFile({ overwrite, workspace_id, workspace_instance_id, + linked_worktrees, codeApiBaseUrl, executionProfile, bridgeWorkerId, @@ -1241,6 +1251,7 @@ async function writeWorkspaceFile({ }) { return executeWorkspaceTool({ baseURL: codeApiBaseUrl, + linkedWorktrees: linked_worktrees, maxQueueWaitMs, codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs, maxRequestTimeoutMs, @@ -1270,6 +1281,7 @@ async function editWorkspaceFile({ expected_base_sha256, workspace_id, workspace_instance_id, + linked_worktrees, codeApiBaseUrl, executionProfile, bridgeWorkerId, @@ -1281,6 +1293,7 @@ async function editWorkspaceFile({ }) { return executeWorkspaceTool({ baseURL: codeApiBaseUrl, + linkedWorktrees: linked_worktrees, maxQueueWaitMs, codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs, maxRequestTimeoutMs, @@ -1309,6 +1322,7 @@ async function previewWorkspaceEdit({ edits, workspace_id, workspace_instance_id, + linked_worktrees, codeApiBaseUrl, executionProfile, bridgeWorkerId, @@ -1320,6 +1334,7 @@ async function previewWorkspaceEdit({ }) { return executeWorkspaceTool({ baseURL: codeApiBaseUrl, + linkedWorktrees: linked_worktrees, maxQueueWaitMs, codeApiMaxRetryWaitMs: req?.config?.endpoints?.agents?.codeApiMaxRetryWaitMs, maxRequestTimeoutMs, diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index 65175294c02..54564260f1b 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -2199,6 +2199,7 @@ describe('Code Process', () => { await expect( listWorkspaceFiles({ workspace_id: 'primary', + linked_worktrees: true, path: 'src', after_path: 'src/app.ts', max_results: 20, @@ -2226,6 +2227,7 @@ describe('Code Process', () => { expect(getCodeApiAuthHeaders).toHaveBeenNthCalledWith(2, mockReq, 'worker-user-1'); expect(mockExecuteWorkspaceTool).toHaveBeenCalledWith({ baseURL: 'https://attached-code.example.com/v1', + linkedWorktrees: true, authHeaders: expect.any(Function), maxQueueWaitMs: 0, codeApiMaxRetryWaitMs: undefined, diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 5ce4e4d603b..51b97957cef 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -2308,6 +2308,7 @@ async function loadToolsForExecution({ baseUrl: codeExecutionContext.baseUrl, workspaceId: codeExecutionContext.codeWorkspace.workspaceId, workspaceInstanceId: codeExecutionContext.codeWorkspace.workspaceInstanceId, + linkedWorktrees: codeExecutionContext.codeWorkspace.linkedWorktrees, environment: codeExecutionContext.codeWorkspace.environment, gitIdentity: agent?.git_identity, maxTimeoutMs: resolveAttachedWorkspaceCommandTimeoutMax( diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 43bac3fd1db..c20c0af9e53 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -3020,6 +3020,7 @@ describe('initializeAgent — execute_code capability expansion', () => { workspaceId: 'project-a', operations: ['read_file', 'list_files', 'execute_command'], environment: { fingerprint: 'a'.repeat(64), repo: 'owner/project', actions: ['check'] }, + linkedWorktrees: true, }, }; if (protectedEdit) codeExecutionContext.codeWorkspace!.operations.push('edit_file'); @@ -3063,6 +3064,10 @@ describe('initializeAgent — execute_code capability expansion', () => { (bashTool?.parameters as { properties?: { timeoutMs?: { maximum?: number } } })?.properties ?.timeoutMs?.maximum, ).toBe(120_000); + expect( + (bashTool?.parameters as { properties?: { cwd?: { description?: string } } })?.properties + ?.cwd?.description, + ).toContain('.worktrees/'); }, ); diff --git a/packages/api/src/agents/execution.ts b/packages/api/src/agents/execution.ts index bf1768815bc..431beecfc80 100644 --- a/packages/api/src/agents/execution.ts +++ b/packages/api/src/agents/execution.ts @@ -44,6 +44,8 @@ export interface CodeExecutionContext { codeWorkspace?: CodeWorkspaceSelection & { operations: CodeWorkspaceOperation[]; workspaceInstanceId?: string; + /** The worker schedules each `.worktrees/` of this root as its own lane. */ + linkedWorktrees?: boolean; /** Live Code API execution ceiling. Omitted by older deployments. */ maxCommandTimeoutMs?: number; instructions?: CodeWorkspaceDescriptor['instructions']; diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index a239557eb96..0afc2299db9 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -563,6 +563,7 @@ export interface ToolExecuteOptions { file_path: string; workspace_id: string; workspace_instance_id?: string; + linked_worktrees?: boolean; start_line: number; max_lines: number; codeApiBaseUrl: string; @@ -579,6 +580,7 @@ export interface ToolExecuteOptions { query: string; workspace_id: string; workspace_instance_id?: string; + linked_worktrees?: boolean; path?: string; max_results: number; codeApiBaseUrl: string; @@ -594,6 +596,7 @@ export interface ToolExecuteOptions { listWorkspaceFiles?: (params: { workspace_id: string; workspace_instance_id?: string; + linked_worktrees?: boolean; path?: string; after_path?: string; max_results: number; @@ -613,6 +616,7 @@ export interface ToolExecuteOptions { overwrite: boolean; workspace_id: string; workspace_instance_id?: string; + linked_worktrees?: boolean; codeApiBaseUrl: string; executionProfile: CodeExecutionContext['executionProfile']; bridgeWorkerId?: string; @@ -628,6 +632,7 @@ export interface ToolExecuteOptions { edits: Array<{ oldText: string; newText: string }>; workspace_id: string; workspace_instance_id?: string; + linked_worktrees?: boolean; codeApiBaseUrl: string; executionProfile: CodeExecutionContext['executionProfile']; bridgeWorkerId?: string; @@ -644,6 +649,7 @@ export interface ToolExecuteOptions { expected_base_sha256?: string; workspace_id: string; workspace_instance_id?: string; + linked_worktrees?: boolean; codeApiBaseUrl: string; executionProfile: CodeExecutionContext['executionProfile']; bridgeWorkerId?: string; @@ -2474,6 +2480,7 @@ async function handleWorkspaceFileRead( ...(codeExecutionContext.codeWorkspace?.workspaceInstanceId ? { workspace_instance_id: codeExecutionContext.codeWorkspace.workspaceInstanceId } : {}), + ...(codeExecutionContext.codeWorkspace?.linkedWorktrees ? { linked_worktrees: true } : {}), start_line: startLine, max_lines: maxLines, codeApiBaseUrl: codeExecutionContext.baseUrl, @@ -2580,6 +2587,7 @@ async function handleWorkspaceSearchCall( ...(codeExecutionContext.codeWorkspace?.workspaceInstanceId ? { workspace_instance_id: codeExecutionContext.codeWorkspace.workspaceInstanceId } : {}), + ...(codeExecutionContext.codeWorkspace?.linkedWorktrees ? { linked_worktrees: true } : {}), ...(typeof args.path === 'string' && args.path.length > 0 ? { path: args.path } : {}), max_results: Number(maxResults), codeApiBaseUrl: codeExecutionContext.baseUrl, @@ -2668,6 +2676,7 @@ async function handleWorkspaceListCall( ...(codeExecutionContext.codeWorkspace?.workspaceInstanceId ? { workspace_instance_id: codeExecutionContext.codeWorkspace.workspaceInstanceId } : {}), + ...(codeExecutionContext.codeWorkspace?.linkedWorktrees ? { linked_worktrees: true } : {}), ...(typeof args.path === 'string' && args.path.length > 0 ? { path: args.path } : {}), ...(typeof args.after_path === 'string' && args.after_path.length > 0 ? { after_path: args.after_path } @@ -3939,6 +3948,7 @@ function attachedWorkspaceMutationParams( ): { workspace_id: string; workspace_instance_id?: string; + linked_worktrees?: boolean; codeApiBaseUrl: string; executionProfile: CodeExecutionContext['executionProfile']; bridgeWorkerId?: string; @@ -3954,6 +3964,7 @@ function attachedWorkspaceMutationParams( ...(codeExecutionContext.codeWorkspace?.workspaceInstanceId ? { workspace_instance_id: codeExecutionContext.codeWorkspace.workspaceInstanceId } : {}), + ...(codeExecutionContext.codeWorkspace?.linkedWorktrees ? { linked_worktrees: true } : {}), codeApiBaseUrl: codeExecutionContext.baseUrl, ...limits, ...(limits.maxRequestTimeoutMs == null diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 9a09ac949c2..6f0af0c7c74 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -2080,6 +2080,7 @@ export async function initializeAgent( workspaceOperations: attachedWorkspaceOperations, workspaceCommandTimeoutMaxMs: attachedWorkspaceCommandTimeoutMaxMs, workspaceEnvironment: trustedCodeExecutionContext.codeWorkspace?.environment, + workspaceLinkedWorktrees: trustedCodeExecutionContext.codeWorkspace?.linkedWorktrees, }); toolDefinitions = codeExecResult.toolDefinitions; recordCapabilityToolNames(AgentCapabilities.execute_code, codeExecResult.toolNames); @@ -2325,6 +2326,7 @@ export async function initializeAgent( userId: user?.id, workspaceCommandTimeoutMaxMs: attachedWorkspaceCommandTimeoutMaxMs, workspaceEnvironment: trustedCodeExecutionContext.codeWorkspace?.environment, + workspaceLinkedWorktrees: trustedCodeExecutionContext.codeWorkspace?.linkedWorktrees, skillStates: params.skillStates, defaultActiveOnShare: params.defaultActiveOnShare, maxCatalogSkills: getMaxCatalogSkills(runtime), diff --git a/packages/api/src/agents/skills.ts b/packages/api/src/agents/skills.ts index d080628cc31..f74fee50ea3 100644 --- a/packages/api/src/agents/skills.ts +++ b/packages/api/src/agents/skills.ts @@ -441,6 +441,8 @@ export interface InjectSkillCatalogParams { /** Deployment ceiling advertised on attached Bash tool definitions. */ workspaceCommandTimeoutMaxMs?: number; workspaceEnvironment?: CodeWorkspaceDescriptor['environment']; + /** The worker runs `.worktrees/` in its own lane; advertise `cwd` routing to the model. */ + workspaceLinkedWorktrees?: boolean; /** Current user ID — used to determine skill ownership for active-state resolution. */ userId?: string; /** Per-user skill overrides: `{ [skillId]: boolean }`. Missing entries use the default. */ @@ -677,6 +679,7 @@ export async function injectSkillCatalog( workspaceOperations, workspaceCommandTimeoutMaxMs, workspaceEnvironment, + workspaceLinkedWorktrees, userId, skillStates, defaultActiveOnShare = false, @@ -863,6 +866,7 @@ export async function injectSkillCatalog( workspaceOperations, workspaceCommandTimeoutMaxMs, workspaceEnvironment, + workspaceLinkedWorktrees, }); workingDefs = codeExecResult.toolDefinitions; diff --git a/packages/api/src/agents/tools.spec.ts b/packages/api/src/agents/tools.spec.ts index 0455c7cb0fb..1852ea5a1a0 100644 --- a/packages/api/src/agents/tools.spec.ts +++ b/packages/api/src/agents/tools.spec.ts @@ -704,6 +704,24 @@ describe('registerCodeExecutionTools', () => { }); expect(bash?.description).toContain('owner/app'); }); + + it('tells the model to route worktree commands through cwd only when lanes are available', () => { + const cwdDescription = (workspaceLinkedWorktrees: boolean): string | undefined => { + const bash = registerCodeExecutionTools({ + toolRegistry: undefined, + toolDefinitions: [], + includeBash: true, + workspaceTools: true, + workspaceOperations: new Set(['execute_command']), + workspaceLinkedWorktrees, + }).toolDefinitions.find((def) => def.name === 'bash_tool'); + return (bash?.parameters as { properties?: { cwd?: { description?: string } } })?.properties + ?.cwd?.description; + }; + + expect(cwdDescription(true)).toContain('.worktrees/'); + expect(cwdDescription(false)).not.toContain('.worktrees'); + }); const makeRegistry = (): LCToolRegistry => new Map() as unknown as LCToolRegistry; describe('fresh run (no pre-existing defs or registry entries)', () => { diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index 8dd9742dbdd..3c8d85c24e0 100644 --- a/packages/api/src/agents/tools.ts +++ b/packages/api/src/agents/tools.ts @@ -439,6 +439,8 @@ export interface RegisterCodeExecutionToolsParams { /** Deployment ceiling advertised on attached Bash tool definitions. */ workspaceCommandTimeoutMaxMs?: number; workspaceEnvironment?: CodeWorkspaceDescriptor['environment']; + /** The worker runs `.worktrees/` in its own lane; advertise `cwd` routing to the model. */ + workspaceLinkedWorktrees?: boolean; /** * When `true`, the registered `bash_tool` description includes the * LLM-facing `{{toolturn}}` reference syntax guide so the @@ -1040,6 +1042,7 @@ function createBashToolDef( workspaceTools = false, workspaceCommandTimeoutMaxMs?: number, workspaceEnvironment?: CodeWorkspaceDescriptor['environment'], + workspaceLinkedWorktrees = false, ): LCTool { /* Passed as a variable (not an inline literal) so the extra * `statefulSessions` key stays assignable against pinned SDK versions @@ -1052,7 +1055,11 @@ function createBashToolDef( ? buildAttachedWorkspaceBashDescription(enableToolOutputReferences, workspaceEnvironment) : buildBashExecutionToolDescription(descriptionOpts), parameters: (workspaceTools - ? buildAttachedWorkspaceBashSchema(workspaceCommandTimeoutMaxMs, workspaceEnvironment) + ? buildAttachedWorkspaceBashSchema( + workspaceCommandTimeoutMaxMs, + workspaceEnvironment, + workspaceLinkedWorktrees, + ) : BashExecutionToolDefinition.schema) as unknown as LCTool['parameters'], }) as LCTool; } @@ -1066,6 +1073,8 @@ function buildBashToolDef(opts: { workspaceTools?: boolean; workspaceCommandTimeoutMaxMs?: number; workspaceEnvironment?: CodeWorkspaceDescriptor['environment']; + /** The worker runs `.worktrees/` in its own lane; advertise `cwd` routing to the model. */ + workspaceLinkedWorktrees?: boolean; }): LCTool { /* Stateful defs are built on demand: the stateless pair covers the * default path, and per-run construction is negligible next to init. */ @@ -1076,6 +1085,7 @@ function buildBashToolDef(opts: { opts.workspaceTools === true, opts.workspaceCommandTimeoutMaxMs, opts.workspaceEnvironment, + opts.workspaceLinkedWorktrees === true, ); } return opts.enableToolOutputReferences @@ -1109,6 +1119,7 @@ export function registerCodeExecutionTools( workspaceOperations, workspaceCommandTimeoutMaxMs, workspaceEnvironment, + workspaceLinkedWorktrees, enableToolOutputReferences = false, statefulSessions = false, } = params; @@ -1129,6 +1140,7 @@ export function registerCodeExecutionTools( workspaceTools, workspaceCommandTimeoutMaxMs, workspaceEnvironment, + workspaceLinkedWorktrees, }), ); } diff --git a/packages/api/src/code/bridge.spec.ts b/packages/api/src/code/bridge.spec.ts index bd9dae4bf0f..9fe3e8990fe 100644 --- a/packages/api/src/code/bridge.spec.ts +++ b/packages/api/src/code/bridge.spec.ts @@ -97,6 +97,7 @@ describe('getCodeBridgeWorkerStatus', () => { id: 'project-a', name: 'Project A', workspaceInstances: ['git_worktree'], + workspaceScopes: ['git_linked_worktree'], }, { id: 'docs', operations: ['read_file'] }, ], @@ -128,6 +129,7 @@ describe('getCodeBridgeWorkerStatus', () => { id: 'project-a', name: 'Project A', workspaceInstances: ['git_worktree'], + workspaceScopes: ['git_linked_worktree'], }, { id: 'docs', operations: ['read_file'] }, ], @@ -249,6 +251,53 @@ describe('getCodeBridgeWorkerStatus', () => { }, }, }, + { + online: true, + ready: true, + leaseExpiresInMs: 5_000, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'native-srt', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [{ id: 'project-a', workspaceScopes: ['subdirectory'] }], + }, + }, + }, + { + online: true, + ready: true, + leaseExpiresInMs: 5_000, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'native-srt', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [{ id: 'project-a', workspaceScopes: 'git_linked_worktree' }], + }, + }, + }, + { + online: true, + ready: true, + leaseExpiresInMs: 5_000, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'native-srt', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [ + { id: 'project-a', workspaceScopes: ['git_linked_worktree', 'git_linked_worktree'] }, + ], + }, + }, + }, ])('rejects an invalid upstream status response: %p', async (invalid) => { const fetchImpl = jest .fn() diff --git a/packages/api/src/code/bridge.ts b/packages/api/src/code/bridge.ts index 0820901eca4..17f31aa1f5e 100644 --- a/packages/api/src/code/bridge.ts +++ b/packages/api/src/code/bridge.ts @@ -282,6 +282,7 @@ function validWorkspaceCapabilities(value: unknown): value is { key !== 'name' && key !== 'operations' && key !== 'workspaceInstances' && + key !== 'workspaceScopes' && key !== 'environment' && key !== 'instructions', ) || @@ -297,6 +298,10 @@ function validWorkspaceCapabilities(value: unknown): value is { (!Array.isArray(workspace.workspaceInstances) || workspace.workspaceInstances.length !== 1 || workspace.workspaceInstances[0] !== 'git_worktree')) || + (workspace.workspaceScopes !== undefined && + (!Array.isArray(workspace.workspaceScopes) || + workspace.workspaceScopes.length !== 1 || + workspace.workspaceScopes[0] !== 'git_linked_worktree')) || (workspace.name !== undefined && (typeof workspace.name !== 'string' || workspace.name.trim().length === 0 || diff --git a/packages/api/src/code/capabilities.spec.ts b/packages/api/src/code/capabilities.spec.ts index 746d68b8d1f..4365e0d24e5 100644 --- a/packages/api/src/code/capabilities.spec.ts +++ b/packages/api/src/code/capabilities.spec.ts @@ -461,6 +461,49 @@ describe('resolveCodeExecutionWorkspaceContext', () => { expect(legacy.codeWorkspace).not.toHaveProperty('workspaceInstanceId'); }); + it('routes linked worktrees into lanes only when configured and no conversation instance owns the checkout', async () => { + const workspaceInstanceId = 'c'.repeat(64); + jest.spyOn(globalThis, 'fetch').mockImplementation(async () => + workspaceStatus([ + { + id: 'lanes', + workspaceInstances: ['git_worktree'], + workspaceScopes: ['git_linked_worktree'], + }, + { id: 'legacy' }, + ]), + ); + const resolve = (workspaceId: string, instanceId?: string, linkedWorktrees = true) => + resolveCodeExecutionWorkspaceContext({ + context: { + ...context, + codeEnvironmentConfigSchema: { workspaces: { linkedWorktrees } }, + ...(instanceId ? { conversationWorkspaceInstanceId: instanceId } : {}), + }, + requestedSelections: [{ environmentId: 'personal', workspaceId }], + environments, + getAppConfig, + }); + + const lanes = await resolve('lanes'); + const disabled = await resolve('lanes', undefined, false); + const unconfigured = await resolveCodeExecutionWorkspaceContext({ + context, + requestedSelections: [{ environmentId: 'personal', workspaceId: 'lanes' }], + environments, + getAppConfig, + }); + const instance = await resolve('lanes', workspaceInstanceId); + const legacy = await resolve('legacy'); + + expect(lanes.codeWorkspace?.linkedWorktrees).toBe(true); + expect(disabled.codeWorkspace).not.toHaveProperty('linkedWorktrees'); + expect(unconfigured.codeWorkspace).not.toHaveProperty('linkedWorktrees'); + expect(instance.codeWorkspace?.workspaceInstanceId).toBe(workspaceInstanceId); + expect(instance.codeWorkspace).not.toHaveProperty('linkedWorktrees'); + expect(legacy.codeWorkspace).not.toHaveProperty('linkedWorktrees'); + }); + it('admits native workspace tools without enabling programmatic runtime execution', async () => { jest .spyOn(globalThis, 'fetch') diff --git a/packages/api/src/code/capabilities.ts b/packages/api/src/code/capabilities.ts index 85b8f7ed72a..32f1d37ad22 100644 --- a/packages/api/src/code/capabilities.ts +++ b/packages/api/src/code/capabilities.ts @@ -182,6 +182,14 @@ export async function resolveCodeExecutionWorkspaceContext({ workspace.workspaceInstances?.includes('git_worktree') ? { workspaceInstanceId: context.conversationWorkspaceInstanceId } : {}), + ...(context.codeEnvironmentConfigSchema?.workspaces?.linkedWorktrees === true && + workspace.workspaceScopes?.includes('git_linked_worktree') && + !( + context.conversationWorkspaceInstanceId && + workspace.workspaceInstances?.includes('git_worktree') + ) + ? { linkedWorktrees: true } + : {}), ...(status.maxCommandTimeoutMs == null ? {} : { maxCommandTimeoutMs: status.maxCommandTimeoutMs }), diff --git a/packages/api/src/code/command.spec.ts b/packages/api/src/code/command.spec.ts index b027583ef6a..7a625819a62 100644 --- a/packages/api/src/code/command.spec.ts +++ b/packages/api/src/code/command.spec.ts @@ -316,6 +316,23 @@ describe('createAttachedWorkspaceBashTool', () => { }); }); + test('runs a command whose working directory is a linked worktree in that lane', async () => { + const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse()); + const bashTool = createAttachedWorkspaceBashTool({ + baseUrl: 'https://code.example.com/v1', + authHeaders: () => ({}), + workspaceId: 'project-a', + linkedWorktrees: true, + fetchImpl, + }); + + expect(JSON.stringify(bashTool.schema)).toContain('.worktrees/'); + await bashTool.func({ command: 'npm test', cwd: '.worktrees/fix-a/api' }, undefined, {}); + + const request = JSON.parse(String((fetchImpl as jest.Mock).mock.calls[0][1]?.body)); + expect(request).toMatchObject({ command: 'npm test', cwd: 'api', worktree: 'fix-a' }); + }); + test('forwards a bounded per-call execution timeout', async () => { const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse()); const bashTool = createAttachedWorkspaceBashTool({ diff --git a/packages/api/src/code/command.ts b/packages/api/src/code/command.ts index 7981204f2ad..d9754ee6704 100644 --- a/packages/api/src/code/command.ts +++ b/packages/api/src/code/command.ts @@ -173,9 +173,15 @@ export function resolveAttachedWorkspaceRequestTimeoutMs( return configured; } +const linkedWorktreeWorkingDirectorySchema: BoundedWorkingDirectorySchema = { + ...attachedWorkingDirectorySchema, + description: `${attachedWorkingDirectorySchema.description} To work in a linked worktree, pass its directory here (for example ".worktrees/fix-auth") instead of running cd inside the command: commands in different .worktrees/ directories then run in parallel, while a cd from the workspace root waits for all of them.`, +}; + export function buildAttachedWorkspaceBashSchema( maxTimeoutMs: number = WORKSPACE_COMMAND_DEFAULT_TIMEOUT_MS, environment?: CodeWorkspaceDescriptor['environment'], + linkedWorktrees = false, ): NonNullable { const effectiveMaxTimeoutMs = normalizeAttachedWorkspaceCommandTimeoutMax(maxTimeoutMs); return { @@ -183,7 +189,10 @@ export function buildAttachedWorkspaceBashSchema( properties: { ...bashSchema.properties, command: attachedCommandSchema, - cwd: attachedWorkingDirectorySchema, + cwd: + linkedWorktrees === true + ? linkedWorktreeWorkingDirectorySchema + : attachedWorkingDirectorySchema, timeoutMs: buildAttachedTimeoutSchema(effectiveMaxTimeoutMs), ...(environment?.actions.length ? { @@ -322,12 +331,15 @@ export function createAttachedWorkspaceBashTool({ codeApiMaxRetryWaitMs, maxRequestTimeoutMs, minCommandAdmissionMs, + linkedWorktrees = false, fetchImpl, }: { baseUrl: string; authHeaders: () => Promise> | Record; workspaceId: string; workspaceInstanceId?: string; + /** The worker runs each `.worktrees/` in its own lane; a matching `cwd` is routed there. */ + linkedWorktrees?: boolean; environment?: CodeWorkspaceDescriptor['environment']; gitIdentity?: AgentGitIdentity | null; /** Effective admin/upstream ceiling already intersected with the protocol hard cap. */ @@ -347,7 +359,7 @@ export function createAttachedWorkspaceBashTool({ minCommandAdmissionMs, ); const schema = structuredClone( - buildAttachedWorkspaceBashSchema(effectiveMaxTimeoutMs, environment), + buildAttachedWorkspaceBashSchema(effectiveMaxTimeoutMs, environment, linkedWorktrees), ); const actions = environment?.actions ?? []; return tool( @@ -405,6 +417,7 @@ export function createAttachedWorkspaceBashTool({ baseURL: baseUrl, /** Passed as a supplier: a queued call outlives its minted token. */ authHeaders, + linkedWorktrees, request: { protocolVersion: 1, operation: 'execute_command', diff --git a/packages/api/src/code/workspace.lanes.spec.ts b/packages/api/src/code/workspace.lanes.spec.ts new file mode 100644 index 00000000000..46ae78b4293 --- /dev/null +++ b/packages/api/src/code/workspace.lanes.spec.ts @@ -0,0 +1,183 @@ +import type { WorkspaceToolRequest, WorkspaceToolResult } from './workspace'; +import { fromLinkedWorktreeResult, toLinkedWorktreeRequest } from './workspace'; + +const base = { protocolVersion: 1, workspaceId: 'librechat' } as const; + +describe('toLinkedWorktreeRequest', () => { + it.each([ + ['read_file', { operation: 'read_file', path: '.worktrees/fix-a/src/index.ts' }], + ['write_file', { operation: 'write_file', path: '.worktrees/fix-a/src/index.ts', content: '' }], + ['edit_file', { operation: 'edit_file', path: '.worktrees/fix-a/src/index.ts', edits: [] }], + [ + 'preview_edit', + { operation: 'preview_edit', path: '.worktrees/fix-a/src/index.ts', edits: [] }, + ], + ])('routes a %s path into its worktree lane', (_operation, fields) => { + const request = { ...base, ...fields } as WorkspaceToolRequest; + expect(toLinkedWorktreeRequest(request)).toEqual({ + worktree: 'fix-a', + request: { ...request, path: 'src/index.ts', worktree: 'fix-a' }, + }); + }); + + it('scopes searches and listings to the worktree, omitting the path at its root', () => { + expect( + toLinkedWorktreeRequest({ + ...base, + operation: 'search_text', + query: 'TODO', + path: '.worktrees/fix-a', + }), + ).toEqual({ + worktree: 'fix-a', + request: { ...base, operation: 'search_text', query: 'TODO', worktree: 'fix-a' }, + }); + expect( + toLinkedWorktreeRequest({ + ...base, + operation: 'list_files', + path: '.worktrees/fix-a/src', + afterPath: '.worktrees/fix-a/src/a.ts', + }), + ).toEqual({ + worktree: 'fix-a', + request: { + ...base, + operation: 'list_files', + path: 'src', + afterPath: 'src/a.ts', + worktree: 'fix-a', + }, + }); + }); + + it('runs a command whose cwd is inside a worktree in that lane', () => { + expect( + toLinkedWorktreeRequest({ + ...base, + operation: 'execute_command', + command: 'npm test', + cwd: '.worktrees/fix-a', + }), + ).toEqual({ + worktree: 'fix-a', + request: { ...base, operation: 'execute_command', command: 'npm test', worktree: 'fix-a' }, + }); + expect( + toLinkedWorktreeRequest({ + ...base, + operation: 'execute_command', + command: 'npx jest', + cwd: '.worktrees/fix-a/packages/api', + })?.request, + ).toMatchObject({ cwd: 'packages/api', worktree: 'fix-a' }); + }); + + it.each<[string, WorkspaceToolRequest]>([ + ['a root path', { ...base, operation: 'read_file', path: 'src/index.ts' }], + ['the worktree directory itself', { ...base, operation: 'read_file', path: '.worktrees/a' }], + ['the worktrees container', { ...base, operation: 'list_files', path: '.worktrees' }], + ['an invalid name', { ...base, operation: 'read_file', path: '.worktrees/-bad/x.ts' }], + ['a lock name', { ...base, operation: 'read_file', path: '.worktrees/a.lock/x.ts' }], + [ + 'an instruction read', + { + ...base, + operation: 'read_file', + path: '.worktrees/a/AGENTS.md', + instructionSha256: 'a'.repeat(64), + }, + ], + [ + 'a conversation instance', + { + ...base, + operation: 'read_file', + path: '.worktrees/a/x.ts', + workspaceInstanceId: 'b'.repeat(64), + }, + ], + [ + 'a listing cursor in another worktree', + { + ...base, + operation: 'list_files', + path: '.worktrees/a', + afterPath: '.worktrees/b/x.ts', + }, + ], + [ + 'a listing cursor at the worktree root', + { ...base, operation: 'list_files', path: '.worktrees/a', afterPath: '.worktrees/a' }, + ], + [ + 'a command without cwd', + { ...base, operation: 'execute_command', command: 'cd .worktrees/a' }, + ], + [ + 'an environment action', + { + ...base, + operation: 'execute_command', + command: 'setup', + cwd: '.worktrees/a', + environmentAction: { name: 'setup', fingerprint: 'c'.repeat(64) }, + }, + ], + ])('leaves %s root-scoped', (_case, request) => { + expect(toLinkedWorktreeRequest(request)).toBeUndefined(); + }); +}); + +describe('fromLinkedWorktreeResult', () => { + it('restores the worktree prefix on every reported path', () => { + const listing: WorkspaceToolResult = { + ...base, + operation: 'list_files', + paths: ['src/a.ts', 'src/b.ts'], + truncated: true, + nextAfterPath: 'src/b.ts', + }; + expect(fromLinkedWorktreeResult(listing, 'fix-a')).toEqual({ + ...listing, + paths: ['.worktrees/fix-a/src/a.ts', '.worktrees/fix-a/src/b.ts'], + nextAfterPath: '.worktrees/fix-a/src/b.ts', + }); + + const search: WorkspaceToolResult = { + ...base, + operation: 'search_text', + matches: [{ path: 'src/a.ts', line: 3, column: 1, text: 'TODO' }], + truncated: false, + }; + expect(fromLinkedWorktreeResult(search, 'fix-a')).toEqual({ + ...search, + matches: [{ path: '.worktrees/fix-a/src/a.ts', line: 3, column: 1, text: 'TODO' }], + }); + + const written: WorkspaceToolResult = { + ...base, + operation: 'write_file', + path: 'src/a.ts', + created: true, + bytesWritten: 1, + }; + expect(fromLinkedWorktreeResult(written, 'fix-a')).toEqual({ + ...written, + path: '.worktrees/fix-a/src/a.ts', + }); + }); + + it('returns command results unchanged', () => { + const result: WorkspaceToolResult = { + ...base, + operation: 'execute_command', + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + truncated: false, + }; + expect(fromLinkedWorktreeResult(result, 'fix-a')).toBe(result); + }); +}); diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index fb48d989333..35fe63292c4 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -2176,3 +2176,72 @@ describe('executeWorkspaceTool', () => { expect(fetchImpl).toHaveBeenCalledTimes(2); }); }); + +describe('linked worktree lanes', () => { + const listing = (paths: string[]) => + new Response( + JSON.stringify({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'librechat', + paths, + truncated: false, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + const request: WorkspaceToolRequest = { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'librechat', + path: '.worktrees/fix-a/src', + }; + + test('sends a worktree request and reports paths relative to the checkout', async () => { + const fetchImpl = jest.fn().mockResolvedValue(listing(['src/a.ts'])); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example/v1', + authHeaders: {}, + fetchImpl, + request, + linkedWorktrees: true, + }), + ).resolves.toMatchObject({ paths: ['.worktrees/fix-a/src/a.ts'] }); + expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'librechat', + path: 'src', + worktree: 'fix-a', + }); + }); + + test('keeps the checkout-scoped request when the worker has no lanes', async () => { + const fetchImpl = jest.fn().mockResolvedValue(listing(['.worktrees/fix-a/src/a.ts'])); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example/v1', + authHeaders: {}, + fetchImpl, + request, + }), + ).resolves.toMatchObject({ paths: ['.worktrees/fix-a/src/a.ts'] }); + expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual(request); + }); + + test('rejects lane results that escape the requested worktree scope', async () => { + const fetchImpl = jest.fn().mockResolvedValue(listing(['lib/a.ts'])); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example/v1', + authHeaders: {}, + fetchImpl, + request, + linkedWorktrees: true, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + }); +}); diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index c953dffbfa9..80ab78a4dc1 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -858,6 +858,142 @@ function getWorkspaceAuthHeaders( }); } +/** Linked worktrees live at `.worktrees/` beneath a registered checkout. */ +const LINKED_WORKTREE_DIRECTORY = '.worktrees/'; +/** Mirrors Code API's single-segment worktree name rule. */ +const LINKED_WORKTREE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +export type LinkedWorktreeRequest = WorkspaceToolRequest & { worktree: string }; + +interface LinkedWorktreePath { + worktree: string; + /** Path relative to the worktree root; empty for the worktree root itself. */ + rest: string; +} + +function splitLinkedWorktreePath(path: string | undefined): LinkedWorktreePath | undefined { + if (path == null || !path.startsWith(LINKED_WORKTREE_DIRECTORY)) return undefined; + const remainder = path.slice(LINKED_WORKTREE_DIRECTORY.length); + const slash = remainder.indexOf('/'); + const worktree = slash === -1 ? remainder : remainder.slice(0, slash); + if (!LINKED_WORKTREE_NAME_PATTERN.test(worktree) || worktree.endsWith('.lock')) { + return undefined; + } + return { worktree, rest: slash === -1 ? '' : remainder.slice(slash + 1) }; +} + +function prefixed(worktree: string, path: string): string { + return `${LINKED_WORKTREE_DIRECTORY}${worktree}/${path}`; +} + +/** + * Route a request that targets `.worktrees//…` into that worktree's own + * scheduling lane, so work in sibling worktrees runs concurrently. Only + * structural targets are routed: a file path, a search or listing scope, or a + * command `cwd`. Anything else, including a command that `cd`s into a worktree + * from the root, stays root-scoped because its reach cannot be bounded. + */ +export function toLinkedWorktreeRequest( + request: WorkspaceToolRequest, +): { request: LinkedWorktreeRequest; worktree: string } | undefined { + if (request.workspaceInstanceId != null) return undefined; + switch (request.operation) { + case 'read_file': + case 'write_file': + case 'edit_file': + case 'preview_edit': { + if (request.operation === 'read_file' && request.instructionSha256 != null) return undefined; + const target = splitLinkedWorktreePath(request.path); + if (target == null || target.rest === '') return undefined; + return { + worktree: target.worktree, + request: { ...request, path: target.rest, worktree: target.worktree }, + }; + } + case 'search_text': { + const target = splitLinkedWorktreePath(request.path); + if (target == null) return undefined; + const { path: _path, ...rest } = request; + return { + worktree: target.worktree, + request: { + ...rest, + ...(target.rest === '' ? {} : { path: target.rest }), + worktree: target.worktree, + }, + }; + } + case 'list_files': { + const target = splitLinkedWorktreePath(request.path); + if (target == null) return undefined; + const after = + request.afterPath == null ? undefined : splitLinkedWorktreePath(request.afterPath); + if ( + request.afterPath != null && + (after == null || after.worktree !== target.worktree || after.rest === '') + ) { + return undefined; + } + const { path: _path, afterPath: _afterPath, ...rest } = request; + return { + worktree: target.worktree, + request: { + ...rest, + ...(target.rest === '' ? {} : { path: target.rest }), + ...(after == null ? {} : { afterPath: after.rest }), + worktree: target.worktree, + }, + }; + } + case 'execute_command': { + if (request.environmentAction != null) return undefined; + const target = splitLinkedWorktreePath(request.cwd); + if (target == null) return undefined; + const { cwd: _cwd, ...rest } = request; + return { + worktree: target.worktree, + request: { + ...rest, + ...(target.rest === '' ? {} : { cwd: target.rest }), + worktree: target.worktree, + }, + }; + } + } +} + +/** Restore the `.worktrees//` prefix on every path a lane result reports. */ +export function fromLinkedWorktreeResult( + result: WorkspaceToolResult, + worktree: string, +): WorkspaceToolResult { + switch (result.operation) { + case 'read_file': + case 'write_file': + case 'edit_file': + case 'preview_edit': + return { ...result, path: prefixed(worktree, result.path) }; + case 'search_text': + return { + ...result, + matches: result.matches.map((match) => ({ + ...match, + path: prefixed(worktree, match.path), + })), + }; + case 'list_files': + return { + ...result, + paths: result.paths.map((path) => prefixed(worktree, path)), + ...(result.nextAfterPath == null + ? {} + : { nextAfterPath: prefixed(worktree, result.nextAfterPath) }), + }; + case 'execute_command': + return result; + } +} + export async function executeWorkspaceTool({ baseURL, authHeaders, @@ -868,6 +1004,7 @@ export async function executeWorkspaceTool({ codeApiMaxRetryWaitMs = CODE_API_RATE_LIMIT_WAIT_DEFAULT_MS, maxRequestTimeoutMs, deadlineAtMs, + linkedWorktrees = false, }: { baseURL: string; authHeaders: WorkspaceToolAuthHeaders; @@ -881,6 +1018,8 @@ export async function executeWorkspaceTool({ maxRequestTimeoutMs?: number; /** Optional earlier caller deadline; a signal alone has no remaining-time value. */ deadlineAtMs?: number; + /** The worker runs each `.worktrees/` in its own lane; route matching requests there. */ + linkedWorktrees?: boolean; }): Promise { if ( !isValidRequest(request) || @@ -898,16 +1037,18 @@ export async function executeWorkspaceTool({ ) { throw new WorkspaceToolHttpError('invalid'); } - const executionBudgetMs = getWorkspaceExecutionBudgetMs(request); + const lane = linkedWorktrees === true ? toLinkedWorktreeRequest(request) : undefined; + const wireRequest: WorkspaceToolRequest = lane?.request ?? request; + const executionBudgetMs = getWorkspaceExecutionBudgetMs(wireRequest); const completionReserveMs = executionBudgetMs + WORKSPACE_COMMAND_TRANSPORT_GRACE_MS; - const perAttemptTimeoutMs = maxRequestTimeoutMs ?? getWorkspaceToolTimeoutMs(request); + const perAttemptTimeoutMs = maxRequestTimeoutMs ?? getWorkspaceToolTimeoutMs(wireRequest); const callerDeadlineAt = Math.min( deadlineAtMs ?? Infinity, maxRequestTimeoutMs == null ? Infinity : Date.now() + maxRequestTimeoutMs, ); const queueDeadlineAt = Date.now() + maxQueueWaitMs; const callerRetryDeadlineAt = callerDeadlineAt - completionReserveMs; - const body = JSON.stringify(request); + const body = JSON.stringify(wireRequest); let lastAdmissionRejection: WorkspaceToolHttpError | undefined; let lastRetryDeadlineAt = Infinity; let rateLimitWaitedMs = 0; @@ -1011,10 +1152,10 @@ export async function executeWorkspaceTool({ continue; } const result = await readBoundedJson(response, requestSignal); - if (!isValidResult(request, result)) { + if (!isValidResult(wireRequest, result)) { throw new WorkspaceToolHttpError('invalid'); } - return result; + return lane ? fromLinkedWorktreeResult(result, lane.worktree) : result; } catch (error) { if (error instanceof WorkspaceToolHttpError) throw error; if ( diff --git a/packages/data-provider/src/code/workspace.ts b/packages/data-provider/src/code/workspace.ts index 33e0592fe4b..5afb6567ab0 100644 --- a/packages/data-provider/src/code/workspace.ts +++ b/packages/data-provider/src/code/workspace.ts @@ -25,6 +25,8 @@ export const CODE_WORKSPACE_OPERATIONS = [ 'execute_command', ] as const; export const CODE_WORKSPACE_INSTANCE_TYPES = ['git_worktree'] as const; +/** Scheduling scopes a worker can admit beneath one registered root. */ +export const CODE_WORKSPACE_SCOPES = ['git_linked_worktree'] as const; export const CODE_WORKSPACE_SELECTION_ERROR_REASONS = [ 'required', 'invalid', @@ -37,6 +39,7 @@ export const CODE_ENVIRONMENT_MODES = ['attached', 'without_attached'] as const; export type CodeWorkspaceOperation = (typeof CODE_WORKSPACE_OPERATIONS)[number]; export type CodeWorkspaceInstanceType = (typeof CODE_WORKSPACE_INSTANCE_TYPES)[number]; +export type CodeWorkspaceScope = (typeof CODE_WORKSPACE_SCOPES)[number]; export type CodeWorkspaceSelectionErrorReason = (typeof CODE_WORKSPACE_SELECTION_ERROR_REASONS)[number]; export type CodeEnvironmentMode = (typeof CODE_ENVIRONMENT_MODES)[number]; @@ -50,6 +53,8 @@ export interface CodeWorkspaceDescriptor { operations?: CodeWorkspaceOperation[]; /** Optional worker-managed isolation modes available beneath this root. */ workspaceInstances?: CodeWorkspaceInstanceType[]; + /** `git_linked_worktree`: each `.worktrees/` runs in its own scheduling lane. */ + workspaceScopes?: CodeWorkspaceScope[]; environment?: { fingerprint: string; repo?: string; diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index 6ef043dbe16..c4e874f424b 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -736,6 +736,35 @@ describe('attached code environment user config schema', () => { }); }); + it.each([ + [{ linkedWorktrees: true }, true], + [{ linkedWorktrees: 'yes' }, false], + [{ linkedWorktrees: true, subdirectories: true }, false], + ])('validates the linked worktree lane toggle %p', (workspaces, valid) => { + const result = configSchema.safeParse({ + version: '1.0', + endpoints: { + agents: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + baseURL: 'https://code.example.com/v1', + default: true, + configSchema: { workspaces }, + }, + ], + }, + }, + }, + }); + + expect(result.success).toBe(valid); + }); + it('rejects an attached command timeout above the protocol hard cap', () => { const result = configSchema.safeParse({ version: '1.0', diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 815ec69d87e..b7ecf82b33f 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1290,6 +1290,15 @@ export const codeEnvironmentUserConfigSchema = z }); }) .optional(), + workspaces: z + .object({ + /** Run requests aimed at `.worktrees/` in that worktree's own lane when the + * worker advertises linked-worktree lanes. Omission keeps every request scoped to + * its checkout. */ + linkedWorktrees: z.boolean().optional(), + }) + .strict() + .optional(), }) .strict();