mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
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:
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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() ?? ''
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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: ' '
|
||||
})
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user