From 5b7845e36f763fd43c5dc7ded1dcb3519b00cc79 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:21:51 -0700 Subject: [PATCH] refactor(pty): one spawn preflight step for both launch lanes (#29101) * test(launch): pin where the two spawn preflights differ, on main Row 3 (one spawn preflight for both launch lanes) first pins main's behaviour: - Row 8, agents started with their workspace (from the earlier row-8 work): a work item's "start agent" and the folder-workspace composer through the real producer and pane and pty:spawn: draft and note on argv, a long multi-line note, PowerShell quoting under cmd.exe, project WSL, terminal shell WSL, and a prompt-less Codex start with no shell wait. - A deleted folder: the window starts in the workspace root and says so; the host passes the folder on and the provider refuses it. - A Windows plain terminal in a folder containing a WSL-project repo, and in a \\wsl$ repo with no saved runtime: the window uses the runtime the renderer sends; the host reads only the repo's project. - A managed SSH Windows host: only the host lane opens the OpenSSH login shell. - A pane's own agent launch drops the root fallback (TEMPORARY parity). * refactor(pty): one spawn preflight step for both launch lanes The window's pty:spawn and the host's runtime controller each kept their own copy of the first spawn step: resolve the folder a new terminal starts in and the shell/runtime its PTY uses. resolvePtySpawnStartupTarget now does both for both lanes. Each lane passes the two inputs it still differs on, so nothing changes for users: - missingCwdFallback (TEMPORARY, row 13a): only window starts fall back to the workspace root, with a notice, when the requested folder is gone (including the WSL cwd probe). - projectRuntimeSource (TEMPORARY, row 13b): the window uses the runtime its renderer sent; the host reads the repo's project from the store. The OpenSSH login shell (#9327) now resolves for both lanes; only an SSH-launched orcad turns it on, and that process has no window, so the window lane never meets it. Both lanes resolve after fresh-spawn recovery and session-id mint (the WSL distro reads the session id). Admission, adoption, hidden mark, recovery and mint stay where they were. --- src/main/ipc/pty-agent-pane-launch.test.ts | 24 ++- .../ipc/pty-launch-parity.test-fixture.ts | 6 +- src/main/ipc/pty-launch-parity.test.ts | 123 +++++++++++- src/main/ipc/pty/ipc/spawn-env-codex.ts | 6 - src/main/ipc/pty/ipc/spawn-preflight.ts | 146 +++------------ src/main/ipc/pty/runtime/spawn-preflight.ts | 45 ++--- src/main/ipc/pty/spawn-startup-target.ts | 165 ++++++++++++++++ .../launch-parity-window-tab.test.ts | 27 ++- ...nch-parity-workspace-start.test-fixture.ts | 104 ++++++++++ ...unch-parity-window-request.test-fixture.ts | 29 ++- ...rity-workspace-start-cases.test-fixture.ts | 177 ++++++++++++++++++ 11 files changed, 680 insertions(+), 172 deletions(-) create mode 100644 src/main/ipc/pty/spawn-startup-target.ts create mode 100644 src/renderer/src/lib/launch-parity-workspace-start.test-fixture.ts create mode 100644 src/shared/launch-parity-workspace-start-cases.test-fixture.ts diff --git a/src/main/ipc/pty-agent-pane-launch.test.ts b/src/main/ipc/pty-agent-pane-launch.test.ts index 907fc573a40..ed6f5a54ca1 100644 --- a/src/main/ipc/pty-agent-pane-launch.test.ts +++ b/src/main/ipc/pty-agent-pane-launch.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentPaneLaunchRequest } from '../../shared/agent-pane-launch-request' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { statSyncMock } from './pty-ipc-mock-registry' import { registerPtyHandlers } from './pty/register-handlers' import { markAgentLaunchesClosedByUser, @@ -108,7 +109,6 @@ describe('a pane that starts its own agent launch', () => { ): Promise { return Promise.resolve( handlers.get('pty:spawn')!(null, { - ...pane, cols: 132, rows: 40, cwd: '/tmp/pane-launch', @@ -116,7 +116,8 @@ describe('a pane that starts its own agent launch', () => { tabId, leafId, command: '', - agentLaunch + agentLaunch, + ...pane }) ) } @@ -181,6 +182,25 @@ describe('a pane that starts its own agent launch', () => { ) }) + // TEMPORARY parity (row 3 pin; spawn-agent-launch.ts drops cwdFallback): a launch into a deleted + // folder fails there, as the host's launch does, instead of starting in the workspace root. + it('starts in the requested folder even when it is gone, without the root fallback', async () => { + const missing = '/tmp/pane-launch/deleted' + statSyncMock.mockImplementation((target: string) => { + if (target === missing) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + const providerSpawn = vi.fn(async (_options: { cwd?: string }) => ({ id: 'pty-agent' })) + register(providerSpawn) + + const result = await spawnPane(AI_BUTTON, { cwd: missing, cwdFallback: 'worktree' }) + + expect(providerSpawn.mock.calls[0]?.[0].cwd).toBe(missing) + expect(result).not.toHaveProperty('startupCwdFallback') + }) + it('starts one agent when the pane spawns twice at once', async () => { const providerSpawn = vi.fn(async () => ({ id: 'pty-agent' })) register(providerSpawn) diff --git a/src/main/ipc/pty-launch-parity.test-fixture.ts b/src/main/ipc/pty-launch-parity.test-fixture.ts index 1920c3be1ed..bce4dcc2ebc 100644 --- a/src/main/ipc/pty-launch-parity.test-fixture.ts +++ b/src/main/ipc/pty-launch-parity.test-fixture.ts @@ -146,7 +146,11 @@ export function startParityLanes( ): ParityLanes { const settings = parityMainSettings(args.settings) const persistPtyBinding = vi.fn(async () => true) - const { repo, folderWorkspace } = launchScope(args.workspace) + const { repo: scopeRepo, folderWorkspace } = launchScope(args.workspace) + // A folder's contained repo is saved too, so the host can borrow its runtime if it would. + const repo = + scopeRepo ?? + (args.workspace.repoPath ? launchScope({ kind: 'repo', path: args.workspace.repoPath }).repo : null) const store = { getSettings: () => settings, getProjects: () => [ diff --git a/src/main/ipc/pty-launch-parity.test.ts b/src/main/ipc/pty-launch-parity.test.ts index 17016cf59cf..317f752d91e 100644 --- a/src/main/ipc/pty-launch-parity.test.ts +++ b/src/main/ipc/pty-launch-parity.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' -import { classifyErrorMock, trackMock } from './pty-ipc-mock-registry' +import { classifyErrorMock, statSyncMock, trackMock } from './pty-ipc-mock-registry' import { providerFacts, recordOf, @@ -11,9 +11,12 @@ import { } from './pty-launch-parity.test-fixture' import { POSIX_PATH, - WINDOW_LAUNCH_CASES + WIN_PATH, + WINDOW_LAUNCH_CASES, + WSL_PATH } from '../../shared/launch-parity-window-cases.test-fixture' import { PLUS_LAUNCH_CASES } from '../../shared/launch-parity-plus-cases.test-fixture' +import { WORKSPACE_START_LAUNCH_CASES } from '../../shared/launch-parity-workspace-start-cases.test-fixture' import { expectedWindowProvider, launchWorkspaceId, @@ -53,6 +56,7 @@ import { resolveOpenCodeDirectModelExecutable } from '../opencode/opencode-model-availability' import { probeOpenCodeLaunchModelContext } from '../opencode/opencode-launch-model-context' +import { readOpenSshDefaultShell } from '../../shared/openssh-default-shell' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -120,6 +124,8 @@ vi.mock('../antigravity/native-account-launch', () => ({ vi.mock('../codex/codex-state-db-backfill-recovery', () => import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) ) +// Why: the OpenSSH DefaultShell registry read; only a managed SSH host's case answers it. +vi.mock('../../shared/openssh-default-shell', () => ({ readOpenSshDefaultShell: vi.fn(() => '') })) type WindowLaneCase = { label: string @@ -133,7 +139,7 @@ type WindowLaneCase = { } const WINDOW_LANE_CASES: WindowLaneCase[] = [ - ...[...WINDOW_LAUNCH_CASES, ...PLUS_LAUNCH_CASES].map((c) => ({ + ...[...WINDOW_LAUNCH_CASES, ...PLUS_LAUNCH_CASES, ...WORKSPACE_START_LAUNCH_CASES].map((c) => ({ ...c, label: `row ${c.row}: ${c.name}`, request: windowSpawnRequest(c), @@ -147,7 +153,7 @@ const WINDOW_LANE_CASES: WindowLaneCase[] = [ })) ] -// Pins main's current launch behaviour as the convergence parity baseline (rows 1, 2, 5 and 6, main +// Pins main's current launch behaviour as the convergence parity baseline (rows 1, 2, 5, 6 and 8, main // half): each table request, fed to pty:spawn, reaches the provider with these facts (the pane's // measured size, never hidden), one agent_started, and a pane binding. // C2 (row 1), C5a-e and C6 check the merged spawn pipeline against it. @@ -451,3 +457,112 @@ describe('launch facts outside the tables', () => { }) }) }) + +const WSL_UBUNTU = { kind: 'wsl', distro: 'Ubuntu' } as const +// What the renderer sends for a WSL project (it reads a never-saved \\wsl$ path the same way). +const WSL_PROJECT_SENT = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } +} +const LOGIN_SHELL = String.raw`C:\Program Files\PowerShell\7\pwsh.exe` + +// Pins main's current spawn preflight (row 3, C5b): where the two lanes resolve a plain terminal's +// start differently today. Row 13a removes the missing-folder split; row 13b the runtime split. +describe('spawn preflight: where the lanes differ today', () => { + const suite = setupPtyIpcSuite() + afterEach(() => { + restoreMainPlatform() + vi.unstubAllEnvs() + }) + + async function spawnBoth( + lanes: ParityLanes, + workspace: LaunchWorkspace, + window: Record, + host: Record = {} + ): Promise>> { + const worktreeId = launchWorkspaceId(workspace) + const result = recordOf( + await lanes.spawnWindow({ cols: 80, rows: 24, cwd: workspace.path, worktreeId, ...window }) + ) + const windowFacts = { ...(await providerFacts(lanes.provider)), result } + await lanes.runtime.createTerminal(`id:${worktreeId}`, host) + return { window: windowFacts, host: await providerFacts(lanes.provider) } + } + + // The host hands the provider the missing folder, and the provider's own check then refuses it + // ("Working directory ... does not exist"). + it('a deleted folder: the window starts in the workspace root and says so; the host does not', async () => { + setMainPlatform('darwin') + const missing = `${POSIX_PATH}/deleted` + statSyncMock.mockImplementation((target: string) => { + if (target === missing) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + const lanes = startParityLanes(suite, { workspace: POSIX_REPO }) + const facts = await spawnBoth( + lanes, + POSIX_REPO, + { cwd: missing, cwdFallback: 'worktree' }, + { cwd: missing } + ) + + expect(facts.window).toMatchObject({ + cwd: POSIX_PATH, + result: { startupCwdFallback: { kind: 'worktree', cwd: POSIX_PATH } } + }) + expect(facts.host).toMatchObject({ cwd: missing }) + }) + + it.each([ + { + name: 'a folder containing a WSL-project repo', + workspace: { + kind: 'folder', + path: String.raw`C:\Users\alice`, + repoPath: WIN_PATH, + projectRuntime: WSL_UBUNTU + } satisfies LaunchWorkspace, + host: { shellOverride: 'powershell.exe', terminalWindowsWslDistro: null } + }, + { + name: 'a \\wsl$ repo with no saved runtime', + workspace: { kind: 'repo', path: WSL_PATH } satisfies LaunchWorkspace, + // The distro comes from the path, but the shell stays the settings' PowerShell. + host: { shellOverride: 'powershell.exe', terminalWindowsWslDistro: 'Ubuntu' } + } + ])('Windows plain terminal, $name: the window uses the sent runtime', async (c) => { + setMainPlatform('win32') + const lanes = startParityLanes(suite, { workspace: c.workspace }) + const facts = await spawnBoth(lanes, c.workspace, { projectRuntime: WSL_PROJECT_SENT }) + + expect(facts.window).toMatchObject({ + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + expect(facts.host).toMatchObject(c.host) + }) + + // One resolution reads it for both lanes. Only an SSH-launched orcad has this env, and it has no + // window (no pty:spawn), so the window lane never meets it in practice. + it('Windows managed SSH host: both lanes open the OpenSSH login shell', async () => { + setMainPlatform('win32') + vi.stubEnv('ORCA_ORCAD_MANAGED_ACTIVATION_ROOT', '/activation') + vi.mocked(readOpenSshDefaultShell).mockReturnValue(LOGIN_SHELL) + const workspace = { kind: 'repo', path: WIN_PATH } satisfies LaunchWorkspace + const lanes = startParityLanes(suite, { workspace }) + const facts = await spawnBoth(lanes, workspace, {}) + + expect(facts.window).toMatchObject({ shellOverride: LOGIN_SHELL }) + expect(facts.host).toMatchObject({ shellOverride: LOGIN_SHELL }) + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index 5634928b81f..fb5a15d5c2f 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -11,7 +11,6 @@ import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex- import { buildPtyHostEnv } from '../host-env/assembly' import { getCompatibleSelectedCodexHomePath, - getCodexSelectionTargetForPty, resolveCodexHomeAfterManagedAuthReadiness, shouldSkipCodexHomeEnvForWindowsShell, shouldStripInheritedOrcaCodexHome, @@ -29,11 +28,6 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis cwd: args.cwd, shellOverride: ctx.effectiveShellOverride }) - ctx.codexSelectionTarget = getCodexSelectionTargetForPty( - ctx.effectiveShellOverride, - ctx.cwd, - ctx.expectedWslDistro - ) const codexResumePreparation = ctx.preAdoptedStablePane ? null : ctx.deps.prepareCodexResumeHome({ diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 064fc41d911..f791190a074 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -1,21 +1,13 @@ import { withClaudeProfileTerminalEnv } from '../../../claude-accounts/claude-profile-installed-router' -import { - isWslShellName, - resolveLocalWindowsTerminalRuntimeOptions -} from '../../../../shared/local-windows-terminal-runtime' -import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths' import { mintPtySessionId } from '../../../daemon/pty-session-id' -import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' import { LocalPtyProvider } from '../../../providers/local-pty-provider' -import { normalizeWindowsTerminalCwd } from '../../../providers/windows-shell-args' -import { wslUncDirectoryExistsAsync } from '../../../wsl' -import { getCodexSelectionTargetForPty } from '../host-env/codex-home' import { isClaudeLaunchCommand, recoverFreshSpawnProviderRouting, routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' import { getProvider } from '../provider/registry' +import { resolvePtySpawnStartupTarget } from '../spawn-startup-target' import type { PtyIpcSpawnState } from './spawn-state' import { getConnectionExecutionHostId } from '../../../../shared/execution-host' @@ -65,89 +57,6 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis await admission } } - if (!ctx.preAdoptedStablePane) { - // Why: reattach needs exact cwd, and successful stable-pane adoption needs no launch preflight. - const requestedMissingCwdFallback = !args.sessionId && args.cwdFallback === 'worktree' - const isPosixStartupCwd = args.cwd?.startsWith('/') === true - const startupWorkspaceCwd = - requestedMissingCwdFallback && isPosixStartupCwd - ? ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, '.') - : undefined - const initiallyResolvedStartupCwd = - requestedMissingCwdFallback && isPosixStartupCwd ? ctx.cwd : undefined - const startupTerminalRuntimeOptions = - requestedMissingCwdFallback && process.platform === 'win32' - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.shellOverride, - settings: ctx.deps.getSettings?.(), - projectRuntime: args.projectRuntime, - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : undefined - const wslRuntimeOwnsStartupCwd = - requestedMissingCwdFallback && - isPosixStartupCwd && - (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || - isWslUncPath(startupWorkspaceCwd ?? '')) - const startupWslContext = wslRuntimeOwnsStartupCwd - ? resolveWslSessionContext({ - cwd: startupWorkspaceCwd, - shellOverride: startupTerminalRuntimeOptions?.shellOverride, - terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro - }) - : undefined - let wslStartupCwdExists: boolean | null = null - let wslWorkspaceCwdExists: boolean | null = null - if (startupWslContext && initiallyResolvedStartupCwd) { - const validationCwd = toWindowsWslPath(initiallyResolvedStartupCwd, startupWslContext.distro) - wslStartupCwdExists = isWslUncPath(validationCwd) - ? await wslUncDirectoryExistsAsync(validationCwd) - : ctx.deps.localStartupCwdDirectoryExists(validationCwd) - if (wslStartupCwdExists === true) { - ctx.prevalidatedCwd = validationCwd - } - if (wslStartupCwdExists === false && startupWorkspaceCwd) { - wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) - ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) - : ctx.deps.localStartupCwdDirectoryExists(startupWorkspaceCwd) - if (wslWorkspaceCwdExists === true) { - ctx.prevalidatedCwd = startupWorkspaceCwd - } - } - } - const allowMissingCwdFallback = - requestedMissingCwdFallback && - (!wslRuntimeOwnsStartupCwd || - (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) - let didFallbackToWorkspaceRootCwd = false - ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd( - args.worktreeId, - args.cwd, - allowMissingCwdFallback - ? { - directoryExists: (path) => - startupWslContext && - wslStartupCwdExists === false && - path === initiallyResolvedStartupCwd - ? false - : startupWslContext && path === startupWorkspaceCwd - ? wslWorkspaceCwdExists === true - : ctx.deps.localStartupCwdDirectoryExists( - process.platform === 'win32' ? normalizeWindowsTerminalCwd(path) : path - ), - onFallbackToWorkspaceRoot: () => { - didFallbackToWorkspaceRootCwd = true - } - } - : undefined - ) - if (didFallbackToWorkspaceRootCwd && wslWorkspaceCwdExists === true && ctx.cwd) { - ctx.prevalidatedCwd = ctx.cwd - } - ctx.startupCwdFallback = - didFallbackToWorkspaceRootCwd && ctx.cwd ? { kind: 'worktree', cwd: ctx.cwd } : undefined - } - ctx.spawnTiming.mark('preflight') const freshSpawnRecovery = ctx.preAdoptedStablePane ? undefined : recoverFreshSpawnProviderRouting(ctx.provider, args.sessionId) @@ -174,40 +83,35 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis } } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && isClaudeLaunchCommand(args.command) - ctx.terminalRuntimeOptions = - ctx.paneAgentLaunch?.launchRuntime?.pty ?? - (process.platform === 'win32' - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.shellOverride, - settings: ctx.deps.getSettings?.(), - projectRuntime: args.projectRuntime, - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : { - shellOverride: - args.shellOverride ?? (ctx.deps.getSettings?.()?.terminalDefaultShell || undefined), - terminalWindowsWslDistro: null - }) - const initialShellOverride = ctx.terminalRuntimeOptions.shellOverride // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. - ctx.expectedWslDistro = - resolveWslSessionContext({ - cwd: ctx.cwd, - sessionId: ctx.effectiveSessionId, - shellOverride: ctx.terminalRuntimeOptions.shellOverride, - terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro - })?.distro ?? null - const initialSelectionTarget = getCodexSelectionTargetForPty( - initialShellOverride, - ctx.cwd, - ctx.expectedWslDistro - ) - args.env = withClaudeProfileTerminalEnv(args.env, initialSelectionTarget) + const target = await resolvePtySpawnStartupTarget({ + worktreeId: args.worktreeId, + cwd: args.cwd, + sessionId: ctx.effectiveSessionId, + shellOverride: args.shellOverride, + plannedPty: ctx.paneAgentLaunch?.launchRuntime?.pty, + projectRuntimeSource: { kind: 'sent', projectRuntime: args.projectRuntime }, + // Why: reattach needs exact cwd, and successful stable-pane adoption needs no launch preflight. + missingCwdFallback: + !ctx.preAdoptedStablePane && !args.sessionId && args.cwdFallback === 'worktree' + ? { directoryExists: ctx.deps.localStartupCwdDirectoryExists } + : undefined, + getSettings: ctx.deps.getSettings, + resolveStartupCwd: ctx.deps.resolvePtySpawnStartupCwd + }) + ctx.cwd = target.cwd + ctx.prevalidatedCwd = target.prevalidatedCwd + ctx.startupCwdFallback = target.startupCwdFallback + ctx.terminalRuntimeOptions = target.terminalRuntimeOptions + ctx.expectedWslDistro = target.expectedWslDistro + ctx.codexSelectionTarget = target.codexSelectionTarget + ctx.spawnTiming.mark('preflight') + args.env = withClaudeProfileTerminalEnv(args.env, ctx.codexSelectionTarget) ctx.claudeAuth = ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth - ? await ctx.deps.prepareClaudeAuth(initialSelectionTarget) + ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) : null ctx.spawnTiming.mark('auth') } diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 36a854b946d..302b50f5376 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -9,7 +9,6 @@ import { getProvider } from '../provider/registry' import { buildPtyHostEnv } from '../host-env/assembly' import { getCompatibleSelectedCodexHomePath, - getCodexSelectionTargetForPty, resolveCodexHomeAfterManagedAuthReadiness, shouldSkipCodexHomeEnvForWindowsShell, shouldStripInheritedOrcaCodexHome, @@ -27,9 +26,7 @@ import { mintPtySessionId, ptySessionIdForAgentCreateOperation } from '../../../daemon/pty-session-id' -import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' -import { resolvePlainTerminalPtyOptions } from '../../../local-project-runtime-resolution' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' import { stampRuntimeSourceEnv } from '../../../../shared/runtime-source-env' @@ -38,6 +35,7 @@ import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex- import { clearProviderPtyState } from '../provider/state-cleanup' import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness' import type { RuntimePtySpawnState } from './spawn-state' +import { resolvePtySpawnStartupTarget } from '../spawn-startup-target' import { getConnectionExecutionHostId } from '../../../../shared/execution-host' export async function prepareRuntimePtySpawn( @@ -50,7 +48,6 @@ export async function prepareRuntimePtySpawn( await admission } } - ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) // Only this machine spawns here: an `ssh:` connection throws, so every line below is local. ctx.provider = getProvider(getConnectionExecutionHostId(args.connectionId)) const freshSpawnRecovery = ctx.preAdoptedStablePane @@ -60,17 +57,6 @@ export async function prepareRuntimePtySpawn( await freshSpawnRecovery } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && isClaudeLaunchCommand(args.command) - // Why: runtime-created terminals carry no renderer projectRuntime. An agent launch brings the PTY - // its command was quoted for; anything else takes the project's runtime and `--shell` pick. - ctx.terminalRuntimeOptions = - args.launchRuntime?.pty ?? - resolvePlainTerminalPtyOptions( - ctx.deps.store, - ctx.deps.getSettings?.(), - args.worktreeId, - args.shellOverride - ) - ctx.daemonShellOverride = ctx.terminalRuntimeOptions.shellOverride ctx.isDaemonHostSpawn = !(ctx.provider instanceof LocalPtyProvider) && !routesFreshSpawnsToLocalProvider(ctx.provider) ctx.callerRequestedSessionId = args.sessionId?.trim() @@ -87,18 +73,23 @@ export async function prepareRuntimePtySpawn( !ctx.preAdoptedStablePane && ctx.isDaemonHostSpawn && (ctx.callerRequestedSessionId === undefined || args.isNewSession === true) - ctx.expectedWslDistro = - resolveWslSessionContext({ - cwd: ctx.cwd, - sessionId: ctx.sessionId, - shellOverride: ctx.terminalRuntimeOptions.shellOverride, - terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro - })?.distro ?? null - ctx.codexSelectionTarget = getCodexSelectionTargetForPty( - ctx.daemonShellOverride, - ctx.cwd, - ctx.expectedWslDistro - ) + // Why: runtime-created terminals carry no renderer projectRuntime. An agent launch brings the PTY + // its command was quoted for; anything else takes the project's runtime and `--shell` pick. + const target = await resolvePtySpawnStartupTarget({ + worktreeId: args.worktreeId, + cwd: args.cwd, + sessionId: ctx.sessionId, + shellOverride: args.shellOverride, + plannedPty: args.launchRuntime?.pty, + projectRuntimeSource: { kind: 'store', store: ctx.deps.store }, + getSettings: ctx.deps.getSettings, + resolveStartupCwd: ctx.deps.resolvePtySpawnStartupCwd + }) + ctx.cwd = target.cwd + ctx.terminalRuntimeOptions = target.terminalRuntimeOptions + ctx.daemonShellOverride = ctx.terminalRuntimeOptions.shellOverride + ctx.expectedWslDistro = target.expectedWslDistro + ctx.codexSelectionTarget = target.codexSelectionTarget const codexResumePreparation = ctx.preAdoptedStablePane ? null : ctx.deps.prepareCodexResumeHome({ diff --git a/src/main/ipc/pty/spawn-startup-target.ts b/src/main/ipc/pty/spawn-startup-target.ts new file mode 100644 index 00000000000..23349a17a9f --- /dev/null +++ b/src/main/ipc/pty/spawn-startup-target.ts @@ -0,0 +1,165 @@ +// Where a new PTY starts and in which shell/runtime, for both the window and the host spawn lane. + +import { + isWslShellName, + type LocalWindowsTerminalRuntimeOptions +} from '../../../shared/local-windows-terminal-runtime' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import type { TerminalStartupCwdMissingDirFallback } from '../../../shared/terminal-startup-cwd' +import { isWslUncPath, toWindowsWslPath } from '../../../shared/wsl-paths' +import type { CodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection' +import { resolveWslSessionContext } from '../../daemon/wsl-session-context' +import { + resolveLocalProjectRuntimeForWorktreeId, + resolveLocalPtyOptions, + type ProjectRuntimeResolutionStore +} from '../../local-project-runtime-resolution' +import { normalizeWindowsTerminalCwd } from '../../providers/windows-shell-args' +import { wslUncDirectoryExistsAsync } from '../../wsl' +import { getCodexSelectionTargetForPty } from './host-env/codex-home' + +// TEMPORARY (row 13b removes it): the window uses the renderer's runtime; the host reads the store. +export type PtySpawnProjectRuntimeSource = + | { kind: 'sent'; projectRuntime: ProjectExecutionRuntimeResolution | undefined } + | { kind: 'store'; store: ProjectRuntimeResolutionStore | undefined } + +export type PtySpawnStartupTarget = { + cwd: string | undefined + /** Already proven to exist by the WSL probe. */ + prevalidatedCwd?: string + startupCwdFallback?: { kind: 'worktree'; cwd: string } + terminalRuntimeOptions: LocalWindowsTerminalRuntimeOptions + expectedWslDistro: string | null + codexSelectionTarget: CodexAccountSelectionTarget +} + +type PtySpawnStartupArgs = { + worktreeId: string | undefined + cwd: string | undefined + /** Its worktree can imply the WSL distro. */ + sessionId: string | undefined + shellOverride: string | undefined + /** The PTY an agent launch's command was quoted for. */ + plannedPty?: LocalWindowsTerminalRuntimeOptions + projectRuntimeSource: PtySpawnProjectRuntimeSource + /** TEMPORARY (row 13a removes it): only window starts fall back to the root of a missing cwd. */ + missingCwdFallback?: { directoryExists: (path: string) => boolean } + getSettings: (() => Parameters[0]) | undefined + resolveStartupCwd: ( + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback + ) => string | undefined +} + +export async function resolvePtySpawnStartupTarget( + args: PtySpawnStartupArgs +): Promise { + const source = args.projectRuntimeSource + const terminalRuntimeOptions = + args.plannedPty ?? + resolveLocalPtyOptions( + args.getSettings?.(), + source.kind === 'sent' + ? source.projectRuntime + : resolveLocalProjectRuntimeForWorktreeId(source.store, args.worktreeId), + args.shellOverride, + process.env.COMSPEC || 'powershell.exe' + ) + const startup = args.missingCwdFallback + ? await resolveStartupCwdWithFallback(args, args.missingCwdFallback, terminalRuntimeOptions) + : { cwd: args.resolveStartupCwd(args.worktreeId, args.cwd) } + const expectedWslDistro = + resolveWslSessionContext({ + cwd: startup.cwd, + sessionId: args.sessionId, + shellOverride: terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null + return { + ...startup, + terminalRuntimeOptions, + expectedWslDistro, + codexSelectionTarget: getCodexSelectionTargetForPty( + terminalRuntimeOptions.shellOverride, + startup.cwd, + expectedWslDistro + ) + } +} + +async function resolveStartupCwdWithFallback( + { worktreeId, resolveStartupCwd, ...args }: PtySpawnStartupArgs, + fallback: { directoryExists: (path: string) => boolean }, + runtimeOptions: LocalWindowsTerminalRuntimeOptions +): Promise> { + const isPosixStartupCwd = args.cwd?.startsWith('/') === true + const startupWorkspaceCwd = isPosixStartupCwd ? resolveStartupCwd(worktreeId, '.') : undefined + const initiallyResolvedStartupCwd = isPosixStartupCwd + ? resolveStartupCwd(worktreeId, args.cwd) + : undefined + // Only a Windows runtime's shell can put a POSIX cwd inside WSL. + const startupRuntime = process.platform === 'win32' ? runtimeOptions : undefined + const wslRuntimeOwnsStartupCwd = + isPosixStartupCwd && + (isWslShellName(startupRuntime?.shellOverride) || isWslUncPath(startupWorkspaceCwd ?? '')) + const startupWslContext = wslRuntimeOwnsStartupCwd + ? resolveWslSessionContext({ + cwd: startupWorkspaceCwd, + shellOverride: startupRuntime?.shellOverride, + terminalWindowsWslDistro: startupRuntime?.terminalWindowsWslDistro + }) + : undefined + let prevalidatedCwd: string | undefined + let wslStartupCwdExists: boolean | null = null + let wslWorkspaceCwdExists: boolean | null = null + if (startupWslContext && initiallyResolvedStartupCwd) { + const validationCwd = toWindowsWslPath(initiallyResolvedStartupCwd, startupWslContext.distro) + wslStartupCwdExists = isWslUncPath(validationCwd) + ? await wslUncDirectoryExistsAsync(validationCwd) + : fallback.directoryExists(validationCwd) + if (wslStartupCwdExists === true) { + prevalidatedCwd = validationCwd + } + if (wslStartupCwdExists === false && startupWorkspaceCwd) { + wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) + ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) + : fallback.directoryExists(startupWorkspaceCwd) + if (wslWorkspaceCwdExists === true) { + prevalidatedCwd = startupWorkspaceCwd + } + } + } + const allowMissingCwdFallback = + !wslRuntimeOwnsStartupCwd || (wslStartupCwdExists === false && wslWorkspaceCwdExists === true) + let didFallbackToWorkspaceRootCwd = false + const cwd = resolveStartupCwd( + worktreeId, + args.cwd, + allowMissingCwdFallback + ? { + directoryExists: (path) => + startupWslContext && + wslStartupCwdExists === false && + path === initiallyResolvedStartupCwd + ? false + : startupWslContext && path === startupWorkspaceCwd + ? wslWorkspaceCwdExists === true + : fallback.directoryExists( + process.platform === 'win32' ? normalizeWindowsTerminalCwd(path) : path + ), + onFallbackToWorkspaceRoot: () => { + didFallbackToWorkspaceRootCwd = true + } + } + : undefined + ) + if (didFallbackToWorkspaceRootCwd && wslWorkspaceCwdExists === true && cwd) { + prevalidatedCwd = cwd + } + return { + cwd, + prevalidatedCwd, + startupCwdFallback: didFallbackToWorkspaceRootCwd && cwd ? { kind: 'worktree', cwd } : undefined + } +} diff --git a/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts b/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts index 660998c079c..6ba97e10cd8 100644 --- a/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts +++ b/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts @@ -15,6 +15,11 @@ import { import { getDefaultSettings } from '../../../../shared/constants' import { WINDOW_LAUNCH_CASES } from '../../../../shared/launch-parity-window-cases.test-fixture' import { PLUS_LAUNCH_CASES } from '../../../../shared/launch-parity-plus-cases.test-fixture' +import { WORKSPACE_START_LAUNCH_CASES } from '../../../../shared/launch-parity-workspace-start-cases.test-fixture' +import { + loadWorkspaceStartModules, + startWorkspaceAgent +} from '@/lib/launch-parity-workspace-start.test-fixture' import { LAUNCH_TAB_ID, LAUNCH_TOKEN, @@ -40,6 +45,12 @@ vi.mock('react', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useCallback: (fn: T): T => fn } }) +// Row 8: what precedes the launch (setup policy and hooks trust) is decided, not asked. +vi.mock('@/lib/launch-work-item-direct-preflight', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveDirectSetupDecision: async () => ({ kind: 'decided', decision: 'inherit' }) +})) +vi.mock('@/lib/ensure-hooks-confirmed', () => ({ ensureHooksConfirmed: async () => 'run' })) vi.mock('@/lib/windows-terminal-capabilities', async (importOriginal) => ({ ...(await importOriginal>()), hasCachedWindowsTerminalCapabilities: () => true, @@ -60,7 +71,8 @@ const load = perClientLoader(async () => ({ pane: await import('./pty-connection'), paneOptions: await import('./terminal-pane-manager-options'), defaults: await import('@/lib/pane-manager/pane-terminal-options'), - lifecycle: await import('./terminal-pane-lifecycle-primitives') + lifecycle: await import('./terminal-pane-lifecycle-primitives'), + workspaceStart: await loadWorkspaceStartModules() })) /** Runs the case's real producer against a real store; returns the tab it opened. */ @@ -75,7 +87,9 @@ async function openTab(c: WindowLaunchCase) { ) const worktreeId = launchWorkspaceId(c.workspace) const producer = c.producer - if (producer.kind === 'quick-command') { + if (producer.kind === 'work-item' || producer.kind === 'folder-composer') { + await startWorkspaceAgent(modules.workspaceStart, store, { ...c, producer }) + } else if (producer.kind === 'quick-command') { modules.quick.runQuickCommandInNewTab({ command: { id: 'qc-1', label: 'Fix', action: 'agent-prompt', ...producer }, worktreeId @@ -205,7 +219,10 @@ describe('window-tab producers: the pty:spawn request a real pane sends', () => vi.unstubAllGlobals() }) - it.each([...WINDOW_LAUNCH_CASES, ...PLUS_LAUNCH_CASES])('row $row: $name', async (c) => { - expect(await paneSpawnRequest(c)).toEqual(windowSpawnRequest(c)) - }) + it.each([...WINDOW_LAUNCH_CASES, ...PLUS_LAUNCH_CASES, ...WORKSPACE_START_LAUNCH_CASES])( + 'row $row: $name', + async (c) => { + expect(await paneSpawnRequest(c)).toEqual(windowSpawnRequest(c)) + } + ) }) diff --git a/src/renderer/src/lib/launch-parity-workspace-start.test-fixture.ts b/src/renderer/src/lib/launch-parity-workspace-start.test-fixture.ts new file mode 100644 index 00000000000..682c37a4004 --- /dev/null +++ b/src/renderer/src/lib/launch-parity-workspace-start.test-fixture.ts @@ -0,0 +1,104 @@ +// Test-only: runs the row-8 producers (an agent started with a workspace) against the parity +// harness's real store. Only what precedes the launch is stubbed: setup policy, hooks trust, agent +// detection and the create itself. +import type { LaunchTestStore } from './launch-parity-renderer.test-fixture' +import type * as WorkItemLaunch from '@/lib/launch-work-item-direct' +import type * as FolderComposerSubmit from '@/components/sidebar/folder-workspace-composer-submit' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../../shared/tui-agent-launch-defaults' +import { + LAUNCH_FOLDER_ID, + type WindowLaunchCase +} from '../../../shared/launch-parity-window-request.test-fixture' + +type WorkspaceStartProducer = Extract< + WindowLaunchCase['producer'], + { kind: 'work-item' | 'folder-composer' } +> + +export type WorkspaceStartModules = { + workItem: typeof WorkItemLaunch + folder: typeof FolderComposerSubmit +} + +export const loadWorkspaceStartModules = async (): Promise => ({ + workItem: await import('@/lib/launch-work-item-direct'), + folder: await import('@/components/sidebar/folder-workspace-composer-submit') +}) + +export async function startWorkspaceAgent( + modules: WorkspaceStartModules, + store: LaunchTestStore, + c: WindowLaunchCase & { producer: WorkspaceStartProducer } +): Promise { + const { producer } = c + const state = store.getState() + // Activating a workspace asks for a PR refresh, which this harness has no bridge for; a prompt + // pasted after start waits on timers. + Object.assign(window.api, { gh: {} }) + Object.assign(window, { setTimeout, clearTimeout }) + store.setState({ + ensureDetectedAgents: async () => [producer.agent], + updateWorktreeMeta: async () => ({ ok: true }) + }) + if (producer.kind === 'work-item') { + // The workspace the create returns is the one the harness seeded. + const worktree = state.worktreesByRepo['repo-1']?.[0] + store.setState({ createWorktree: async () => ({ worktree }) }) + const started = await modules.workItem.launchWorkItemDirect({ + item: { + title: 'Fix the bug', + url: 'https://github.com/acme/repo/issues/7', + type: 'issue', + number: 7, + pasteContent: producer.text + }, + repoId: 'repo-1', + launchSource: 'task_page', + telemetrySource: 'sidebar', + agentOverride: producer.agent, + promptDelivery: producer.delivery, + openModalFallback: () => { + throw new Error(`${c.name}: fell back to the modal`) + } + }) + if (!started) { + throw new Error(`${c.name}: the work item started no agent`) + } + return + } + const folderWorkspace = state.folderWorkspaces.find( + (workspace) => `folder:${workspace.id}` === LAUNCH_FOLDER_ID + ) + const settings = state.settings + await modules.folder.submitFolderWorkspaceCreate({ + projectGroup: { + id: 'pg-1', + name: 'group', + parentPath: c.workspace.path, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 + }, + name: 'folder', + lastAutoName: '', + linkedWorkItem: null, + note: producer.note, + quickAgent: producer.agent, + autoRenameBranchFromWork: false, + // As the composer's folder submit passes them. + agentCmdOverrides: settings?.agentCmdOverrides, + agentArgs: resolveTuiAgentLaunchArgs(producer.agent, settings?.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(producer.agent, settings?.agentDefaultEnv), + terminalWindowsShell: settings?.terminalWindowsShell, + launchSource: 'new_workspace_composer', + createFolderWorkspace: async () => folderWorkspace ?? null, + onOpenChange: () => {} + }) +} diff --git a/src/shared/launch-parity-window-request.test-fixture.ts b/src/shared/launch-parity-window-request.test-fixture.ts index 3258bb1b789..52eaeb9a346 100644 --- a/src/shared/launch-parity-window-request.test-fixture.ts +++ b/src/shared/launch-parity-window-request.test-fixture.ts @@ -33,9 +33,24 @@ export type WindowLaunchProducer = | { kind: 'transcript-continue'; transcript: string; cwd: string | null } /** A "+" launch with no prompt, as each "+" entry point calls launchAgentInNewTab. */ | { kind: 'plus'; agent: 'claude'; via: PlusEntry } + /** A work item's "start agent" (launch-work-item-direct.ts): the workspace and its agent at once. */ + | { + kind: 'work-item' + agent: 'claude' | 'codex' + delivery: 'draft' | 'submit-after-ready' + text: string + } + /** The folder-workspace composer's create with an agent; the note is its prompt. */ + | { kind: 'folder-composer'; agent: 'claude' | 'codex'; note: string } /** "+" entry points. Quick Launch items omit launchSource and get `menu`'s source by default. */ export type PlusEntry = 'menu' | 'shortcut' | 'dashboard' +const PRODUCER_LAUNCH_SOURCE = { + 'quick-command': 'quick_command', + continuation: 'terminal_context_menu', + 'work-item': 'task_page', + 'folder-composer': 'new_workspace_composer' +} as const const PLUS_LAUNCH_SOURCE: Record = { menu: 'tab_bar_quick_launch', shortcut: 'shortcut', @@ -57,10 +72,14 @@ export type WindowRequestFacts = { agentEnv?: Record /** The agent's saved default args; absent = the settings default. */ agentArgs?: string + /** Whether the command waits for the shell to be ready; absent = only Codex does. */ + shellReady?: boolean } /** What reaches the provider for that request (main half). */ export type WindowProviderFacts = { + /** Default: the request's command. */ + command?: string shellOverride: string | undefined terminalWindowsWslDistro?: string | null } @@ -68,7 +87,7 @@ export type WindowProviderFacts = { export type WindowLaunchCase = { name: string /** The launch-convergence producer row. */ - row: 1 | 2 | 6 + row: 1 | 2 | 6 | 8 client: LaunchClient workspace: LaunchWorkspace settings?: Record @@ -138,7 +157,7 @@ export function windowSpawnRequest(c: WindowLaunchCase): Record }, launchToken: LAUNCH_TOKEN, launchAgent: agent, - ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' } : {}), + ...((request.shellReady ?? agent === 'codex') ? { startupCommandDelivery: 'shell-ready' } : {}), worktreeId, tabId: LAUNCH_TAB_ID, leafId: LAUNCH_LEAF_ID, @@ -153,9 +172,7 @@ export function windowSpawnRequest(c: WindowLaunchCase): Record launch_source: producer.kind === 'plus' ? PLUS_LAUNCH_SOURCE[producer.via] - : producer.kind === 'quick-command' - ? 'quick_command' - : 'terminal_context_menu', + : PRODUCER_LAUNCH_SOURCE[producer.kind], request_kind: 'new' } } @@ -182,7 +199,7 @@ export function expectedWindowProvider( // main adds the terminal handle to the pane's env. const env = { ...(typeof request.env === 'object' ? request.env : {}), ORCA_TERMINAL_HANDLE: '' } return { - command: request.command, + command: provider.command ?? request.command, // main normalizes the cwd to forward slashes (resolveRuntimePath). cwd: String(request.cwd).replaceAll('\\', '/'), // The pane's measured size, and never marked hidden before its pane shows. diff --git a/src/shared/launch-parity-workspace-start-cases.test-fixture.ts b/src/shared/launch-parity-workspace-start-cases.test-fixture.ts new file mode 100644 index 00000000000..aec8125eebb --- /dev/null +++ b/src/shared/launch-parity-workspace-start-cases.test-fixture.ts @@ -0,0 +1,177 @@ +// Test-only (never imported by production code): the window-lane launch parity table for row 8 +// (an agent started together with its workspace: a work item's "start agent", the folder-workspace +// composer). Renderer suites assert the real producer + pane send `request`; main suites feed it to +// pty:spawn and assert `provider`. C4 moves these launchers onto the pane trigger: the request then +// carries the launch instead of a command, and every provider fact here must hold unchanged. +import type { + LaunchClient, + LaunchWorkspace, + WindowLaunchCase, + WindowLaunchProducer, + WindowProviderFacts, + WindowRequestFacts +} from './launch-parity-window-request.test-fixture' +import { POSIX_PATH, WIN_PATH } from './launch-parity-window-cases.test-fixture' + +const SKIP = '--dangerously-skip-permissions' +const POSIX_CLAUDE = `claude '${SKIP}'` +const CMD_CLAUDE = `claude "${SKIP}"` +const POSIX_CODEX = "codex '--dangerously-bypass-approvals-and-sandbox'" +const BOB = "fix Bob's bug" +// Longer than one typed line can safely hold, and multi-line: main's window still puts it on argv. +const LONG_NOTE = `${'Read the failing test output and fix the cause. '.repeat(12)}\nThen run it again.` + +const ZSH = { shellOverride: '/bin/zsh' } +const IN_CMD = { + shellOverride: 'cmd.exe', + projectRuntime: { kind: 'windows-host', reason: 'global-default' } +} as const +const CMD_EXE = { shellOverride: 'cmd.exe', terminalWindowsWslDistro: null } +const IN_WSL = { + shellOverride: 'wsl.exe', + projectRuntime: { kind: 'wsl', reason: 'project-override', distro: 'Ubuntu' } +} as const +const WSL_EXE = { shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' } +const CMD = { terminalWindowsShell: 'cmd.exe' } + +const repo = (path: string, extra: Partial = {}): LaunchWorkspace => ({ + kind: 'repo', + path, + ...extra +}) +const folder = (path: string): LaunchWorkspace => ({ kind: 'folder', path }) +const draft = { kind: 'work-item', agent: 'claude', delivery: 'draft', text: BOB } as const +const note = (text: string): WindowLaunchProducer => ({ + kind: 'folder-composer', + agent: 'claude', + note: text +}) + +function start( + name: string, + client: LaunchClient, + workspace: LaunchWorkspace, + producer: WindowLaunchProducer, + request: WindowRequestFacts, + provider: WindowProviderFacts, + settings?: Record +): WindowLaunchCase { + return { name, row: 8, client, workspace, producer, request, provider, settings } +} + +export const WORKSPACE_START_LAUNCH_CASES: WindowLaunchCase[] = [ + // A work item's context is a draft: Claude takes it on argv. + start( + 'work item, macOS repo, draft rides argv', + 'darwin', + repo(POSIX_PATH), + draft, + { command: `${POSIX_CLAUDE} --prefill 'fix Bob'"'"'s bug'`, agentCommand: POSIX_CLAUDE }, + ZSH + ), + // main today: the work item quotes for PowerShell on Windows even when cmd.exe is the shell + // (C4 keeps it; C4-fix flips this row to cmd.exe quoting). + start( + 'work item, Windows C: repo, cmd.exe set', + 'win32', + repo(WIN_PATH), + draft, + { + command: `${POSIX_CLAUDE} --prefill 'fix Bob''s bug'`, + agentCommand: POSIX_CLAUDE, + ...IN_CMD + }, + CMD_EXE, + CMD + ), + start( + 'work item, Windows C: repo, project WSL', + 'win32', + repo(WIN_PATH, { projectRuntime: { kind: 'wsl', distro: 'Ubuntu' } }), + draft, + { + command: `${POSIX_CLAUDE} --prefill 'fix Bob'"'"'s bug'`, + agentCommand: POSIX_CLAUDE, + ...IN_WSL + }, + WSL_EXE, + CMD + ), + // A C: project never set to a runtime, with WSL as the terminal shell: the work item follows the + // Windows runtime default. + start( + 'work item, Windows C: repo, terminal shell WSL', + 'win32', + repo(WIN_PATH), + draft, + { + command: `${POSIX_CLAUDE} --prefill 'fix Bob''s bug'`, + agentCommand: POSIX_CLAUDE, + shellOverride: 'powershell.exe', + projectRuntime: { kind: 'windows-host', reason: 'global-default' } + }, + { shellOverride: 'powershell.exe', terminalWindowsWslDistro: null }, + { terminalWindowsShell: 'wsl.exe' } + ), + // Fix checks submit after the agent is ready: the command carries no prompt, the window pastes it, + // and main's prompt-less Codex start is typed without waiting for the shell to be ready. + start( + 'work item, submit after ready', + 'darwin', + repo(POSIX_PATH), + { kind: 'work-item', agent: 'codex', delivery: 'submit-after-ready', text: BOB }, + { command: POSIX_CODEX, agentCommand: POSIX_CODEX, shellReady: false }, + ZSH + ), + // The composer's note is an auto-submitted prompt on argv. + start( + 'folder composer, macOS folder, note rides argv', + 'darwin', + folder(POSIX_PATH), + note(BOB), + { command: `${POSIX_CLAUDE} 'fix Bob'"'"'s bug'`, agentCommand: POSIX_CLAUDE }, + ZSH + ), + // Unlike the work item, the composer quotes for the Windows shell it runs in. + start( + 'folder composer, Windows C: folder, cmd.exe set', + 'win32', + folder(WIN_PATH), + note(BOB), + // A folder has no project, so no project runtime is sent. + { + command: `${CMD_CLAUDE} "fix Bob's bug"`, + agentCommand: CMD_CLAUDE, + shellOverride: 'cmd.exe' + }, + CMD_EXE, + CMD + ), + // A folder keeps WSL when it is the terminal shell (the host's wslShellRule). + start( + 'folder composer, Windows C: folder, terminal shell WSL', + 'win32', + folder(WIN_PATH), + note(BOB), + { + command: `${POSIX_CLAUDE} 'fix Bob'"'"'s bug'`, + agentCommand: POSIX_CLAUDE, + shellOverride: 'wsl.exe' + }, + // main today: the terminal's default distro, none named. + { shellOverride: 'wsl.exe', terminalWindowsWslDistro: null }, + { terminalWindowsShell: 'wsl.exe' } + ), + // main's window carries a prompt on argv at any length, where the host would paste a long one. + start( + 'folder composer, long multi-line note rides argv', + 'darwin', + folder(POSIX_PATH), + note(LONG_NOTE), + { + command: `${POSIX_CLAUDE} '${LONG_NOTE}'`, + agentCommand: POSIX_CLAUDE + }, + ZSH + ) +]