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.
This commit is contained in:
Brennan Benson
2026-10-11 20:48:28 -07:00
parent 3b168fa1be
commit 5326c88d0d
45 changed files with 676 additions and 601 deletions
+12 -2
View File
@@ -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) {
@@ -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<AgentLaunchStructuredSurface>
createTerminalAgent(
args: {
worktreeId: string
agent: TuiAgent
options?: Readonly<Record<string, unknown>>
/** 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<Record<string, unknown>>
/** 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<Record<string, unknown>>
/** 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<Record<string, unknown>>
} & AgentLaunchTerminalInputs
): Promise<{
createWorktree(args: {
create: Readonly<Record<string, unknown>>
/** 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<Record<string, unknown>>
/** 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. */
@@ -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',
@@ -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' })
+9 -21
View File
@@ -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)
+57 -48
View File
@@ -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<Project, 'id' | 'localWindowsRuntimePreference'>,
settings: ReturnType<ResolvableStore['getSettings']> = 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<FolderWorkspace, 'id'> | 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<typeof resolveLocalPtyRuntimeOptions>[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()
})
}
@@ -35,6 +35,7 @@ function scope() {
},
platform: process.platform,
isRemote: false,
shell: undefined,
sessionOptions: { model: preferred }
}),
cwd: '/private/project',
@@ -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
@@ -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 = <T>(fields: Partial<T>): T => fields as T
const repo = (path: string) => fixture<Repo>({ id: 'r', path, projectGroupId: 'g' })
const folder = (path: string) =>
fixture<FolderWorkspace>({ 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'
)
})
+45 -62
View File
@@ -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<LaunchScopeRuntimeStore, 'getSettings'> &
Pick<RuntimeStore, 'getSettings'>
/** 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))
}
}
}
@@ -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<typeof buildWorktreeStartupForDraft> {
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<typeof buildWorktreeStartupForAgent> {
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)
})
}
@@ -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),
@@ -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,
@@ -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,
@@ -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<TerminalCreateOptions, 'shellOverride' | 'launchRuntime'>
@@ -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<TerminalCreateOptions, 'signal' | 'refuseSleptWorktree' | 'spawnIntent'>
): { signal?: AbortSignal; refuseSleptWorktree?: true; spawnIntent?: SpawnIntent } {
@@ -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)
@@ -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,
@@ -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 } : {})
})
@@ -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
}
}
}
@@ -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<typeof resolveRepoAgentLaunchRuntime> {
return resolveRepoAgentLaunchRuntime(this.requireStore(), repo)
}
}
@@ -107,6 +107,7 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi
targetGroupId: opts.targetGroupId,
supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement,
launchConfig: startupCommand.launchConfig,
launchRuntime: startupCommand.launchRuntime,
signal: opts.signal
}
)
@@ -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
@@ -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') {
@@ -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<Repo, 'connectionId' | 'path'>,
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)
}
@@ -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' }
})
})
})
@@ -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<TerminalCreateOptions> {
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
})
)
})
}
@@ -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,
@@ -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,
@@ -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
@@ -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>): 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',
@@ -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<RuntimeStore['getSettings']>
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
@@ -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.`
)
}
@@ -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() ?? ''
+14 -42
View File
@@ -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<Worktree, 'hostId'> | 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
@@ -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,
+7 -7
View File
@@ -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', () => {
@@ -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
-74
View File
@@ -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
})
})
})
+62 -57
View File
@@ -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)
}
}
@@ -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)
})
})
@@ -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 {}
}
@@ -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: ' '
})
+15 -7
View File
@@ -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: '',
+3 -2
View File
@@ -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<string, SessionOptionValue> | undefined
}): AgentStartupPlanInputs {
const { agent, settings, platform, isRemote, sessionOptions } = args
@@ -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<Project, 'id' | 'sourceRepoIds' | 'localWindowsRuntimePreference'>[]
| 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
})
}