From 5326c88d0db5aeef2a8ac7aa68043d21f2d812c8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:48:28 -0700 Subject: [PATCH] fix(agent-launch): one shared runtime rule, a derived fact, and only quoted commands carry it Round-1 review fixes for C1: - Window and host read a workspace's runtime through one shared resolver (local-workspace-execution-runtime.ts); a repo-less \\wsl$ folder gets no runtime, as in the window. - The fact is { platform, remote, pty } with the spawn's own PTY type; the quoting shell is derived from it, never stored. - createAgentSession uses the same fact (its pre-C1 exception is deleted). - Worktree startups quote from a fact for the repo and pass it on; a runtime that needs repair fails the startup, not the create. `shell` is required when building a launch command. - The host planner stamps a fact only on commands quoted with it; a caller-quoted command keeps the spawn's own rule. - Qoder probes where the process runs (\\wsl$ cwd, then a WSL shell). - wslShellRule 'host-legacy' keeps today's WSL-shell outcome where no project chose a runtime (temporary, removed by C8). - terminalOnly reaches the early-tab decision with an accurate receipt sentence; the unused client gate is removed. - Adds one host C0 row for a \\wsl$ repo with no saved runtime. --- src/main/agent-launch/agent-launch-mode.ts | 14 ++- .../agent-launch-surface-factories.ts | 96 +++++++------- .../ipc/pty-launch-parity-host.test-cases.ts | 10 +- .../spawn-preflight-requested-shell.test.ts | 5 +- src/main/ipc/pty/runtime/spawn-preflight.ts | 30 ++--- src/main/local-project-runtime-resolution.ts | 105 +++++++++------- .../opencode-model-startup-plan.test.ts | 1 + .../preflight/workspace-agent-detection.ts | 4 +- .../agent-launch-scope-runtime.test.ts | 81 +++++++----- .../runtime/agent-launch-scope-runtime.ts | 107 +++++++--------- .../orca-runtime-activate-managed-worktree.ts | 13 +- .../orca-runtime-create-agent-session.ts | 6 +- .../orca-runtime-create-managed-worktree.ts | 5 + ...e-runtime-owned-mobile-session-terminal.ts | 3 + ...ca-runtime-create-terminal-dependencies.ts | 4 +- ...e-get-agent-session-execution-namespace.ts | 4 +- ...a-runtime-get-terminal-interactive-wait.ts | 2 + ...form-mobile-session-pty-records-refresh.ts | 1 + ...resolve-mobile-session-terminal-command.ts | 8 +- ...tore-structured-agent-session-tabs-once.ts | 22 ++-- ...time-run-create-mobile-session-terminal.ts | 1 + ...a-runtime-terminal-create-deduplication.ts | 4 + .../methods/agent-launch-tab-publication.ts | 3 +- .../runtime-agent-launch-resolution.ts | 18 ++- .../runtime-agent-terminal-startup.test.ts | 33 +++-- .../runtime/runtime-agent-terminal-startup.ts | 106 +++++++++++----- .../runtime/runtime-folder-worktree-create.ts | 8 ++ ...runtime-local-worktree-terminal-startup.ts | 8 ++ .../runtime/runtime-terminal-contracts.ts | 8 +- .../runtime-worktree-agent-startup.test.ts | 52 ++++++-- .../runtime/runtime-worktree-agent-startup.ts | 42 +++++-- .../terminal-shell-override-host-support.ts | 14 ++- .../lib/launch-agent-background-session.ts | 2 + .../src/lib/local-preflight-context.ts | 56 +++------ .../lib/local-project-runtime-workspace.ts | 22 +--- src/shared/agent-launch-operation.test.ts | 14 +-- src/shared/agent-launch-runtime-capability.ts | 2 +- src/shared/agent-launch-runtime.test.ts | 74 ----------- src/shared/agent-launch-runtime.ts | 119 +++++++++--------- .../agent-launch-terminal-inputs.test.ts | 33 ----- src/shared/agent-launch-terminal-inputs.ts | 28 ----- ...artup-plan-inputs-extra-agent-args.test.ts | 4 + src/shared/agent-startup-plan-inputs.test.ts | 22 ++-- src/shared/agent-startup-plan-inputs.ts | 5 +- .../local-workspace-execution-runtime.ts | 78 ++++++++++++ 45 files changed, 676 insertions(+), 601 deletions(-) delete mode 100644 src/shared/agent-launch-runtime.test.ts delete mode 100644 src/shared/agent-launch-terminal-inputs.test.ts delete mode 100644 src/shared/agent-launch-terminal-inputs.ts create mode 100644 src/shared/local-workspace-execution-runtime.ts diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 874f5695656..49f02ebfa14 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -130,13 +130,14 @@ const HOST_SUPPORT_REASON: Record< export function decideAgentLaunchMode(args: { placement: AgentLaunchModePlacement settings: AgentLaunchModeSettings | null | undefined - /** Host-internal: the caller's contract is a terminal handle, so the chat default cannot apply. */ + /** The caller asked for a terminal (its contract is a terminal handle), so the chat default + * cannot apply. */ terminalOnly?: boolean vocabulary?: AgentLaunchModeVocabulary }): AgentLaunchModeReceipt { const { placement, settings } = args const vocabulary = args.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY - if (args.terminalOnly || !isNativeChatEnabled(settings)) { + if (!isNativeChatEnabled(settings)) { return { mode: 'terminal', preferred: 'terminal', @@ -144,6 +145,15 @@ export function decideAgentLaunchMode(args: { detail: `Started ${vocabulary.terminal}, the default for new agent tabs in your settings.` } } + if (args.terminalOnly) { + // No reason word says "the caller asked" yet; a new one needs a capability older peers read. + return { + mode: 'terminal', + preferred: 'terminal', + reason: 'user_default', + detail: `Started ${vocabulary.terminal} because this launch asked for one.` + } + } // A worker placed on another runtime starts through federation, which creates terminal agents // only; this host cannot answer for that runtime's structured support. if (placement.on) { diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index daf3f8f6a70..633bf306a18 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -8,14 +8,6 @@ import type { AgentLaunchIntent, AgentLaunchPrompt } from '../../shared/agent-la import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import type { TuiAgent } from '../../shared/tui-agent' -/** What a terminal agent takes beyond its arguments: its tab's title, saved extra arguments, and - * the `agent_started.request_kind` the host cannot tell for itself. */ -export type AgentLaunchTerminalInputs = { - title?: string - extraAgentArgs?: string - requestKind?: string -} - /** How a surface is built once the executor has decided which one. Injected because an * orchestration worker's session carries a redrive subscription and a mailbox a plain launch * must not take, while the decision and ordering above it are identical. */ @@ -31,25 +23,27 @@ export type AgentLaunchSurfaceFactory = { * is shown; absent records the id clients derive. */ tabId?: string }): Promise - createTerminalAgent( - args: { - worktreeId: string - agent: TuiAgent - options?: Readonly> - /** Offered only for an agent whose CLI takes the prompt on argv. It rides the launch command - * only when the typed line can carry it; `promptRodeLaunchCommand` reports which happened. */ - startupPrompt?: string - /** Replaces the settings default for this launch only; `null` means no arguments at all. */ - agentArgs?: string | null - cwd?: string - /** The `agent_started` surface, which the host cannot derive for itself. */ - launchSource?: string - /** The caller-minted pane to create; refused with `AgentLaunchPaneAlreadyLiveError` if live. */ - paneKey?: string - /** The tab's first view, derived on the host by the window's own rule. */ - viewMode?: 'terminal' | 'chat' - } & AgentLaunchTerminalInputs - ): Promise<{ + createTerminalAgent(args: { + worktreeId: string + agent: TuiAgent + options?: Readonly> + /** Offered only for an agent whose CLI takes the prompt on argv. It rides the launch command + * only when the typed line can carry it; `promptRodeLaunchCommand` reports which happened. */ + startupPrompt?: string + /** Replaces the settings default for this launch only; `null` means no arguments at all. */ + agentArgs?: string | null + cwd?: string + /** The one member of the `agent_started` triple the host cannot derive for itself. */ + launchSource?: string + /** The caller-minted pane to create; refused with `AgentLaunchPaneAlreadyLiveError` if live. */ + paneKey?: string + /** The tab's first view, derived on the host by the window's own rule. */ + viewMode?: 'terminal' | 'chat' + /** As on the intent: the tab's title, saved extra arguments, and `agent_started.request_kind`. */ + title?: AgentLaunchIntent['title'] + extraAgentArgs?: AgentLaunchIntent['extraAgentArgs'] + requestKind?: AgentLaunchIntent['requestKind'] + }): Promise<{ handle: string /** The pane this create minted; a factory whose runtime reports none omits it, never invents. */ paneKey?: string @@ -111,29 +105,31 @@ export class AgentLaunchStructuredSessionRefusedError extends Error { /** Creating the workspace, when the intent asks for one. Injected so orchestration keeps recording * its own worktree stages and residual-resource effects around the same call. */ export type AgentLaunchWorkspaceFactory = { - createWorktree( - args: { - create: Readonly> - /** Set only when the settled mode is a terminal agent: agent-first creation sequences the - * agent's startup command behind the setup runner, which is how a PTY launch gets its - * wait-for-setup gate for free. A structured launch has no startup command to sequence and - * must await that gate explicitly instead. */ - startupAgent: TuiAgent | undefined - /** Offered only alongside a `startupAgent` whose CLI takes the prompt on argv: agent-first - * creation builds the startup command, so that is where the typed line is measured. */ - startupPrompt?: string - /** Set instead of `startupPrompt` under the `legacy-host` prompt policy: this create owns the - * text for any agent and length, as `worktree.create` always has. */ - legacyPrompt?: AgentLaunchPrompt - /** Inputs needed when this terminal is created as the worktree's startup surface. */ - agentArgs?: string | null - cwd?: string - launchSource?: string - paneKey?: string - /** The launch's session options, read as the startup terminal's model/effort/mode preferences. */ - options?: Readonly> - } & AgentLaunchTerminalInputs - ): Promise<{ + createWorktree(args: { + create: Readonly> + /** Set only when the settled mode is a terminal agent: agent-first creation sequences the + * agent's startup command behind the setup runner, which is how a PTY launch gets its + * wait-for-setup gate for free. A structured launch has no startup command to sequence and + * must await that gate explicitly instead. */ + startupAgent: TuiAgent | undefined + /** Offered only alongside a `startupAgent` whose CLI takes the prompt on argv: agent-first + * creation builds the startup command, so that is where the typed line is measured. */ + startupPrompt?: string + /** Set instead of `startupPrompt` under the `legacy-host` prompt policy: this create owns the + * text for any agent and length, as `worktree.create` always has. */ + legacyPrompt?: AgentLaunchPrompt + /** Inputs needed when this terminal is created as the worktree's startup surface. */ + agentArgs?: string | null + cwd?: string + launchSource?: string + paneKey?: string + /** The launch's session options, read as the startup terminal's model/effort/mode preferences. */ + options?: Readonly> + /** As on the intent: the tab's title, saved extra arguments, and `agent_started.request_kind`. */ + title?: AgentLaunchIntent['title'] + extraAgentArgs?: AgentLaunchIntent['extraAgentArgs'] + requestKind?: AgentLaunchIntent['requestKind'] + }): Promise<{ worktreeId: string /** The new workspace's SSH connection; `null` is local. The executor carries it but nothing * reads it yet; absent when the factory did not resolve it. */ diff --git a/src/main/ipc/pty-launch-parity-host.test-cases.ts b/src/main/ipc/pty-launch-parity-host.test-cases.ts index 631291c0a10..9e2e123e375 100644 --- a/src/main/ipc/pty-launch-parity-host.test-cases.ts +++ b/src/main/ipc/pty-launch-parity-host.test-cases.ts @@ -2,7 +2,7 @@ // producer makes and the facts main hands the provider, the phone and telemetry for it. import type { TerminalCreateOptions } from '../runtime/runtime-terminal-contracts' import type { LaunchWorkspace } from '../../shared/launch-parity-window-request.test-fixture' -import { POSIX_PATH } from '../../shared/launch-parity-window-cases.test-fixture' +import { POSIX_PATH, WSL_PATH } from '../../shared/launch-parity-window-cases.test-fixture' export const HOST_TAB_ID = '33333333-3333-4333-8333-333333333333' export const HOST_LEAF_ID = '44444444-4444-4444-8444-444444444444' @@ -128,6 +128,14 @@ function aiButton( export const HOST_LAUNCH_CASES: HostLaunchCase[] = [ // C1 (host planner inputs) and C2 (AI buttons onto the pane trigger) must keep these commands. aiButton('macOS repo', 'darwin', repo(POSIX_PATH), { command: POSIX_CLAUDE, ...ZSH }), + // C1: a `\\wsl$` repo with no saved runtime is quoted for Linux and runs in its distro, as in "+". + aiButton( + 'Windows wsl$ repo, no saved runtime', + 'win32', + repo(WSL_PATH), + { command: POSIX_CLAUDE, shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' }, + CMD + ), aiButton( 'macOS folder, saved default args', 'darwin', diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index 99632bbf80e..4e1c9581384 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -138,9 +138,8 @@ describe('runtime pty spawn preflight: requested shell on a local Windows host', shellOverride: 'cmd.exe', launchRuntime: { platform: 'linux', - quotingShell: 'posix', - ptyShellOverride: 'wsl.exe', - wslDistro: 'Ubuntu' + remote: false, + pty: { shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' } } }) ).resolves.toMatchObject({ shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' }) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 64575112ef1..44e6f2b7a99 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -29,9 +29,7 @@ import { } from '../../../daemon/pty-session-id' import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' -import { resolveLocalWindowsTerminalRuntimeOptions } from '../../../../shared/local-windows-terminal-runtime' -import { resolveLocalProjectRuntimeForWorktreeId } from '../../../local-project-runtime-resolution' -import { resolveManagedSshHostLoginShell } from '../../../pty/managed-ssh-host-login-shell' +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' @@ -69,24 +67,14 @@ export async function prepareRuntimePtySpawn( // default shell with the request typed into it. Still Windows-only: the override names a // Windows shell, and spawn-options applies it under the same platform gate. // An agent launch's shell is the one it was quoted for, resolved once by its plan. - ctx.terminalRuntimeOptions = args.launchRuntime - ? { - shellOverride: args.launchRuntime.ptyShellOverride, - terminalWindowsWslDistro: args.launchRuntime.wslDistro - } - : process.platform === 'win32' - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.shellOverride, - settings: ctx.deps.getSettings?.(), - projectRuntime: resolveLocalProjectRuntimeForWorktreeId(ctx.deps.store, args.worktreeId), - fallbackHostShell: process.env.COMSPEC || 'powershell.exe', - sshLoginShell: resolveManagedSshHostLoginShell() - }) - : { - shellOverride: - args.shellOverride ?? (ctx.deps.getSettings?.()?.terminalDefaultShell || undefined), - terminalWindowsWslDistro: null - } + 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) diff --git a/src/main/local-project-runtime-resolution.ts b/src/main/local-project-runtime-resolution.ts index 2f3c2008567..fea481dc178 100644 --- a/src/main/local-project-runtime-resolution.ts +++ b/src/main/local-project-runtime-resolution.ts @@ -4,10 +4,11 @@ import type { Project } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import type { FolderWorkspace } from '../shared/folder-workspace-types' import type { ProjectGroup } from '../shared/project-group-types' -import { findFolderWorkspaceCandidateRepos } from '../shared/folder-workspace-execution-host' -import { parseWslUncPath } from '../shared/wsl-paths' import { - getWorkspaceRuntimePreference, + findLocalFolderWorkspaceRuntimeRepo, + resolveLocalWorkspaceExecutionRuntime +} from '../shared/local-workspace-execution-runtime' +import { resolveProjectExecutionRuntime, type ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' @@ -18,6 +19,9 @@ import { hasCachedWslDistros } from './wsl' import { getRepoIdFromWorktreeId } from '../shared/worktree/id' +import { resolveLocalPtyRuntimeOptions } from '../shared/agent-launch-runtime' +import type { LocalWindowsTerminalRuntimeOptions } from '../shared/local-windows-terminal-runtime' +import { resolveManagedSshHostLoginShell } from './pty/managed-ssh-host-login-shell' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' /** @@ -57,20 +61,27 @@ function resolveLocalProjectRuntime( project: Pick, settings: ReturnType = store.getSettings() ): ProjectExecutionRuntimeResolution { - const wslAvailable = hasCachedWslAvailability() - ? (getCachedWslAvailability() ?? undefined) - : undefined - const availableWslDistros = hasCachedWslDistros() ? getCachedWslDistros() : null return resolveProjectExecutionRuntime({ appPlatform: process.platform, projectId: project.id, projectRuntimePreference: project.localWindowsRuntimePreference, globalWindowsRuntimeDefault: settings.localWindowsRuntimeDefault, - wslAvailable, - availableWslDistros + ...cachedWslFacts() }) } +function cachedWslFacts(): { + wslAvailable?: boolean + availableWslDistros: readonly string[] | null +} { + return { + wslAvailable: hasCachedWslAvailability() + ? (getCachedWslAvailability() ?? undefined) + : undefined, + availableWslDistros: hasCachedWslDistros() ? getCachedWslDistros() : null + } +} + export function resolveLocalProjectRuntimeForRepo( store: ProjectRuntimeResolutionStore, repo: Repo @@ -88,8 +99,8 @@ export function resolveLocalProjectRuntimeForRepo( return resolveLocalProjectRuntime(store, project) } -/** Same choice the renderer makes for a workspace: the project's runtime, else the distro of a - * WSL-share path, else the global default. Works for a repo with no project too. */ +/** The window's runtime for a repo workspace (`resolveLocalWorkspaceExecutionRuntime`), read from + * main's store and WSL caches. Works for a repo with no project too. */ export function resolveLocalWorkspaceRuntime( store: ProjectRuntimeResolutionStore, repo: Repo, @@ -101,13 +112,13 @@ export function resolveLocalWorkspaceRuntime( ) { return undefined } - const project = store.getProjects().find((entry) => entry.sourceRepoIds.includes(repo.id)) - return resolveLocalProjectRuntime(store, { - id: project?.id ?? repo.id, - localWindowsRuntimePreference: getWorkspaceRuntimePreference( - project?.localWindowsRuntimePreference, - workspacePath - ) + return resolveLocalWorkspaceExecutionRuntime({ + appPlatform: process.platform, + projects: store.getProjects(), + settings: store.getSettings(), + repoId: repo.id, + workspacePath, + ...cachedWslFacts() }) } @@ -117,23 +128,6 @@ export type LaunchScopeRuntimeStore = ProjectRuntimeResolutionStore & { getProjectGroups?: () => ProjectGroup[] } -/** The one local repo whose project runs a folder workspace; a folder spanning several has none. */ -export function findFolderWorkspaceRuntimeRepo( - store: LaunchScopeRuntimeStore, - folderWorkspaceId: string -): Repo | undefined { - const candidates = findFolderWorkspaceCandidateRepos( - { - folderWorkspaces: store.getFolderWorkspaces?.() ?? [], - projectGroups: store.getProjectGroups?.() ?? [], - repos: store.getRepos?.() ?? [] - }, - folderWorkspaceId - ) - const repo = candidates.length === 1 ? candidates[0] : undefined - return repo && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID ? repo : undefined -} - /** The window's runtime for a workspace (`getLocalProjectExecutionRuntimeContext`), for a launch * scope this host resolved. A remote scope has none: its host plans for itself. */ export function resolveLocalLaunchScopeRuntime( @@ -146,25 +140,23 @@ export function resolveLocalLaunchScopeRuntime( folderWorkspace: Pick | null } ): ProjectExecutionRuntimeResolution | undefined { - if (scope.connectionId || !canResolveProjectRuntimeForRepo(store)) { + if (scope.connectionId) { return undefined } const repo = scope.repo ?? (scope.folderWorkspace - ? findFolderWorkspaceRuntimeRepo(store, scope.folderWorkspace.id) + ? findLocalFolderWorkspaceRuntimeRepo( + { + folderWorkspaces: store.getFolderWorkspaces?.() ?? [], + projectGroups: store.getProjectGroups?.() ?? [], + repos: store.getRepos?.() ?? [] + }, + scope.folderWorkspace.id + ) : undefined) - if (repo) { - return resolveLocalWorkspaceRuntime(store, repo, scope.path) - } - // A repo-less folder has no project, so only a `\\wsl$` path places it, as in the window. - const distro = parseWslUncPath(scope.path)?.distro - return distro - ? resolveLocalProjectRuntime(store, { - id: scope.id, - localWindowsRuntimePreference: { kind: 'wsl', distro } - }) - : undefined + // A repo-less folder has no runtime: its path alone places it, and its cwd picks its shell. + return repo ? resolveLocalWorkspaceRuntime(store, repo, scope.path) : undefined } export function resolveLocalProjectRuntimesForRepos( @@ -214,3 +206,20 @@ export function resolveLocalProjectRuntimeForWorktreeId( const repo = store.getRepo(getRepoIdFromWorktreeId(worktreeId)) return repo ? resolveLocalProjectRuntimeForRepo(store, repo) : undefined } + +/** A plain terminal's PTY on this host: the spawn's rule when no agent plan brought its own. */ +export function resolvePlainTerminalPtyOptions( + store: ProjectRuntimeResolutionStore | undefined, + settings: Parameters[0]['settings'], + worktreeId: string | undefined, + requestedShellOverride: string | undefined +): LocalWindowsTerminalRuntimeOptions { + return resolveLocalPtyRuntimeOptions({ + hostPlatform: process.platform, + requestedShellOverride, + settings, + projectRuntime: resolveLocalProjectRuntimeForWorktreeId(store, worktreeId), + fallbackHostShell: process.env.COMSPEC || 'powershell.exe', + sshLoginShell: resolveManagedSshHostLoginShell() + }) +} diff --git a/src/main/opencode/opencode-model-startup-plan.test.ts b/src/main/opencode/opencode-model-startup-plan.test.ts index 46771e630bc..fd27517d95b 100644 --- a/src/main/opencode/opencode-model-startup-plan.test.ts +++ b/src/main/opencode/opencode-model-startup-plan.test.ts @@ -35,6 +35,7 @@ function scope() { }, platform: process.platform, isRemote: false, + shell: undefined, sessionOptions: { model: preferred } }), cwd: '/private/project', diff --git a/src/main/preflight/workspace-agent-detection.ts b/src/main/preflight/workspace-agent-detection.ts index 640c5275a77..9b5b512c0a0 100644 --- a/src/main/preflight/workspace-agent-detection.ts +++ b/src/main/preflight/workspace-agent-detection.ts @@ -6,8 +6,8 @@ import { } from '../../shared/execution-host' import { resolveFolderWorkspaceHost } from '../../shared/folder-workspace-execution-host' import type { Repo } from '../../shared/repo-types' +import { findLocalFolderWorkspaceRuntimeRepo } from '../../shared/local-workspace-execution-runtime' import { - findFolderWorkspaceRuntimeRepo, resolveLocalWorkspaceRuntime, type LaunchScopeRuntimeStore } from '../local-project-runtime-resolution' @@ -108,7 +108,7 @@ function resolveFolderWorkspace( throw new Error(WORKSPACE_ON_OTHER_RUNTIME) } const folder = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) - const repo = findFolderWorkspaceRuntimeRepo(store, folderWorkspaceId) + const repo = findLocalFolderWorkspaceRuntimeRepo(state, folderWorkspaceId) return host.kind === 'local' && folder && repo ? { kind: 'local', repo, path: folder.folderPath } : null diff --git a/src/main/runtime/agent-launch-scope-runtime.test.ts b/src/main/runtime/agent-launch-scope-runtime.test.ts index fc7a3245aa5..e8e5b0e5e61 100644 --- a/src/main/runtime/agent-launch-scope-runtime.test.ts +++ b/src/main/runtime/agent-launch-scope-runtime.test.ts @@ -1,40 +1,22 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { Repo } from '../../shared/repo-types' import type { FolderWorkspace } from '../../shared/folder-workspace-types' -import { resolveProjectExecutionRuntime } from '../../shared/project-execution-runtime' import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl' import { agentLaunchPlatformForScope, resolveAgentLaunchRuntime } from './agent-launch-scope-runtime' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' +import { agentLaunchQuotingShell } from '../../shared/agent-launch-runtime' const WIN = 'C:\\repo' const SHARE = '\\\\wsl$\\Ubuntu\\home\\a\\repo' const UBUNTU = { kind: 'wsl', distro: 'Ubuntu' } as const -const repo = (path: string): Repo => ({ - id: 'r', - path, - displayName: 'r', - badgeColor: '#000', - addedAt: 0, - projectGroupId: 'g' -}) -const folder = (path: string): FolderWorkspace => ({ - id: 'f', - projectGroupId: 'g', - name: 'f', - folderPath: path, - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - createdAt: 0, - updatedAt: 0 -}) +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolvers read only the fields each fixture sets. +const fixture = (fields: Partial): T => fields as T +const repo = (path: string) => fixture({ id: 'r', path, projectGroupId: 'g' }) +const folder = (path: string) => + fixture({ id: 'f', projectGroupId: 'g', folderPath: path }) const scope = ( path: string, kind: { repo?: Repo; folder?: FolderWorkspace } @@ -50,6 +32,7 @@ function store(args: { project?: unknown global?: unknown folder?: FolderWorkspace + shell?: string }) { const fake = { getProjects: () => [ @@ -60,7 +43,7 @@ function store(args: { } ], getSettings: () => ({ - terminalWindowsShell: 'cmd.exe', + terminalWindowsShell: args.shell ?? 'cmd.exe', localWindowsRuntimeDefault: args.global }), getRepo: () => args.repos?.[0], @@ -126,21 +109,51 @@ describe("the window's runtime rule, on the host", () => { }) // PLAN §7 UNVERIFIED 4. Relied on by C2, whose pane spawn takes the host's answer. -it("answers from main's WSL cache when the window has not loaded its own", () => { +it("answers from main's WSL cache, which the window may not have read yet", () => { _setWslCachesForTests({ available: true, distros: ['Debian'] }) expect(() => resolveAgentLaunchRuntime( store({ project: UBUNTU }), scope(WIN, { repo: repo(WIN) }), - undefined + undefined, + 'host-legacy' ) ).toThrow('wsl-distro-missing') - // The window before its first capability read passes no WSL facts (local-preflight-context.ts). + // A repo-less `\\wsl$` folder has no runtime to repair, as in the window: its path places it. expect( - resolveProjectExecutionRuntime({ - appPlatform: 'win32', - projectId: 'p', - projectRuntimePreference: UBUNTU - }).status - ).toBe('resolved') + resolveAgentLaunchRuntime( + store({}), + scope(SHARE, { folder: folder(SHARE) }), + undefined, + 'host-legacy' + ).platform + ).toBe('linux') +}) + +// Relied on by C2: the pane's pty:spawn takes the fact as is, for quoting and shell alike. +it('quotes for the shell the PTY gets, not a setting the runtime replaced', () => { + const windowsHost = store({ project: { kind: 'windows-host' }, shell: 'wsl.exe' }) + const local = resolveAgentLaunchRuntime( + windowsHost, + scope(WIN, { repo: repo(WIN) }), + undefined, + 'host-legacy' + ) + expect(local.pty.shellOverride).toBe('powershell.exe') + expect(agentLaunchQuotingShell(local)).toBe('powershell') +}) + +// TEMPORARY until C8, which decides how to model a global default nobody chose. +it('keeps a WSL Terminal shell where no project chose a runtime, until C8', () => { + const s = store({ global: { kind: 'windows-host' }, shell: 'wsl.exe' }) + const launchScope = scope(WIN, { repo: repo(WIN) }) + const legacy = resolveAgentLaunchRuntime(s, launchScope, undefined, 'host-legacy') + expect([legacy.platform, legacy.pty.shellOverride, agentLaunchQuotingShell(legacy)]).toEqual([ + 'win32', + 'wsl.exe', + 'posix' + ]) + expect(resolveAgentLaunchRuntime(s, launchScope, undefined, 'window').pty.shellOverride).toBe( + 'powershell.exe' + ) }) diff --git a/src/main/runtime/agent-launch-scope-runtime.ts b/src/main/runtime/agent-launch-scope-runtime.ts index 30d8e8e5441..d14576c0fd2 100644 --- a/src/main/runtime/agent-launch-scope-runtime.ts +++ b/src/main/runtime/agent-launch-scope-runtime.ts @@ -5,40 +5,20 @@ import { remoteExecutionPlatform, type AgentLaunchRuntime } from '../../shared/agent-launch-runtime' -import { resolveStartupShell } from '../../shared/tui-agent-startup-shell' -import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' -import { isWslUncPath } from '../../shared/wsl-paths' +import { getRepoSshConnectionId } from '../../shared/execution-host' import { resolveLocalLaunchScopeRuntime, - resolveLocalProjectRuntimeForRepo, resolveLocalProjectRuntimeForWorktreeId, - resolveLocalWorkspaceRuntime, type LaunchScopeRuntimeStore } from '../local-project-runtime-resolution' +import { isWslShellName } from '../../shared/local-windows-terminal-runtime' import { resolveManagedSshHostLoginShell } from '../pty/managed-ssh-host-login-shell' -import { getAgentLaunchPlatformForRepo } from './runtime-agent-launch-resolution' import type { RuntimeStore } from './runtime-store-contract' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' type AgentLaunchRuntimeStore = Omit & Pick -/** The platform an agent for this repo is quoted for before any workspace exists, by the window's - * rule (`resolveRepoExecutionHostPlatform`). */ -export function agentLaunchPlatformForRepo( - store: LaunchScopeRuntimeStore, - repo: Repo -): NodeJS.Platform { - if (repo.connectionId) { - return remoteExecutionPlatform(repo.path) - } - return executionRuntimePlatform( - resolveLocalWorkspaceRuntime(store, repo, repo.path), - repo.path, - process.platform - ) -} - /** The platform an agent in this workspace is quoted for; never throws, unlike the full fact. */ export function agentLaunchPlatformForScope( store: LaunchScopeRuntimeStore, @@ -53,63 +33,66 @@ export function agentLaunchPlatformForScope( ) } +/** + * TEMPORARY, removed by C8. `host-legacy` keeps today's host outcome for a Windows scope with no + * project runtime of its own whose global default is the Windows host: a `wsl.exe` Terminal shell + * still runs it in WSL. An explicit "Windows host" default cannot yet be told apart from the + * shipped one. `window` is the "+" button's rule. + */ +export type WslShellRule = 'host-legacy' | 'window' + /** The one runtime fact for an agent launched into this workspace. Throws, as the spawn would, * when the workspace's runtime needs repair. */ export function resolveAgentLaunchRuntime( store: AgentLaunchRuntimeStore, scope: TerminalWorkspaceLaunchScope, - requestedShellOverride: string | undefined + requestedShellOverride: string | undefined, + wslShellRule: WslShellRule ): AgentLaunchRuntime { + const settings = store.getSettings() + const projectRuntime = resolveLocalLaunchScopeRuntime(store, scope) + const keepsWslShell = + wslShellRule === 'host-legacy' && + process.platform === 'win32' && + projectRuntime?.status === 'resolved' && + projectRuntime.runtime.kind === 'windows-host' && + projectRuntime.runtime.reason === 'global-default' && + isWslShellName(settings.terminalWindowsShell) && + !resolveLocalProjectRuntimeForWorktreeId(store, scope.id) return deriveAgentLaunchRuntime({ hostPlatform: process.platform, remote: Boolean(scope.connectionId), workspacePath: scope.path, - projectRuntime: resolveLocalLaunchScopeRuntime(store, scope), + // With no runtime, the spawn runs the Terminal shell, as main's host did for these scopes. + projectRuntime: keepsWslShell ? undefined : projectRuntime, requestedShellOverride, - settings: store.getSettings(), + settings, sshLoginShell: resolveManagedSshHostLoginShell() }) } /** - * Main's quoting and shell rules from before the one fact, as one fact: kept only for the OpenCode - * model hop (`createAgentSession`), which C2 deletes. + * The fact for an agent a new workspace of this repo starts, built before the workspace exists. A + * runtime that needs repair returns its error with the platform to quote for, so the create goes on + * and the startup fails with that error as its spawn would. */ -export function resolvePreC1AgentLaunchRuntime( +export function resolveRepoAgentLaunchRuntime( store: AgentLaunchRuntimeStore, - scope: TerminalWorkspaceLaunchScope -): AgentLaunchRuntime { - const settings = store.getSettings() - const platform = scope.repo - ? getAgentLaunchPlatformForRepo( - scope.repo, - scope.repo.connectionId ? undefined : resolveLocalProjectRuntimeForRepo(store, scope.repo) - ) - : scope.connectionId - ? remoteExecutionPlatform(scope.path) - : isWslUncPath(scope.path) - ? 'linux' - : process.platform - const pty = deriveAgentLaunchRuntime({ - hostPlatform: process.platform, - remote: Boolean(scope.connectionId), - workspacePath: scope.path, - projectRuntime: resolveLocalProjectRuntimeForWorktreeId(store, scope.id), - requestedShellOverride: undefined, - settings, - fallbackHostShell: process.env.COMSPEC || 'powershell.exe', - sshLoginShell: resolveManagedSshHostLoginShell() - }) - return { - ...pty, - platform, - quotingShell: resolveStartupShell( - platform, - resolveLocalWindowsAgentStartupShell({ - platform, - isRemote: Boolean(scope.connectionId), - terminalWindowsShell: settings.terminalWindowsShell - }) - ) + repo: Repo +): AgentLaunchRuntime | { platform: NodeJS.Platform; repairError: Error } { + const scope = { + id: repo.id, + path: repo.path, + connectionId: getRepoSshConnectionId(repo), + repo, + folderWorkspace: null + } + try { + return resolveAgentLaunchRuntime(store, scope, undefined, 'host-legacy') + } catch (error) { + return { + platform: agentLaunchPlatformForScope(store, scope), + repairError: error instanceof Error ? error : new Error(String(error)) + } } } diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index f0a055a138b..55fb186f3ee 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -10,7 +10,6 @@ import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' -import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { WorktreeStartupDraftPaste, WorktreeStartupFollowup @@ -151,11 +150,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM draft: string, requestedAgent?: TuiAgent, launchSource?: string - ): Promise<{ - agent: TuiAgent - startup: WorktreeStartupLaunch - draftPaste?: WorktreeStartupDraftPaste - } | null> { + ): ReturnType { if (!this.store) { return null } @@ -165,7 +160,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM ...(requestedAgent ? { requestedAgent } : {}), ...(launchSource ? { launchSource } : {}), settings: this.store.getSettings(), - getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo) + getLaunchRuntime: () => this.resolveRepoAgentLaunchRuntime(repo) }) } @@ -181,7 +176,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM requestKind?: string onPromptCarry?: (carried: boolean) => void } - ): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { + ): ReturnType { if (!this.store) { throw new Error('runtime_unavailable') } @@ -196,7 +191,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM ...(launchInputs?.requestKind ? { requestKind: launchInputs.requestKind } : {}), ...(launchInputs?.onPromptCarry ? { onPromptCarry: launchInputs.onPromptCarry } : {}), settings: this.store.getSettings(), - getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo), + getLaunchRuntime: () => this.resolveRepoAgentLaunchRuntime(repo), toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences) }) } diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 2d80d078263..d9cd5df4677 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -13,6 +13,7 @@ import { import { createHash } from 'node:crypto' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { agentLaunchQuotingShell } from '../../shared/agent-launch-runtime' import { buildExecutionHostAgentStartupPlan } from '../opencode/opencode-model-startup-plan' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' import type { @@ -130,13 +131,12 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } - // The OpenCode model hop keeps main's own quoting and shell rules until C2 deletes it. - const launchRuntime = this.resolvePreC1AgentLaunchRuntime(workspace) + const launchRuntime = this.resolveAgentLaunchRuntime(workspace) const startupArgs = resolveAgentStartupPlanInputs({ agent: request.agent, settings, platform: launchRuntime.platform, - shell: launchRuntime.quotingShell, + shell: agentLaunchQuotingShell(launchRuntime), // Why: `workspace.repo` is display metadata and may be a row from another host; the launch // shape must match the PTY route this scope already resolved. isRemote: Boolean(workspace.connectionId), diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index a651ef3265b..8f2df2de440 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -78,6 +78,9 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork : null const effectiveStartup = args.startup ?? agentStartup?.startup ?? draftStartup?.startup const effectiveStartupFollowup = agentStartup?.followup + const startupLaunchRuntime = args.startup + ? undefined + : (agentStartup ?? draftStartup)?.launchRuntime const effectiveCreatedWithAgent = args.startup ? args.createdWithAgent : (agentStartup?.agent ?? @@ -93,6 +96,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork repo, startup: effectiveStartup, startupFollowup: effectiveStartupFollowup, + startupLaunchRuntime, createdWithAgent: effectiveCreatedWithAgent, draftPaste: effectiveDraftPaste, deps: { @@ -206,6 +210,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork defaultTabs, startup: effectiveStartup, startupFollowup: effectiveStartupFollowup, + startupLaunchRuntime, createdWithAgent: effectiveCreatedWithAgent, draftPaste: effectiveDraftPaste, warning, diff --git a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts index 88592b517b1..24cad7a936b 100644 --- a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts @@ -3,6 +3,7 @@ import { OrcaRuntimeWithResolveMobileSessionTerminalCommand } from './orca-runti import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { TuiAgent } from '../../shared/tui-agent' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime' import type { RuntimeMobileSessionCreateTerminalResult, RuntimeMobileSessionTabsSnapshot, @@ -58,6 +59,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca targetGroupId?: string supportsSplitGroupPlacement?: boolean launchConfig?: SleepingAgentLaunchConfig + launchRuntime?: AgentLaunchRuntime signal?: AbortSignal refuseSleptWorktree?: boolean } = {} @@ -110,6 +112,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca envToDelete: opts.envToDelete, ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.launchRuntime ? { launchRuntime: opts.launchRuntime } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), ...(opts.targetGroupId ? { targetGroupId: opts.targetGroupId } : {}), startupCommandDelivery: opts.startupCommandDelivery, diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 925fec8c8c8..24348b76a32 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -41,8 +41,6 @@ export const BACKGROUND_TERMINAL_SPAWN_FLAGS = { persistHostSessionBinding: true } as const -/** Caller-scoped spawn fields: abort when the caller disconnects; never wake a slept worktree; - * the caller's declared fork intent, which the host's gate validates. */ /** The PTY's shell inputs: a requested shell, and an agent launch's runtime fact. */ export function spawnShellInputs( opts: Pick @@ -53,6 +51,8 @@ export function spawnShellInputs( } } +/** Caller-scoped spawn fields: abort when the caller disconnects; never wake a slept worktree; + * the caller's declared fork intent, which the host's gate validates. */ export function callerSpawnScope( opts: Pick ): { signal?: AbortSignal; refuseSleptWorktree?: true; spawnIntent?: SpawnIntent } { diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index b0677ab0523..ab6dfce7a95 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -12,6 +12,7 @@ import type { import { canonicalizeAgentSessionIdentity } from './agent-session-claim-identity' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import { agentLaunchQuotingShell } from '../../shared/agent-launch-runtime' import { OrchestrationError } from './orchestration/orchestration-error' import { resolveTuiAgentLaunchArgs, @@ -125,7 +126,8 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim throw new Error('Selected agent is disabled. Choose an enabled agent before resuming.') } const launchRuntime = this.resolveAgentLaunchRuntime(workspace) - const { platform, quotingShell: shell } = launchRuntime + const { platform } = launchRuntime + const shell = agentLaunchQuotingShell(launchRuntime) // Why: `workspace.repo` is display metadata and may be a row from another host; the launch // shape must match the PTY route this scope already resolved. const isRemote = Boolean(workspace.connectionId) diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index 567cbe6896d..0a28a208d41 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -268,6 +268,8 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd settings, platform: wsl ? 'linux' : process.platform, isRemote: false, + // A yes/no model probe; nothing is typed into a shell. + shell: undefined, sessionOptions: { model: target.model } }), cwd: path, diff --git a/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts b/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts index 01910354224..5c43076b867 100644 --- a/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts +++ b/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts @@ -197,6 +197,7 @@ export class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh extends OrcaRu startupCommandDelivery: agentStartup.startupCommandDelivery, launchConfig: agentStartup.launchConfig, launchAgent: tab.launchAgent, + launchRuntime: agentStartup.launchRuntime, targetGroupId, ...(automatic ? { refuseSleptWorktree: true } : {}) }) diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index 42bdd3cdb52..65988afbad3 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -7,6 +7,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resum import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { agentLaunchQuotingShell, type AgentLaunchRuntime } from '../../shared/agent-launch-runtime' export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRuntimeWithRunCreateMobileSessionTerminal { protected async resolveMobileSessionTerminalCommand( @@ -28,6 +29,8 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + /** What the command was quoted for; the create spawns with it. */ + launchRuntime?: AgentLaunchRuntime }> { if (opts.command || !opts.agent) { return { @@ -53,7 +56,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt settings, // Why: mobile may be iOS while the shell host is Windows/macOS/Linux or SSH Linux; quote for the host shell. platform: launchRuntime.platform, - shell: launchRuntime.quotingShell, + shell: agentLaunchQuotingShell(launchRuntime), // Why: SSH runs the CLI through the relay shim (plain `orca`), so the Linux-only `orca-ide` rename must not apply. isRemote: Boolean(workspace.connectionId) }), @@ -74,7 +77,8 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt envToDelete: opts.envToDelete, launchConfig: startupPlan.launchConfig, launchAgent: opts.agent, - startupCommandDelivery: startupPlan.startupCommandDelivery + startupCommandDelivery: startupPlan.startupCommandDelivery, + launchRuntime } } } diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 3af57c4991f..2ee770a13c8 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -19,10 +19,9 @@ import { type LocalProjectGhExecOptions } from '../project-runtime-git-options' import { - agentLaunchPlatformForRepo, agentLaunchPlatformForScope, resolveAgentLaunchRuntime, - resolvePreC1AgentLaunchRuntime + resolveRepoAgentLaunchRuntime } from './agent-launch-scope-runtime' import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' @@ -267,10 +266,6 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] } - protected getAgentLaunchPlatformForRepo(repo: Repo): NodeJS.Platform { - return agentLaunchPlatformForRepo(this.requireStore(), repo) - } - protected getAgentLaunchPlatformForWorkspace( scope: TerminalWorkspaceLaunchScope ): NodeJS.Platform { @@ -281,12 +276,17 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu scope: TerminalWorkspaceLaunchScope, requestedShellOverride?: string ): AgentLaunchRuntime { - return resolveAgentLaunchRuntime(this.requireStore(), scope, requestedShellOverride) + return resolveAgentLaunchRuntime( + this.requireStore(), + scope, + requestedShellOverride, + 'host-legacy' + ) } - protected resolvePreC1AgentLaunchRuntime( - scope: TerminalWorkspaceLaunchScope - ): AgentLaunchRuntime { - return resolvePreC1AgentLaunchRuntime(this.requireStore(), scope) + protected resolveRepoAgentLaunchRuntime( + repo: Repo + ): ReturnType { + return resolveRepoAgentLaunchRuntime(this.requireStore(), repo) } } diff --git a/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts index 5e3c6458252..c9de7d995f2 100644 --- a/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts @@ -107,6 +107,7 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi targetGroupId: opts.targetGroupId, supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement, launchConfig: startupCommand.launchConfig, + launchRuntime: startupCommand.launchRuntime, signal: opts.signal } ) diff --git a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts index c80a67578ec..525bf2ad143 100644 --- a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts +++ b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts @@ -173,11 +173,15 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC undefined, opts.extraAgentArgs ? { extraAgentArgs: opts.extraAgentArgs } : undefined ) + if (startup.launchRuntime instanceof Error) { + throw startup.launchRuntime + } const terminal = await this.createTerminal(`id:${worktree.id}`, { command: startup.startup.command, env: startup.startup.env, ...(startup.startup.launchConfig ? { launchConfig: startup.startup.launchConfig } : {}), launchAgent: startup.agent, + launchRuntime: startup.launchRuntime, startupCommandDelivery: startup.startup.startupCommandDelivery, telemetry: startup.startup.telemetry, title: opts.title diff --git a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts index 26770ed49d9..772fb6d67fb 100644 --- a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts +++ b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts @@ -177,7 +177,8 @@ export async function publishAgentLaunchTabEarly( workspacePath: workspace.path, ...(params.cwd ? { cwd: params.cwd } : {}) }, - settings + settings, + ...(params.terminalOnly ? { terminalOnly: true } : {}) }) // A chat's tab is the session's; it appears when the session is created. if (preflight.mode !== 'terminal') { diff --git a/src/main/runtime/runtime-agent-launch-resolution.ts b/src/main/runtime/runtime-agent-launch-resolution.ts index 9f5ddd4bd0a..0482516b538 100644 --- a/src/main/runtime/runtime-agent-launch-resolution.ts +++ b/src/main/runtime/runtime-agent-launch-resolution.ts @@ -4,7 +4,10 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' -import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { + executionRuntimePlatform, + remoteExecutionPlatform +} from '../../shared/agent-launch-runtime' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' @@ -106,14 +109,7 @@ export function getAgentLaunchPlatformForRepo( repo: Pick, projectRuntime?: ProjectExecutionRuntimeResolution ): NodeJS.Platform { - if (!repo.connectionId) { - if (projectRuntime?.status === 'repair-required') { - return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : process.platform - } - if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { - return 'linux' - } - return process.platform - } - return isWindowsAbsolutePathLike(repo.path) ? 'win32' : 'linux' + return repo.connectionId + ? remoteExecutionPlatform(repo.path) + : executionRuntimePlatform(projectRuntime, undefined, process.platform) } diff --git a/src/main/runtime/runtime-agent-terminal-startup.test.ts b/src/main/runtime/runtime-agent-terminal-startup.test.ts index 4cf75502774..bb0e8583691 100644 --- a/src/main/runtime/runtime-agent-terminal-startup.test.ts +++ b/src/main/runtime/runtime-agent-terminal-startup.test.ts @@ -10,16 +10,15 @@ vi.mock('../preflight/agent-detection', () => ({ const workspace = { id: 'w', path: '/repo', connectionId: null, repo: null, folderWorkspace: null } const host = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the planner reads only getSettings when the fact is supplied. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: with a supplied fact, or a caller-quoted non-Qoder command, the planner reads only getSettings. store: { getSettings: () => ({}) } as never, hostIdentity: 'host-1', toSessionOptions: () => undefined } const CMD: AgentLaunchRuntime = { platform: 'win32', - quotingShell: 'cmd', - ptyShellOverride: 'cmd.exe', - wslDistro: null + remote: false, + pty: { shellOverride: 'cmd.exe', terminalWindowsWslDistro: null } } const plan = (opts: TerminalCreateOptions, launchRuntime: AgentLaunchRuntime = CMD) => resolveAgentTerminalCreateOptions(workspace, { ...opts, launchRuntime }, host) @@ -30,13 +29,13 @@ describe('host agent planner', () => { const launch = await plan({ startupAgent: 'claude', agentArgs: '--model opus' }) expect(launch.command).toBe('claude "--model" "opus"') expect(launch.launchRuntime).toBe(CMD) - // A caller that built its own agent command still gets the host's fact for its spawn. + // A command the caller quoted itself keeps the spawn's own shell rule (C4 moves those callers). const own = await resolveAgentTerminalCreateOptions( workspace, { command: 'claude', launchAgent: 'claude' }, host ) - expect(own.launchRuntime).toMatchObject({ platform: process.platform, wslDistro: null }) + expect(own.launchRuntime).toBeUndefined() }) it('merges extra arguments and reports the request kind (C3 resume, C7 automations)', async () => { @@ -71,14 +70,24 @@ describe('host agent planner', () => { it("rewrites Qoder once, probing where the fact's PTY runs (C2 skips the window copy)", async () => { vi.mocked(detectAgentCommandsOnHost).mockClear() - const launch = await plan( - { startupAgent: 'qoder', agentArgs: null }, - { platform: 'linux', quotingShell: 'posix', ptyShellOverride: 'wsl.exe', wslDistro: 'Ubuntu' } - ) - expect(launch.command).toBe('qoder') + const platform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const launch = await plan( + { startupAgent: 'qoder', agentArgs: null }, + { + platform: 'linux', + remote: false, + pty: { shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' } + } + ) + expect(launch.command).toBe('qoder') + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + } expect(detectAgentCommandsOnHost).toHaveBeenCalledTimes(1) expect(vi.mocked(detectAgentCommandsOnHost).mock.calls[0]?.[1]).toMatchObject({ - context: { wslDistro: 'Ubuntu', wslDefault: true } + context: { wslDistro: 'Ubuntu' } }) }) }) diff --git a/src/main/runtime/runtime-agent-terminal-startup.ts b/src/main/runtime/runtime-agent-terminal-startup.ts index f4342d8c969..88fa96666c2 100644 --- a/src/main/runtime/runtime-agent-terminal-startup.ts +++ b/src/main/runtime/runtime-agent-terminal-startup.ts @@ -1,7 +1,11 @@ import type { SessionOptionValue } from '../../shared/native-chat-session-options' import type { TuiAgent } from '../../shared/tui-agent' import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority' -import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime' +import { + agentLaunchQuotingShell, + remoteExecutionPlatform, + type AgentLaunchRuntime +} from '../../shared/agent-launch-runtime' import type { RuntimeStore } from './runtime-store-contract' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' import type { TerminalCreateOptions } from './runtime-terminal-contracts' @@ -21,14 +25,17 @@ import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { terminalShellOverrideRefusal } from './terminal-shell-override-host-support' import { resolveLocalLaunchScopeRuntime, - resolveLocalProjectRuntimeForWorktreeId + resolveLocalProjectRuntimeForWorktreeId, + resolvePlainTerminalPtyOptions } from '../local-project-runtime-resolution' import { agentLaunchPlatformForScope, resolveAgentLaunchRuntime } from './agent-launch-scope-runtime' import { resolveQoderTerminalCommand } from './qoder-terminal-command-resolution' -import { isWslShellName } from '../../shared/local-windows-terminal-runtime' +import { resolveStartupShell } from '../../shared/tui-agent-startup-shell' +import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' +import { resolveWslSessionContext } from '../daemon/wsl-session-context' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' type AgentTerminalPlanHost = { @@ -40,9 +47,9 @@ type AgentTerminalPlanHost = { } /** - * Every host terminal create passes here. An agent create — requested, typed bare, or brought with - * the caller's own command — gets the workspace's one runtime fact, which its spawn's PTY shell - * comes from; a plain terminal keeps the spawn's own shell rule. + * Every host terminal create passes here. An agent the host plans (requested or typed bare) is + * quoted for the workspace's one runtime fact and spawns with it. A command the caller quoted keeps + * the spawn's own shell rule unless the caller passes the fact it quoted with. */ export async function resolveAgentTerminalCreateOptions( workspace: TerminalWorkspaceLaunchScope, @@ -77,7 +84,7 @@ export async function resolveAgentTerminalCreateOptions( platform: process.platform, projectRuntime: opts.shellOverride && store - ? launchAgent + ? plannedAgent || opts.launchRuntime ? resolveLocalLaunchScopeRuntime(store, workspace) : resolveLocalProjectRuntimeForWorktreeId(store, workspace.id) : undefined, @@ -104,24 +111,31 @@ export async function resolveAgentTerminalCreateOptions( return opts } const runtime = - opts.launchRuntime ?? resolveAgentLaunchRuntime(store, workspace, opts.shellOverride) - const launch = plannedAgent - ? await buildRuntimeAgentTerminalStartupOptions( - workspace, - opts, - plannedAgent, - store.getSettings(), - runtime, - host - ) - : opts + opts.launchRuntime ?? + (plannedAgent + ? resolveAgentLaunchRuntime( + store, + workspace, + opts.shellOverride, + opts.wslShellRule ?? 'host-legacy' + ) + : undefined) + const launch = + plannedAgent && runtime + ? await buildRuntimeAgentTerminalStartupOptions( + workspace, + opts, + plannedAgent, + store.getSettings(), + runtime, + host + ) + : opts if (!launch.launchAgent) { return launch } - return { - ...(await resolveQoderLaunchCommand(launch, workspace, runtime)), - launchRuntime: runtime - } + const qoderLaunch = await resolveQoderLaunchCommand(launch, workspace, store, runtime) + return runtime ? { ...qoderLaunch, launchRuntime: runtime } : qoderLaunch } async function buildRuntimeAgentTerminalStartupOptions( @@ -151,7 +165,7 @@ async function buildRuntimeAgentTerminalStartupOptions( isRemote, ...(opts.agentArgs !== undefined ? { agentArgs: opts.agentArgs } : {}), ...(opts.extraAgentArgs ? { extraAgentArgs: opts.extraAgentArgs } : {}), - shell: runtime.quotingShell, + shell: agentLaunchQuotingShell(runtime), sessionOptions: host.toSessionOptions(opts.launchPreferences) }), prompt: opts.startupPrompt ?? '', @@ -169,7 +183,7 @@ async function buildRuntimeAgentTerminalStartupOptions( host: { shellName: nameLocalTypedLineShell({ isRemote, - ...(runtime.ptyShellOverride ? { shellOverride: runtime.ptyShellOverride } : {}) + ...(runtime.pty.shellOverride ? { shellOverride: runtime.pty.shellOverride } : {}) }), provesAgentInFront: launchHostProvesAgentInFront({ isRemote, @@ -208,19 +222,45 @@ async function buildRuntimeAgentTerminalStartupOptions( } } -/** Qoder ships under two command names; the launch runs whichever its execution host has. */ +/** Qoder ships under two command names; the launch runs whichever the machine that runs it has. */ function resolveQoderLaunchCommand( launch: TerminalCreateOptions, workspace: TerminalWorkspaceLaunchScope, - runtime: AgentLaunchRuntime + store: RuntimeStore, + runtime: AgentLaunchRuntime | undefined ): Promise { + if (launch.launchAgent !== 'qoder') { + return Promise.resolve(launch) + } + if (workspace.connectionId) { + return resolveQoderTerminalCommand(launch, { + connectionId: workspace.connectionId, + shell: resolveStartupShell(remoteExecutionPlatform(workspace.path)) + }) + } + const settings = store.getSettings() + const pty = + runtime?.pty ?? + resolvePlainTerminalPtyOptions(store, settings, workspace.id, launch.shellOverride) + // Where the process runs: a `\\wsl$` cwd first, then a WSL shell, as the spawn decides it. + const wsl = resolveWslSessionContext({ + cwd: resolveTerminalStartupCwd(workspace.path, launch.cwd) ?? workspace.path, + shellOverride: pty.shellOverride, + terminalWindowsWslDistro: pty.terminalWindowsWslDistro + }) + const platform = runtime?.platform ?? agentLaunchPlatformForScope(store, workspace) return resolveQoderTerminalCommand(launch, { - connectionId: workspace.connectionId, - context: workspace.connectionId - ? undefined - : isWslShellName(runtime.ptyShellOverride) - ? { wslDistro: runtime.wslDistro, wslDefault: true } - : {}, - shell: runtime.quotingShell + context: wsl ? { wslDistro: wsl.distro } : {}, + // A caller-quoted command was quoted by the Settings shell, as the window quotes. + shell: runtime + ? agentLaunchQuotingShell(runtime) + : resolveStartupShell( + platform, + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote: false, + terminalWindowsShell: launch.shellOverride ?? settings.terminalWindowsShell + }) + ) }) } diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index 7adc0e3533b..405bb43565d 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -7,6 +7,7 @@ import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' +import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime' import { getRuntimeFolderWorkspaceInstanceId, mergeRuntimeFolderWorkspace @@ -50,6 +51,9 @@ export async function createRuntimeFolderWorktree(args: { repo: Repo startup?: WorktreeStartupLaunch startupFollowup?: WorktreeStartupFollowup + /** What a host-built startup was quoted for, or the repair error it fails with; absent keeps the + * spawn's own shell rule. */ + startupLaunchRuntime?: AgentLaunchRuntime | Error createdWithAgent?: TuiAgent draftPaste?: WorktreeStartupDraftPaste deps: RuntimeFolderWorktreeCreateDeps @@ -132,6 +136,9 @@ export async function createRuntimeFolderWorktree(args: { let startupTerminal: CreateWorktreeResult['startupTerminal'] if (args.startup && deps.ptySpawnAvailable) { try { + if (args.startupLaunchRuntime instanceof Error) { + throw args.startupLaunchRuntime + } const terminal = await deps.createTerminal(`id:${worktree.id}`, { command: args.startup.command, ...(request.startupCwd ? { cwd: request.startupCwd } : {}), @@ -140,6 +147,7 @@ export async function createRuntimeFolderWorktree(args: { env: args.startup.env, ...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), + ...(args.startupLaunchRuntime ? { launchRuntime: args.startupLaunchRuntime } : {}), ...(args.startup.viewMode ? { viewMode: args.startup.viewMode } : {}), startupCommandDelivery: args.startup.startupCommandDelivery, telemetry: args.startup.telemetry, diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index f19125ece6f..815019fbd3d 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -4,6 +4,7 @@ import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' import type { Worktree } from '../../shared/worktree/types' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' +import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime' import { createSequencedSetupAgentCommands } from '../../shared/setup-agent-sequencing' import { getSetupRunnerCommandPlatformForPath } from '../../shared/setup-runner-command' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' @@ -59,6 +60,9 @@ export async function startRuntimeLocalWorktreeTerminals(args: { defaultTabs?: CreateWorktreeResult['defaultTabs'] startup?: WorktreeStartupLaunch startupFollowup?: WorktreeStartupFollowup + /** What a host-built startup was quoted for, or the repair error it fails with; absent keeps the + * spawn's own shell rule. */ + startupLaunchRuntime?: AgentLaunchRuntime | Error createdWithAgent?: TuiAgent draftPaste?: WorktreeStartupDraftPaste warning?: string @@ -94,6 +98,9 @@ export async function startRuntimeLocalWorktreeTerminals(args: { if (sequencedStartup && ports.canSpawn) { try { + if (args.startupLaunchRuntime instanceof Error) { + throw args.startupLaunchRuntime + } const terminal = await ports.createTerminal(`id:${worktree.id}`, { command: sequencedStartup.command, ...(request.startupCwd ? { cwd: request.startupCwd } : {}), @@ -103,6 +110,7 @@ export async function startRuntimeLocalWorktreeTerminals(args: { env: sequencedStartup.env, ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), + ...(args.startupLaunchRuntime ? { launchRuntime: args.startupLaunchRuntime } : {}), ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, telemetry: sequencedStartup.telemetry, diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index d0a2778f087..48162be3290 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -1,4 +1,5 @@ import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime' +import type { WslShellRule } from './agent-launch-scope-runtime' import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' import type { @@ -78,8 +79,11 @@ export type TerminalCreateOptions = { /** Launch-convergence parity: a promptless Codex waits for the shell, as window launches do. * Absent is the host's no-wait. Dies when one rule is chosen. */ codexNoPromptShellWait?: boolean - /** Main-internal: the runtime fact this agent launch was planned for; its spawn takes the PTY - * shell from it. Set by `resolveAgentTerminalCreateOptions`, never by a client. */ + /** Launch-convergence parity: absent is `host-legacy`, today's host WSL-shell outcome. Dies in + * C8 (`WslShellRule`). */ + wslShellRule?: WslShellRule + /** Main-internal: the runtime fact this agent's command was quoted for; its spawn takes the PTY + * shell from it. Set by the host planner or a host caller that quoted with it, never a client. */ launchRuntime?: AgentLaunchRuntime launchPreferences?: AgentLaunchPreferences terminalKittyKeyboardProtocol?: boolean diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index 1751b82a2a2..db18adb0f36 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -3,6 +3,7 @@ import type { Repo } from '../../shared/repo-types' import { tuiAgentToAgentKind } from '../../shared/agent-kind' import { getDefaultSettings } from '../../shared/constants' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' +import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime' const mocks = vi.hoisted(() => ({ detectInstalledAgentsWithShellPathHydration: vi.fn() @@ -32,6 +33,12 @@ function makeRepo(fields: Partial): Repo { } } +const LINUX: AgentLaunchRuntime = { + platform: 'linux', + remote: false, + pty: { shellOverride: undefined, terminalWindowsWslDistro: null } +} + const settings = { ...getDefaultSettings('/tmp'), agentCmdOverrides: {}, @@ -47,7 +54,7 @@ function launchCliNameFor(repo: Repo): string { repo, settings, agent: 'claude-agent-teams', - getLaunchPlatform: () => 'linux', + getLaunchRuntime: () => LINUX, toSessionOptions: () => undefined }).startup.command.split(' ')[0]! } @@ -83,7 +90,7 @@ describe('buildWorktreeStartupForAgent host resolution', () => { agent: 'claude', agentArgs: '--model opus', launchSource: 'source_control_recovery', - getLaunchPlatform: () => 'linux', + getLaunchRuntime: () => LINUX, toSessionOptions: () => undefined }) @@ -100,7 +107,7 @@ describe('buildWorktreeStartupForAgent host resolution', () => { repo: makeRepo({}), settings, agent: 'claude', - getLaunchPlatform: () => 'linux', + getLaunchRuntime: () => LINUX, toSessionOptions: () => undefined }) @@ -112,6 +119,25 @@ describe('buildWorktreeStartupForAgent host resolution', () => { }) }) +// Relied on by C2/C4: a host-built startup is typed into the shell it was quoted for. +it('quotes a worktree startup for its fact, not the Settings shell, and hands the fact on', () => { + const powershell: AgentLaunchRuntime = { + platform: 'win32', + remote: false, + pty: { shellOverride: 'powershell.exe', terminalWindowsWslDistro: null } + } + const result = buildWorktreeStartupForAgent({ + repo: makeRepo({ path: 'C:\\repo' }), + settings: { ...settings, terminalWindowsShell: 'wsl.exe' }, + agent: 'claude', + prompt: "fix Bob's bug", + getLaunchRuntime: () => powershell, + toSessionOptions: () => undefined + }) + expect(result.startup.command).toContain("'fix Bob''s bug'") + expect(result.launchRuntime).toBe(powershell) +}) + describe('buildWorktreeStartupForAgent prompt carry', () => { const build = (onPromptCarry?: (carried: boolean) => void, terminalDefaultShell = '/bin/bash') => buildWorktreeStartupForAgent({ @@ -119,7 +145,10 @@ describe('buildWorktreeStartupForAgent prompt carry', () => { settings: Object.assign({}, settings, { terminalDefaultShell }), agent: 'claude', prompt: 'summarize the diff\nthen list the risks', - getLaunchPlatform: () => 'linux', + getLaunchRuntime: () => ({ + ...LINUX, + pty: { shellOverride: terminalDefaultShell, terminalWindowsWslDistro: null } + }), toSessionOptions: () => undefined, ...(onPromptCarry ? { onPromptCarry } : {}) }) @@ -159,7 +188,7 @@ describe('buildWorktreeStartupForAgent blank prompt', () => { settings, agent, ...(prompt !== undefined ? { prompt } : {}), - getLaunchPlatform: () => 'linux', + getLaunchRuntime: () => LINUX, toSessionOptions: () => undefined }) @@ -179,7 +208,7 @@ describe('buildWorktreeStartupForDraft agent detection', () => { repo: makeRepo({ executionHostId: 'ssh:openclaw' }), settings, draft: 'ship it', - getLaunchPlatform: () => 'linux' + getLaunchRuntime: () => LINUX }) expect(mocks.detectInstalledAgentsWithShellPathHydration).not.toHaveBeenCalled() @@ -193,7 +222,7 @@ describe('buildWorktreeStartupForDraft agent detection', () => { repo: makeRepo({ connectionId: 'm4air', executionHostId: 'local' }), settings, draft: 'ship it', - getLaunchPlatform: () => 'linux' + getLaunchRuntime: () => LINUX }) expect(result?.agent).toBe('claude') @@ -214,7 +243,7 @@ describe('buildWorktreeStartupForDraft agent detection', () => { settings, draft: 'ship it', requestedAgent: agent, - getLaunchPlatform: () => 'linux', + getLaunchRuntime: () => LINUX, ...(launchSource ? { launchSource } : {}) }) @@ -239,7 +268,7 @@ describe('buildWorktreeStartupForAgent extra agent args', () => { agent: 'claude', prompt: 'go', extraAgentArgs: '--model opus', - getLaunchPlatform: () => 'linux', + getLaunchRuntime: () => LINUX, toSessionOptions: () => undefined }) @@ -256,7 +285,7 @@ describe('buildWorktreeStartupForAgent extra agent args', () => { agent: 'claude', prompt: 'go', extraAgentArgs: '--settings evil.json', - getLaunchPlatform: () => 'linux', + getLaunchRuntime: () => LINUX, toSessionOptions: () => undefined }) ).toThrow('"--settings"') @@ -265,7 +294,8 @@ describe('buildWorktreeStartupForAgent extra agent args', () => { it('threads worktree-create extras into the startup build', () => { const build = vi.fn(() => ({ agent: 'claude' as const, - startup: { command: 'claude' } + startup: { command: 'claude' }, + launchRuntime: LINUX })) const createArgs: RuntimeManagedWorktreeCreateArgs = { repoSelector: 'repo-1', diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index fe796d28613..541ad7bd0ff 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -8,6 +8,7 @@ import { getRepoSshConnectionId } from '../../shared/execution-host' import { isTuiAgent } from '../../shared/tui-agent-config' import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selection' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { agentLaunchQuotingShell, type AgentLaunchRuntime } from '../../shared/agent-launch-runtime' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' import { @@ -29,7 +30,9 @@ type AttributedWorktreeStartupLaunch = WorktreeStartupLaunch & { type StartupEnvironment = { repo: Repo settings: ReturnType - getLaunchPlatform: () => NodeJS.Platform + /** Where the agent runs; its command is quoted for this and the create spawns with it. A runtime + * that needs repair gives its error instead, which the startup spawn then fails with. */ + getLaunchRuntime: () => AgentLaunchRuntime | { platform: NodeJS.Platform; repairError: Error } /** Replaces the configured arguments for this launch; `null` means none. */ agentArgs?: string | null /** An automation's saved extras, merged over the launch's arguments. */ @@ -39,6 +42,11 @@ type StartupEnvironment = { requestKind?: string } +/** A startup that will fail with its repair error is quoted for the Settings shell, as before. */ +function startupQuotingShell(launchRuntime: AgentLaunchRuntime | Error) { + return launchRuntime instanceof Error ? undefined : agentLaunchQuotingShell(launchRuntime) +} + /** The agent a linked draft starts: the requested one, else the default, else one detected on the * host that runs it. `null` means the draft starts no agent. */ export async function resolveWorktreeStartupDraftAgent( @@ -74,6 +82,7 @@ export async function buildWorktreeStartupForDraft( agent: TuiAgent startup: AttributedWorktreeStartupLaunch draftPaste?: WorktreeStartupDraftPaste + launchRuntime: AgentLaunchRuntime | Error } | null> { const content = environment.draft.trim() if (!content) { @@ -85,10 +94,13 @@ export async function buildWorktreeStartupForDraft( return null } + const resolved = environment.getLaunchRuntime() + const launchRuntime = 'repairError' in resolved ? resolved.repairError : resolved const launchArgs = resolveAgentStartupPlanInputs({ agent, settings, - platform: environment.getLaunchPlatform(), + platform: resolved.platform, + shell: startupQuotingShell(launchRuntime), isRemote: repoIsRemote(repo), ...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {}) }) @@ -105,7 +117,8 @@ export async function buildWorktreeStartupForDraft( : {}), ...(draftPlan.env ? { env: draftPlan.env } : {}), telemetry - } + }, + launchRuntime } } const startupPlan = buildAgentStartupPlan({ @@ -127,7 +140,8 @@ export async function buildWorktreeStartupForDraft( ...(startupPlan.env ? { env: startupPlan.env } : {}), telemetry }, - draftPaste: { agent, content } + draftPaste: { agent, content }, + launchRuntime } } @@ -147,15 +161,19 @@ export function buildWorktreeStartupForAgent( agent: TuiAgent startup: AttributedWorktreeStartupLaunch followup?: WorktreeStartupFollowup + launchRuntime: AgentLaunchRuntime | Error } { const { agent, repo, settings } = environment if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } + const resolved = environment.getLaunchRuntime() + const launchRuntime = 'repairError' in resolved ? resolved.repairError : resolved const planInputs = resolveAgentStartupPlanInputs({ agent, settings, - platform: environment.getLaunchPlatform(), + platform: resolved.platform, + shell: startupQuotingShell(launchRuntime), isRemote: repoIsRemote(repo), ...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {}), ...(environment.extraAgentArgs ? { extraAgentArgs: environment.extraAgentArgs } : {}), @@ -167,13 +185,13 @@ export function buildWorktreeStartupForAgent( const offered = planStartupWithPromptCandidate(planInputs, prompt, { shellName: nameLocalTypedLineShell({ isRemote: repoIsRemote(repo), - ...(settings.terminalDefaultShell - ? { defaultShellSetting: settings.terminalDefaultShell } + ...(!(launchRuntime instanceof Error) && launchRuntime.pty.shellOverride + ? { shellOverride: launchRuntime.pty.shellOverride } : {}) }), provesAgentInFront: launchHostProvesAgentInFront({ isRemote: repoIsRemote(repo), - launchPlatform: environment.getLaunchPlatform() + launchPlatform: resolved.platform }) }) startupPlan = offered.plan @@ -197,6 +215,7 @@ export function buildWorktreeStartupForAgent( ...(startupPlan.env ? { env: startupPlan.env } : {}), telemetry: agentStartedTelemetry(agent, environment.launchSource, environment.requestKind) }, + launchRuntime, ...(startupPlan.followupPrompt ? { followup: { @@ -221,7 +240,12 @@ export function resolveWorktreeCreateAgentStartup( requestKind?: string onPromptCarry?: (carried: boolean) => void } - ) => { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } + ) => { + agent: TuiAgent + startup: WorktreeStartupLaunch + followup?: WorktreeStartupFollowup + launchRuntime: AgentLaunchRuntime | Error + } ) { if (args.startup || !args.startupAgent) { return null diff --git a/src/main/runtime/terminal-shell-override-host-support.ts b/src/main/runtime/terminal-shell-override-host-support.ts index d91b202a532..ff9b0495541 100644 --- a/src/main/runtime/terminal-shell-override-host-support.ts +++ b/src/main/runtime/terminal-shell-override-host-support.ts @@ -58,10 +58,20 @@ function projectRuntimeShellConflict( if (runsInWsl === isWslShellName(shellOverride)) { return null } + // A workspace with no saved runtime of its own follows the default, so name that. + const where = runsInWsl ? 'in WSL' : 'on the Windows host' + const runs = + projectRuntime.runtime.reason === 'global-default' + ? `This workspace runs its terminals ${where}, the default in Settings > Project Runtime` + : `This workspace's project runs its terminals ${where}` + const change = + projectRuntime.runtime.reason === 'global-default' + ? 'that default' + : "the project's execution runtime" return new Error( runsInWsl - ? `This workspace's project runs its terminals in WSL, so --shell ${shellOverride} cannot be applied. No terminal was created. Use --shell wsl.exe, or change the project's execution runtime.` - : `This workspace's project runs its terminals on the Windows host, so --shell ${shellOverride} cannot be applied. No terminal was created. Change the project's execution runtime to WSL, or pass a Windows shell.` + ? `${runs}, so --shell ${shellOverride} cannot be applied. No terminal was created. Use --shell wsl.exe, or change ${change}.` + : `${runs}, so --shell ${shellOverride} cannot be applied. No terminal was created. Change ${change} to WSL, or pass a Windows shell.` ) } diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index fb2739f3caa..1ac3f9b82a4 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -74,6 +74,8 @@ export async function launchAgentBackgroundSession( : { ...store.settings, terminalWindowsShell: undefined }, platform: launchPlatform, isRemote, + // The window quotes by its Settings shell, which its pane's spawn also reads. + shell: undefined, extraAgentArgs: args.extraAgentArgs }) const trimmedPrompt = prompt?.trim() ?? '' diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index 2eb0d9133df..113a892a2a3 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -3,11 +3,11 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { parseWslUncPath } from '../../../shared/wsl-paths' import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings, - getWorkspaceRuntimePreference, resolveProjectExecutionRuntime, type ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { resolveLocalWorkspaceExecutionRuntime } from '../../../shared/local-workspace-execution-runtime' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' import { parseWorkspaceKey } from '../../../shared/workspace-scope' @@ -80,22 +80,14 @@ export function getLocalProjectExecutionRuntimeContext( if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(worktree)) { return undefined } - const projectId = getLocalPreflightProjectId(state, worktreeId) - const project = getLocalRuntimeProject(state, projectId, repo.id) - const projectRuntimePreference = getWorkspaceRuntimePreference( - project?.localWindowsRuntimePreference, - worktree?.path ?? repo?.path - ) - - return resolveProjectExecutionRuntime({ + return resolveLocalWorkspaceExecutionRuntime({ appPlatform, - projectId, - projectRuntimePreference, - globalWindowsRuntimeDefault: - state.settings?.localWindowsRuntimeDefault ?? - deriveGlobalWindowsRuntimeDefaultFromLegacySettings(state.settings).defaultRuntime, - wslAvailable: wslContext.wslAvailable, - availableWslDistros: wslContext.availableWslDistros + projects: state.projects, + settings: state.settings, + repoId: repo.id, + projectId: getLocalPreflightProjectId(state, worktreeId), + workspacePath: worktree?.path ?? repo?.path, + ...wslContext }) } @@ -138,22 +130,13 @@ export function getLocalRepoProjectExecutionRuntimeContext( if (!isLocalRuntimeRepo(repo)) { return undefined } - const project = getLocalRuntimeProject(state, repoId, repo.id) - const projectId = project?.id ?? repoId - const projectRuntimePreference = getWorkspaceRuntimePreference( - project?.localWindowsRuntimePreference, - repo?.path - ) - - return resolveProjectExecutionRuntime({ + return resolveLocalWorkspaceExecutionRuntime({ appPlatform, - projectId, - projectRuntimePreference, - globalWindowsRuntimeDefault: - state.settings?.localWindowsRuntimeDefault ?? - deriveGlobalWindowsRuntimeDefaultFromLegacySettings(state.settings).defaultRuntime, - wslAvailable: wslContext.wslAvailable, - availableWslDistros: wslContext.availableWslDistros + projects: state.projects, + settings: state.settings, + repoId: repo.id, + workspacePath: repo.path, + ...wslContext }) } @@ -312,17 +295,6 @@ function isLocalRuntimeWorktree(worktree?: Pick | null): boo return !worktree?.hostId || worktree.hostId === LOCAL_EXECUTION_HOST_ID } -function getLocalRuntimeProject( - state: LocalProjectRuntimeState, - projectId: string, - repoId: string -) { - return state.projects?.find( - (entry) => - entry.id === projectId || entry.id === repoId || entry.sourceRepoIds?.includes(repoId) - ) -} - function getLocalPreflightProjectId( state: LocalProjectRuntimeState, worktreeId?: string | null diff --git a/src/renderer/src/lib/local-project-runtime-workspace.ts b/src/renderer/src/lib/local-project-runtime-workspace.ts index 1eda3cb9f67..9c8290ee790 100644 --- a/src/renderer/src/lib/local-project-runtime-workspace.ts +++ b/src/renderer/src/lib/local-project-runtime-workspace.ts @@ -1,10 +1,7 @@ import type { AppState } from '@/store/types' import { getIndexedWorktreeById } from '@/store/worktree-repo-index' -import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import { - findFolderWorkspaceCandidateRepos, - resolveFolderWorkspaceHost -} from '../../../shared/folder-workspace-execution-host' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { findLocalFolderWorkspaceRuntimeRepo } from '../../../shared/local-workspace-execution-runtime' import type { Worktree } from '../../../shared/worktree/types' import { parseWorkspaceKey } from '../../../shared/workspace-scope' @@ -33,20 +30,9 @@ export function getLocalProjectRuntimeWorkspace( projectGroups: state.projectGroups ?? [], repos: state.repos } + const repo = findLocalFolderWorkspaceRuntimeRepo(folderState, scope.folderWorkspaceId) const folder = folderState.folderWorkspaces.find((entry) => entry.id === scope.folderWorkspaceId) - if (!folder || resolveFolderWorkspaceHost(folderState, folder.id).kind !== 'local') { - return null - } - const group = folderState.projectGroups.find((entry) => entry.id === folder.projectGroupId) - if ( - (folder.executionHostId ?? group?.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !== - LOCAL_EXECUTION_HOST_ID - ) { - return null - } - const candidates = findFolderWorkspaceCandidateRepos(folderState, folder.id) - const repo = candidates.length === 1 ? candidates[0] : undefined - return repo && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID + return repo && folder ? { id: targetWorktreeId, repoId: repo.id, diff --git a/src/shared/agent-launch-operation.test.ts b/src/shared/agent-launch-operation.test.ts index 4a84a16711a..a89864cfd22 100644 --- a/src/shared/agent-launch-operation.test.ts +++ b/src/shared/agent-launch-operation.test.ts @@ -84,14 +84,14 @@ describe('fields the launch fingerprint deliberately ignores', () => { computeAgentLaunchFingerprint(BASE) ) }) -}) -// Relied on by C2/C3/C7, which send these through agent.launchReplay. -it('covers the terminal inputs that change what runs, not the tab title or request kind', () => { - const plain = fingerprintOfWirePayload(BASE) - expect(fingerprintOfWirePayload({ ...BASE, title: 'Fix', requestKind: 'resume' })).toBe(plain) - expect(fingerprintOfWirePayload({ ...BASE, extraAgentArgs: '--verbose' })).not.toBe(plain) - expect(fingerprintOfWirePayload({ ...BASE, terminalOnly: true })).not.toBe(plain) + // Relied on by C2/C3/C7, which send these through agent.launchReplay. + it('ignores the tab title and request kind, but not the inputs that change what runs', () => { + const plain = fingerprintOfWirePayload(BASE) + expect(fingerprintOfWirePayload({ ...BASE, title: 'Fix', requestKind: 'resume' })).toBe(plain) + expect(fingerprintOfWirePayload({ ...BASE, extraAgentArgs: '--verbose' })).not.toBe(plain) + expect(fingerprintOfWirePayload({ ...BASE, terminalOnly: true })).not.toBe(plain) + }) }) describe('compatibility with rows written before these fields existed', () => { diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index 0eddcc617ae..aa3799d352f 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -46,7 +46,7 @@ export const AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY = 'agent.launch.create-folder-workspace.v1' as const // Host-advertised: reads `title`, `extraAgentArgs`, `requestKind` and `terminalOnly`. An older host -// drops unknown keys, so a client sends them only on this (`agentLaunchTerminalInputsFor`). +// drops unknown keys, so a client sends them only to a host that advertises this. export const AGENT_LAUNCH_TERMINAL_INPUTS_RUNTIME_CAPABILITY = 'agent.launch.terminal-inputs.v1' as const diff --git a/src/shared/agent-launch-runtime.test.ts b/src/shared/agent-launch-runtime.test.ts deleted file mode 100644 index ac1953838f6..00000000000 --- a/src/shared/agent-launch-runtime.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { deriveAgentLaunchRuntime } from './agent-launch-runtime' -import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime' - -const WSL: ProjectExecutionRuntimeResolution = { - status: 'resolved', - runtime: { - kind: 'wsl', - hostPlatform: 'wsl', - projectId: 'p', - distro: 'Ubuntu', - reason: 'project-override', - cacheKey: 'p:wsl:Ubuntu' - } -} -const WINDOWS_HOST: ProjectExecutionRuntimeResolution = { - status: 'resolved', - runtime: { - kind: 'windows-host', - hostPlatform: 'win32', - projectId: 'p', - reason: 'project-override', - cacheKey: 'p:windows-host' - } -} -const base = { - hostPlatform: 'win32' as const, - remote: false, - workspacePath: 'C:\\repo', - requestedShellOverride: undefined, - settings: { terminalWindowsShell: 'cmd.exe' }, - sshLoginShell: undefined -} - -// Relied on by C2: the pane's pty:spawn takes this object as is, for quoting and shell alike. -describe('the one runtime fact for an agent launch', () => { - it('quotes a WSL runtime for Linux and starts it in that distro', () => { - expect(deriveAgentLaunchRuntime({ ...base, projectRuntime: WSL })).toEqual({ - platform: 'linux', - quotingShell: 'posix', - ptyShellOverride: 'wsl.exe', - wslDistro: 'Ubuntu' - }) - }) - - it('quotes for the Windows shell that runs, not a wsl.exe setting it replaced', () => { - expect( - deriveAgentLaunchRuntime({ - ...base, - settings: { terminalWindowsShell: 'wsl.exe' }, - projectRuntime: WINDOWS_HOST - }) - ).toMatchObject({ - platform: 'win32', - quotingShell: 'powershell', - ptyShellOverride: 'powershell.exe' - }) - }) - - it('leaves a remote host its own shell and quotes for its path', () => { - expect( - deriveAgentLaunchRuntime({ - ...base, - remote: true, - projectRuntime: undefined - }) - ).toEqual({ - platform: 'win32', - quotingShell: 'powershell', - ptyShellOverride: undefined, - wslDistro: null - }) - }) -}) diff --git a/src/shared/agent-launch-runtime.ts b/src/shared/agent-launch-runtime.ts index d8ab2d6c34c..1a8aeab122f 100644 --- a/src/shared/agent-launch-runtime.ts +++ b/src/shared/agent-launch-runtime.ts @@ -1,23 +1,36 @@ import type { GlobalSettings } from './global-settings-types' import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime' import { isWindowsAbsolutePathLike } from './cross-platform-path' -import { resolveLocalWindowsTerminalRuntimeOptions } from './local-windows-terminal-runtime' +import { + resolveLocalWindowsTerminalRuntimeOptions, + type LocalWindowsTerminalRuntimeOptions +} from './local-windows-terminal-runtime' import { resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' import { resolveLocalWindowsAgentStartupShell } from './windows-terminal-shell' import { isWslUncPath } from './wsl-paths' /** - * Where an agent launch runs, resolved once on its execution host: what its command is quoted for - * and the shell its PTY is. Both come from here so they cannot disagree. In memory only. + * Where an agent launch runs, resolved once on its execution host: the platform its command is + * quoted for and its PTY's shell. The quoting shell is derived from both, so the command is always + * quoted for the shell it is typed into. In memory only. */ export type AgentLaunchRuntime = { - /** The platform the command is quoted for: `linux` for a WSL runtime. */ + /** `linux` for a WSL runtime. */ platform: NodeJS.Platform - quotingShell: AgentStartupShell - /** The provider's `shellOverride`; absent for a remote host, which picks its own shell. */ - ptyShellOverride: string | undefined - /** The provider's `terminalWindowsWslDistro`. */ - wslDistro: string | null + remote: boolean + /** The spawn's own PTY options; empty for a remote host, which picks its own shell. */ + pty: LocalWindowsTerminalRuntimeOptions +} + +export function agentLaunchQuotingShell(runtime: AgentLaunchRuntime): AgentStartupShell { + return resolveStartupShell( + runtime.platform, + resolveLocalWindowsAgentStartupShell({ + platform: runtime.platform, + isRemote: runtime.remote, + terminalWindowsShell: runtime.pty.shellOverride + }) + ) } /** A local workspace's platform: its project runtime, else a `\\wsl$` path is Linux, else the host. */ @@ -40,64 +53,56 @@ export function remoteExecutionPlatform(workspacePath: string): NodeJS.Platform return isWindowsAbsolutePathLike(workspacePath) ? 'win32' : 'linux' } -export function deriveAgentLaunchRuntime(args: { +type LocalPtyInputs = { hostPlatform: NodeJS.Platform - remote: boolean - workspacePath: string - projectRuntime: ProjectExecutionRuntimeResolution | undefined requestedShellOverride: string | undefined - settings: Partial< - Pick< - GlobalSettings, - 'terminalWindowsShell' | 'terminalWindowsWslDistro' | 'terminalDefaultShell' - > - > + settings: + | Partial< + Pick< + GlobalSettings, + 'terminalWindowsShell' | 'terminalWindowsWslDistro' | 'terminalDefaultShell' + > + > + | undefined + projectRuntime: ProjectExecutionRuntimeResolution | undefined /** The Windows shell that replaces a `wsl.exe` setting on a Windows-host runtime; absent is * `powershell.exe`, as the window's tabs pick. */ fallbackHostShell?: string sshLoginShell: string | undefined -}): AgentLaunchRuntime { +} + +/** A local PTY's shell and distro. Throws for a runtime that needs repair. */ +export function resolveLocalPtyRuntimeOptions( + args: LocalPtyInputs +): LocalWindowsTerminalRuntimeOptions { + return args.hostPlatform === 'win32' + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.requestedShellOverride, + settings: args.settings, + projectRuntime: args.projectRuntime, + fallbackHostShell: args.fallbackHostShell, + sshLoginShell: args.sshLoginShell + }) + : { + shellOverride: + args.requestedShellOverride ?? (args.settings?.terminalDefaultShell || undefined), + terminalWindowsWslDistro: null + } +} + +export function deriveAgentLaunchRuntime( + args: LocalPtyInputs & { remote: boolean; workspacePath: string } +): AgentLaunchRuntime { if (args.remote) { - const platform = remoteExecutionPlatform(args.workspacePath) return { - platform, - quotingShell: resolveStartupShell(platform), - ptyShellOverride: undefined, - wslDistro: null + platform: remoteExecutionPlatform(args.workspacePath), + remote: true, + pty: { shellOverride: undefined, terminalWindowsWslDistro: null } } } - // The spawn's own formula; it throws for a runtime that needs repair, as the spawn would. - const pty = - args.hostPlatform === 'win32' - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.requestedShellOverride, - settings: args.settings, - projectRuntime: args.projectRuntime, - fallbackHostShell: args.fallbackHostShell, - sshLoginShell: args.sshLoginShell - }) - : { - shellOverride: - args.requestedShellOverride ?? (args.settings.terminalDefaultShell || undefined), - terminalWindowsWslDistro: null - } - const platform = executionRuntimePlatform( - args.projectRuntime, - args.workspacePath, - args.hostPlatform - ) return { - platform, - // Why from the PTY shell: quoting for a setting the spawn overrode types into the wrong shell. - quotingShell: resolveStartupShell( - platform, - resolveLocalWindowsAgentStartupShell({ - platform, - isRemote: false, - terminalWindowsShell: pty.shellOverride - }) - ), - ptyShellOverride: pty.shellOverride, - wslDistro: pty.terminalWindowsWslDistro + platform: executionRuntimePlatform(args.projectRuntime, args.workspacePath, args.hostPlatform), + remote: false, + pty: resolveLocalPtyRuntimeOptions(args) } } diff --git a/src/shared/agent-launch-terminal-inputs.test.ts b/src/shared/agent-launch-terminal-inputs.test.ts deleted file mode 100644 index 5530cbcd06d..00000000000 --- a/src/shared/agent-launch-terminal-inputs.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { agentLaunchTerminalInputsFor } from './agent-launch-terminal-inputs' -import { AGENT_LAUNCH_TERMINAL_INPUTS_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' -import { AgentLaunch } from './rpc-contract/agent-launch-params' - -const INPUTS = { - title: 'Fix', - requestKind: 'resume', - extraAgentArgs: '--verbose', - terminalOnly: true -} - -// Relied on by C2, C3 and C7, the first producers that send these fields. -describe('agent.launch terminal inputs across versions', () => { - it('sends all of them to a host that reads them', () => { - expect( - agentLaunchTerminalInputsFor([AGENT_LAUNCH_TERMINAL_INPUTS_RUNTIME_CAPABILITY], INPUTS) - ).toEqual(INPUTS) - }) - - it('drops labels for an older host and refuses inputs it would silently ignore', () => { - expect(agentLaunchTerminalInputsFor([], { title: 'Fix', requestKind: 'resume' })).toEqual({}) - expect(() => agentLaunchTerminalInputsFor([], { extraAgentArgs: '--verbose' })).toThrow( - /too old/ - ) - expect(() => agentLaunchTerminalInputsFor(undefined, { terminalOnly: true })).toThrow(/too old/) - }) - - it("parses an older client's launch exactly as before", () => { - const params = { agent: 'claude', target: { kind: 'existing', worktree: 'id:w' } } - expect(AgentLaunch.parse(params)).toEqual(params) - }) -}) diff --git a/src/shared/agent-launch-terminal-inputs.ts b/src/shared/agent-launch-terminal-inputs.ts deleted file mode 100644 index 9cf26fdecf5..00000000000 --- a/src/shared/agent-launch-terminal-inputs.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { AGENT_LAUNCH_TERMINAL_INPUTS_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' - -export type AgentLaunchTerminalInputParams = { - title?: string - extraAgentArgs?: string - requestKind?: string - terminalOnly?: boolean -} - -/** - * The `agent.launch` terminal inputs a client may send to this host. An older host drops unknown - * keys, so an input that changes what runs refuses here rather than launching something else; a - * label (title, request kind) is just left off. - */ -export function agentLaunchTerminalInputsFor( - hostCapabilities: readonly string[] | null | undefined, - inputs: AgentLaunchTerminalInputParams -): AgentLaunchTerminalInputParams { - if (hostCapabilities?.includes(AGENT_LAUNCH_TERMINAL_INPUTS_RUNTIME_CAPABILITY)) { - return inputs - } - if (inputs.extraAgentArgs || inputs.terminalOnly) { - throw new Error( - 'The Orca host for this workspace is too old to start this agent. Update Orca on that host and try again.' - ) - } - return {} -} diff --git a/src/shared/agent-startup-plan-inputs-extra-agent-args.test.ts b/src/shared/agent-startup-plan-inputs-extra-agent-args.test.ts index a247d6e7105..75375e3500f 100644 --- a/src/shared/agent-startup-plan-inputs-extra-agent-args.test.ts +++ b/src/shared/agent-startup-plan-inputs-extra-agent-args.test.ts @@ -20,6 +20,7 @@ describe('resolveAgentStartupPlanInputs with extra agent args', () => { settings: SETTINGS, platform, isRemote, + shell: undefined, extraAgentArgs: '--model opus --add-dir "my docs"' }) const plan = buildAgentStartupPlan({ ...inputs, prompt: 'go' }) @@ -34,6 +35,7 @@ describe('resolveAgentStartupPlanInputs with extra agent args', () => { settings: SETTINGS, platform: 'darwin', isRemote: false, + shell: undefined, extraAgentArgs: '--effort high' }) const desktopArgs = mergeExtraAgentArgs({ @@ -64,6 +66,7 @@ describe('resolveAgentStartupPlanInputs with extra agent args', () => { settings: SETTINGS, platform: 'linux', isRemote: false, + shell: undefined, extraAgentArgs: '--permission-mode bypassPermissions' }) ).toThrow('"--permission-mode"') @@ -75,6 +78,7 @@ describe('resolveAgentStartupPlanInputs with extra agent args', () => { settings: SETTINGS, platform: 'linux', isRemote: false, + shell: undefined, agentArgs: null, extraAgentArgs: ' ' }) diff --git a/src/shared/agent-startup-plan-inputs.test.ts b/src/shared/agent-startup-plan-inputs.test.ts index c241fbb4d15..bc645aba5ea 100644 --- a/src/shared/agent-startup-plan-inputs.test.ts +++ b/src/shared/agent-startup-plan-inputs.test.ts @@ -17,7 +17,8 @@ describe('resolveAgentStartupPlanInputs', () => { agentDefaultEnv: { claude: { FOO: 'bar' } } }, platform: 'darwin', - isRemote: false + isRemote: false, + shell: undefined }) expect(inputs.cmdOverrides).toEqual({ claude: 'my-claude' }) @@ -28,7 +29,8 @@ describe('resolveAgentStartupPlanInputs', () => { agent: 'claude', settings: EMPTY_SETTINGS, platform: 'darwin', - isRemote: false + isRemote: false, + shell: undefined }).cmdOverrides ).toEqual({}) }) @@ -39,7 +41,8 @@ describe('resolveAgentStartupPlanInputs', () => { agent: 'claude' as const, settings, platform: 'darwin' as const, - isRemote: false + isRemote: false, + shell: undefined } expect(resolveAgentStartupPlanInputs(base).agentArgs).toBe('--verbose') @@ -57,7 +60,8 @@ describe('resolveAgentStartupPlanInputs', () => { agent: 'claude', settings, platform: 'win32', - isRemote: false + isRemote: false, + shell: undefined }).shell ).toBe('posix') // Remote targets need their own shell signal before quoting can be overridden. @@ -66,7 +70,8 @@ describe('resolveAgentStartupPlanInputs', () => { agent: 'claude', settings, platform: 'win32', - isRemote: true + isRemote: true, + shell: undefined }).shell ).toBeUndefined() expect( @@ -74,7 +79,8 @@ describe('resolveAgentStartupPlanInputs', () => { agent: 'claude', settings, platform: 'darwin', - isRemote: false + isRemote: false, + shell: undefined }).shell ).toBeUndefined() }) @@ -96,7 +102,8 @@ describe('resolveAgentStartupPlanInputs', () => { agent: 'codex' as const, settings: EMPTY_SETTINGS, platform: 'darwin' as const, - isRemote: false + isRemote: false, + shell: undefined } const without = resolveAgentStartupPlanInputs(base) @@ -123,6 +130,7 @@ describe('a picked session option outranks configured launch arguments', () => { settings, platform: 'darwin', isRemote: false, + shell: undefined, sessionOptions: { model: 'gpt-5' } }), prompt: '', diff --git a/src/shared/agent-startup-plan-inputs.ts b/src/shared/agent-startup-plan-inputs.ts index 66b937dd0c9..08837d699ea 100644 --- a/src/shared/agent-startup-plan-inputs.ts +++ b/src/shared/agent-startup-plan-inputs.ts @@ -46,8 +46,9 @@ export function resolveAgentStartupPlanInputs(args: { agentArgs?: string | null /** An automation's saved extras, merged over the resolved args; invalid extras throw. */ extraAgentArgs?: string - /** The shell family the execution host resolved for this PTY; absent derives it from Settings. */ - shell?: AgentStartupShell + /** The shell family this PTY will be; `undefined` reads it from the Settings shell, which is + * right only where the Settings shell is the one the PTY gets. */ + shell: AgentStartupShell | undefined sessionOptions?: Record | undefined }): AgentStartupPlanInputs { const { agent, settings, platform, isRemote, sessionOptions } = args diff --git a/src/shared/local-workspace-execution-runtime.ts b/src/shared/local-workspace-execution-runtime.ts new file mode 100644 index 00000000000..77edabe22b7 --- /dev/null +++ b/src/shared/local-workspace-execution-runtime.ts @@ -0,0 +1,78 @@ +import type { Project } from './project-types' +import type { Repo } from './repo-types' +import { + findFolderWorkspaceCandidateRepos, + resolveFolderWorkspaceHost, + type FolderWorkspaceHostState +} from './folder-workspace-execution-host' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host' +import { + deriveGlobalWindowsRuntimeDefaultFromLegacySettings, + getWorkspaceRuntimePreference, + resolveProjectExecutionRuntime, + type LegacyWindowsRuntimeSettings, + type ProjectExecutionRuntimeResolution +} from './project-execution-runtime' + +/** The one local repo whose project runs a folder workspace on this machine; none for a folder + * pinned to another host or spanning several repos. */ +export function findLocalFolderWorkspaceRuntimeRepo( + state: FolderWorkspaceHostState, + folderWorkspaceId: string +): Repo | undefined { + const folder = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + if (!folder || resolveFolderWorkspaceHost(state, folder.id).kind !== 'local') { + return undefined + } + const group = state.projectGroups.find((entry) => entry.id === folder.projectGroupId) + if ( + (folder.executionHostId ?? group?.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !== + LOCAL_EXECUTION_HOST_ID + ) { + return undefined + } + const candidates = findFolderWorkspaceCandidateRepos(state, folder.id) + const repo = candidates.length === 1 ? candidates[0] : undefined + return repo && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID ? repo : undefined +} + +/** + * A local repo workspace's runtime, read the same way by the window and the host: its project's + * saved runtime, else the distro of a WSL-share path, else the global default. + */ +export function resolveLocalWorkspaceExecutionRuntime(args: { + appPlatform: NodeJS.Platform + projects: + | readonly Pick[] + | undefined + settings: + | (LegacyWindowsRuntimeSettings & { localWindowsRuntimeDefault?: unknown }) + | null + | undefined + repoId: string + /** The workspace's own project; absent looks the project up by repo. */ + projectId?: string + workspacePath: string | null | undefined + wslAvailable?: boolean + availableWslDistros?: readonly string[] | null +}): ProjectExecutionRuntimeResolution { + const project = args.projects?.find( + (entry) => + entry.id === args.projectId || + entry.id === args.repoId || + entry.sourceRepoIds?.includes(args.repoId) + ) + return resolveProjectExecutionRuntime({ + appPlatform: args.appPlatform, + projectId: args.projectId ?? project?.id ?? args.repoId, + projectRuntimePreference: getWorkspaceRuntimePreference( + project?.localWindowsRuntimePreference, + args.workspacePath + ), + globalWindowsRuntimeDefault: + args.settings?.localWindowsRuntimeDefault ?? + deriveGlobalWindowsRuntimeDefaultFromLegacySettings(args.settings).defaultRuntime, + wslAvailable: args.wslAvailable, + availableWslDistros: args.availableWslDistros + }) +}