mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
feat(agent-launch): host plans agent launches with the window's runtime rule and one runtime fact
Host-started agent launches now pick their platform with the same rule the window uses (project runtime, else a \\wsl$ path, else the host), and resolve one runtime fact that supplies both the command quoting and the PTY shell. Adds the agent.launch.terminal-inputs.v1 capability and its optional title/extraAgentArgs/requestKind/terminalOnly fields; no sender yet. OpenCode's createAgentSession stays on today's rules until C2 (temporary).
This commit is contained in:
@@ -41,8 +41,8 @@ export type AgentLaunchSurfaceExecution = AgentLaunchExecutionBase & {
|
||||
intent: AgentLaunchIntent
|
||||
surfaces: AgentLaunchSurfaceFactory
|
||||
workspaces?: AgentLaunchWorkspaceFactory
|
||||
/** Host-internal, never on the wire: settles a terminal agent whatever the chat default says, for
|
||||
* a caller whose contract is a terminal handle. */
|
||||
/** Settles a terminal agent whatever the chat default says, for a caller whose contract is a
|
||||
* terminal handle or whose surface is a terminal tab (`agent.launch`'s `terminalOnly`). */
|
||||
terminalOnly?: boolean
|
||||
/** The launch's own delivery: argv only when the typed line carries it, else a paste. */
|
||||
promptPolicy?: undefined
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
} from './agent-launch-legacy-host'
|
||||
import {
|
||||
AgentLaunchStructuredSessionRefusedError,
|
||||
terminalLaunchInputs,
|
||||
type AgentLaunchStructuredSurface
|
||||
} from './agent-launch-surface-factories'
|
||||
import type {
|
||||
@@ -93,7 +94,7 @@ export async function executeAgentLaunch(
|
||||
...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {})
|
||||
},
|
||||
settings: readAgentLaunchModeSettings(runtime),
|
||||
...(execution.terminalOnly ? { terminalOnly: true } : {}),
|
||||
...(execution.terminalOnly || intent.terminalOnly ? { terminalOnly: true } : {}),
|
||||
vocabulary
|
||||
})
|
||||
// The create's startup terminal is this launch's only surface, and the create delivers the text.
|
||||
@@ -319,7 +320,7 @@ async function createSurface(
|
||||
function ignoredStructuredAgentArgsWarning(
|
||||
intent: AgentLaunchIntent
|
||||
): { warning: string } | undefined {
|
||||
return intent.agentArgs === undefined
|
||||
return intent.agentArgs === undefined && intent.extraAgentArgs === undefined
|
||||
? undefined
|
||||
: {
|
||||
warning:
|
||||
@@ -327,19 +328,6 @@ function ignoredStructuredAgentArgsWarning(
|
||||
}
|
||||
}
|
||||
|
||||
/** What every route that builds a terminal agent passes on, so the startup terminal of a new
|
||||
* workspace and the terminal of an existing one start the same agent. */
|
||||
function terminalLaunchInputs(intent: AgentLaunchIntent) {
|
||||
return {
|
||||
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
|
||||
// `null` is a value the caller meant, so this tests for absence rather than falsiness.
|
||||
...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}),
|
||||
...(intent.cwd ? { cwd: intent.cwd } : {}),
|
||||
...(intent.launchSource ? { launchSource: intent.launchSource } : {}),
|
||||
...(intent.paneKey ? { paneKey: intent.paneKey } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a terminal agent is created, so the structured-refusal downgrade builds the same
|
||||
* surface — carrying the same argv prompt — as a launch that chose a terminal outright.
|
||||
|
||||
@@ -4,10 +4,18 @@
|
||||
* implements, not part of the sequencing it runs.
|
||||
*/
|
||||
|
||||
import type { AgentLaunchPrompt } from '../../shared/agent-launch-intent'
|
||||
import type { AgentLaunchIntent, AgentLaunchPrompt } from '../../shared/agent-launch-intent'
|
||||
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. */
|
||||
@@ -23,23 +31,25 @@ 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 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'
|
||||
}): 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 `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<{
|
||||
handle: string
|
||||
/** The pane this create minted; a factory whose runtime reports none omits it, never invents. */
|
||||
paneKey?: string
|
||||
@@ -101,27 +111,29 @@ 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>>
|
||||
}): 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>>
|
||||
} & AgentLaunchTerminalInputs
|
||||
): 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. */
|
||||
@@ -140,3 +152,19 @@ export type AgentLaunchWorkspaceFactory = {
|
||||
create: Readonly<Record<string, unknown>>
|
||||
}): Promise<{ worktreeId: string; connectionId: string | null }>
|
||||
}
|
||||
|
||||
/** What every route that builds a terminal agent passes on, so the startup terminal of a new
|
||||
* workspace and the terminal of an existing one start the same agent. */
|
||||
export function terminalLaunchInputs(intent: AgentLaunchIntent) {
|
||||
return {
|
||||
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
|
||||
// `null` is a value the caller meant, so this tests for absence rather than falsiness.
|
||||
...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}),
|
||||
...(intent.cwd ? { cwd: intent.cwd } : {}),
|
||||
...(intent.launchSource ? { launchSource: intent.launchSource } : {}),
|
||||
...(intent.paneKey ? { paneKey: intent.paneKey } : {}),
|
||||
...(intent.title ? { title: intent.title } : {}),
|
||||
...(intent.extraAgentArgs ? { extraAgentArgs: intent.extraAgentArgs } : {}),
|
||||
...(intent.requestKind ? { requestKind: intent.requestKind } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* The `agent_started` triple for an agent launch the host builds, of which only `launch_source`
|
||||
* comes from the caller.
|
||||
* The `agent_started` triple for an agent launch the host builds; `launch_source` and
|
||||
* `request_kind` come from the caller.
|
||||
*
|
||||
* `agent_kind` and `request_kind` are derived rather than accepted — the host already knows both,
|
||||
* and a value it derives is a value a caller cannot misreport. `request_kind` is always `new`
|
||||
* because a host-built launch is always a fresh agent.
|
||||
* `agent_kind` is derived rather than accepted — a value the host derives is one a caller cannot
|
||||
* misreport. `request_kind` is the caller's (a resume or follow-up the host cannot tell from a fresh
|
||||
* start), read as leniently as `launch_source`: absent or unknown is `new`.
|
||||
*
|
||||
* Every launch is attributed: a caller that names no surface, or one this build has never heard of
|
||||
* (a newer client), is recorded as `unknown` rather than dropped. The wire keeps the arm set open so
|
||||
@@ -13,18 +13,20 @@
|
||||
*/
|
||||
|
||||
import { tuiAgentToAgentKind } from '../../shared/agent-kind'
|
||||
import { launchSourceSchema } from '../../shared/telemetry-property-schemas'
|
||||
import { launchSourceSchema, requestKindSchema } from '../../shared/telemetry-property-schemas'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
|
||||
|
||||
export function agentStartedTelemetry(
|
||||
agent: TuiAgent,
|
||||
launchSource: string | undefined
|
||||
launchSource: string | undefined,
|
||||
requestKind?: string
|
||||
): NonNullable<WorktreeStartupLaunch['telemetry']> {
|
||||
const parsed = launchSourceSchema.safeParse(launchSource)
|
||||
const kind = requestKindSchema.safeParse(requestKind)
|
||||
return {
|
||||
agent_kind: tuiAgentToAgentKind(agent),
|
||||
launch_source: parsed.success ? parsed.data : 'unknown',
|
||||
request_kind: 'new'
|
||||
request_kind: kind.success ? kind.data : 'new'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,25 @@ describe('runtime pty spawn preflight: requested shell on a local Windows host',
|
||||
|
||||
await expect(resolveSpawnShell(undefined)).resolves.toBe(HOST_DEFAULT_SHELL)
|
||||
})
|
||||
|
||||
// Relied on by C2 and C5b: a planned agent launch's shell is the one its command was quoted for.
|
||||
it("takes an agent launch's shell only from its plan", async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
await expect(
|
||||
resolveSpawnOptions({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
shellOverride: 'cmd.exe',
|
||||
launchRuntime: {
|
||||
platform: 'linux',
|
||||
quotingShell: 'posix',
|
||||
ptyShellOverride: 'wsl.exe',
|
||||
wslDistro: 'Ubuntu'
|
||||
}
|
||||
})
|
||||
).resolves.toMatchObject({ shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime pty spawn preflight: OpenSSH login shell on a managed SSH host (#9327)', () => {
|
||||
|
||||
@@ -68,8 +68,13 @@ export async function prepareRuntimePtySpawn(
|
||||
// could only ever get the host default shell, so a caller asking for cmd/PowerShell got the
|
||||
// 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.
|
||||
ctx.terminalRuntimeOptions =
|
||||
process.platform === 'win32'
|
||||
// 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?.(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime'
|
||||
import type { SpawnIntent } from '../../../../shared/host-spawn-admission'
|
||||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types'
|
||||
import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry'
|
||||
@@ -106,6 +107,8 @@ export type RuntimePtySpawnArgs = {
|
||||
leafId?: string
|
||||
sessionId?: string
|
||||
shellOverride?: string
|
||||
/** The agent launch's runtime fact; when present the PTY shell comes only from it. */
|
||||
launchRuntime?: AgentLaunchRuntime
|
||||
isNewSession?: boolean
|
||||
/** No renderer view exists at spawn; main owns delivery and query replies until one mounts. */
|
||||
initiallyHidden?: boolean
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { Store } from './persistence'
|
||||
import type { GlobalSettings } from '../shared/global-settings-types'
|
||||
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,
|
||||
resolveProjectExecutionRuntime,
|
||||
@@ -107,6 +111,62 @@ export function resolveLocalWorkspaceRuntime(
|
||||
})
|
||||
}
|
||||
|
||||
export type LaunchScopeRuntimeStore = ProjectRuntimeResolutionStore & {
|
||||
getRepos?: () => Repo[]
|
||||
getFolderWorkspaces?: () => FolderWorkspace[]
|
||||
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(
|
||||
store: LaunchScopeRuntimeStore,
|
||||
scope: {
|
||||
id: string
|
||||
path: string
|
||||
connectionId: string | null
|
||||
repo: Repo | null
|
||||
folderWorkspace: Pick<FolderWorkspace, 'id'> | null
|
||||
}
|
||||
): ProjectExecutionRuntimeResolution | undefined {
|
||||
if (scope.connectionId || !canResolveProjectRuntimeForRepo(store)) {
|
||||
return undefined
|
||||
}
|
||||
const repo =
|
||||
scope.repo ??
|
||||
(scope.folderWorkspace
|
||||
? findFolderWorkspaceRuntimeRepo(store, 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
|
||||
}
|
||||
|
||||
export function resolveLocalProjectRuntimesForRepos(
|
||||
store: ProjectRuntimeResolutionStore,
|
||||
repos: readonly Repo[]
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { WORKSPACE_ON_OTHER_RUNTIME } from '../../shared/protocol-version'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
getSshTargetIdForExecutionHost,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
parseExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import {
|
||||
findFolderWorkspaceCandidateRepos,
|
||||
resolveFolderWorkspaceHost
|
||||
} from '../../shared/folder-workspace-execution-host'
|
||||
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../shared/project-group-types'
|
||||
import { resolveFolderWorkspaceHost } from '../../shared/folder-workspace-execution-host'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import {
|
||||
findFolderWorkspaceRuntimeRepo,
|
||||
resolveLocalWorkspaceRuntime,
|
||||
type ProjectRuntimeResolutionStore
|
||||
type LaunchScopeRuntimeStore
|
||||
} from '../local-project-runtime-resolution'
|
||||
import type { PreflightRuntimeContext } from '../ipc/preflight-runtime-target'
|
||||
import { resolveWorktreeHostRouting } from '../runtime/worktree-launch-host-repo'
|
||||
@@ -30,11 +25,7 @@ export type AgentDetectionHost =
|
||||
| { kind: 'local'; context?: PreflightRuntimeContext }
|
||||
| { kind: 'ssh'; connectionId: string }
|
||||
|
||||
export type WorkspaceAgentDetectionStore = ProjectRuntimeResolutionStore & {
|
||||
getRepos?: () => Repo[]
|
||||
getFolderWorkspaces?: () => FolderWorkspace[]
|
||||
getProjectGroups?: () => ProjectGroup[]
|
||||
}
|
||||
export type WorkspaceAgentDetectionStore = LaunchScopeRuntimeStore
|
||||
|
||||
export const HOST_DEFAULT_AGENT_DETECTION: AgentDetectionHost = { kind: 'local' }
|
||||
|
||||
@@ -117,13 +108,8 @@ function resolveFolderWorkspace(
|
||||
throw new Error(WORKSPACE_ON_OTHER_RUNTIME)
|
||||
}
|
||||
const folder = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
|
||||
const candidates = findFolderWorkspaceCandidateRepos(state, folderWorkspaceId)
|
||||
// Why one candidate: a folder spanning several repos has no single project runtime to pick.
|
||||
const repo = candidates.length === 1 ? candidates[0] : undefined
|
||||
return host.kind === 'local' &&
|
||||
folder &&
|
||||
repo &&
|
||||
getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID
|
||||
const repo = findFolderWorkspaceRuntimeRepo(store, folderWorkspaceId)
|
||||
return host.kind === 'local' && folder && repo
|
||||
? { kind: 'local', repo, path: folder.folderPath }
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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'
|
||||
|
||||
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
|
||||
})
|
||||
const scope = (
|
||||
path: string,
|
||||
kind: { repo?: Repo; folder?: FolderWorkspace }
|
||||
): TerminalWorkspaceLaunchScope => ({
|
||||
id: 'w',
|
||||
path,
|
||||
connectionId: null,
|
||||
repo: kind.repo ?? null,
|
||||
folderWorkspace: kind.folder ?? null
|
||||
})
|
||||
function store(args: {
|
||||
repos?: Repo[]
|
||||
project?: unknown
|
||||
global?: unknown
|
||||
folder?: FolderWorkspace
|
||||
}) {
|
||||
const fake = {
|
||||
getProjects: () => [
|
||||
{
|
||||
id: 'p',
|
||||
sourceRepoIds: ['r'],
|
||||
localWindowsRuntimePreference: args.project
|
||||
}
|
||||
],
|
||||
getSettings: () => ({
|
||||
terminalWindowsShell: 'cmd.exe',
|
||||
localWindowsRuntimeDefault: args.global
|
||||
}),
|
||||
getRepo: () => args.repos?.[0],
|
||||
getRepos: () => args.repos ?? [],
|
||||
getFolderWorkspaces: () => (args.folder ? [args.folder] : []),
|
||||
getProjectGroups: () => [{ id: 'g', name: 'g', parentPath: null, connectionId: null }]
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolvers read only these members and fields.
|
||||
return fake as never
|
||||
}
|
||||
|
||||
const original = process.platform
|
||||
beforeEach(() =>
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
)
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: original
|
||||
})
|
||||
_resetWslCachesForTests()
|
||||
})
|
||||
|
||||
// Relied on by C2: "+" moves onto the host planner with no change, because both sides use this rule.
|
||||
describe("the window's runtime rule, on the host", () => {
|
||||
it.each([
|
||||
['repo, project WSL', store({ project: UBUNTU }), scope(WIN, { repo: repo(WIN) }), 'linux'],
|
||||
[
|
||||
'repo, no saved runtime, \\\\wsl$ path',
|
||||
store({}),
|
||||
scope(SHARE, { repo: repo(SHARE) }),
|
||||
'linux'
|
||||
],
|
||||
[
|
||||
'repo, global default WSL',
|
||||
store({ global: UBUNTU }),
|
||||
scope(WIN, { repo: repo(WIN) }),
|
||||
'linux'
|
||||
],
|
||||
[
|
||||
'folder holding one WSL-project repo',
|
||||
store({
|
||||
project: UBUNTU,
|
||||
repos: [repo(WIN)],
|
||||
folder: folder('C:\\work')
|
||||
}),
|
||||
scope('C:\\work', { folder: folder('C:\\work') }),
|
||||
'linux'
|
||||
],
|
||||
[
|
||||
'repo-less folder ignores the global default, as the window does',
|
||||
store({ global: UBUNTU, folder: folder('C:\\work') }),
|
||||
scope('C:\\work', { folder: folder('C:\\work') }),
|
||||
'win32'
|
||||
],
|
||||
['repo-less \\\\wsl$ folder', store({}), scope(SHARE, { folder: folder(SHARE) }), 'linux']
|
||||
])('%s', (_name, s, launchScope, platform) => {
|
||||
expect(agentLaunchPlatformForScope(s, launchScope)).toBe(platform)
|
||||
})
|
||||
})
|
||||
|
||||
// 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", () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Debian'] })
|
||||
expect(() =>
|
||||
resolveAgentLaunchRuntime(
|
||||
store({ project: UBUNTU }),
|
||||
scope(WIN, { repo: repo(WIN) }),
|
||||
undefined
|
||||
)
|
||||
).toThrow('wsl-distro-missing')
|
||||
// The window before its first capability read passes no WSL facts (local-preflight-context.ts).
|
||||
expect(
|
||||
resolveProjectExecutionRuntime({
|
||||
appPlatform: 'win32',
|
||||
projectId: 'p',
|
||||
projectRuntimePreference: UBUNTU
|
||||
}).status
|
||||
).toBe('resolved')
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import {
|
||||
deriveAgentLaunchRuntime,
|
||||
executionRuntimePlatform,
|
||||
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 {
|
||||
resolveLocalLaunchScopeRuntime,
|
||||
resolveLocalProjectRuntimeForRepo,
|
||||
resolveLocalProjectRuntimeForWorktreeId,
|
||||
resolveLocalWorkspaceRuntime,
|
||||
type LaunchScopeRuntimeStore
|
||||
} from '../local-project-runtime-resolution'
|
||||
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,
|
||||
scope: TerminalWorkspaceLaunchScope
|
||||
): NodeJS.Platform {
|
||||
return scope.connectionId
|
||||
? remoteExecutionPlatform(scope.path)
|
||||
: executionRuntimePlatform(
|
||||
resolveLocalLaunchScopeRuntime(store, scope),
|
||||
scope.path,
|
||||
process.platform
|
||||
)
|
||||
}
|
||||
|
||||
/** 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
|
||||
): AgentLaunchRuntime {
|
||||
return deriveAgentLaunchRuntime({
|
||||
hostPlatform: process.platform,
|
||||
remote: Boolean(scope.connectionId),
|
||||
workspacePath: scope.path,
|
||||
projectRuntime: resolveLocalLaunchScopeRuntime(store, scope),
|
||||
requestedShellOverride,
|
||||
settings: store.getSettings(),
|
||||
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.
|
||||
*/
|
||||
export function resolvePreC1AgentLaunchRuntime(
|
||||
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
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
|
||||
agentArgs?: string | null
|
||||
extraAgentArgs?: string
|
||||
launchSource?: string
|
||||
requestKind?: string
|
||||
onPromptCarry?: (carried: boolean) => void
|
||||
}
|
||||
): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } {
|
||||
@@ -192,6 +193,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
|
||||
...(launchInputs?.agentArgs !== undefined ? { agentArgs: launchInputs.agentArgs } : {}),
|
||||
...(launchInputs?.extraAgentArgs ? { extraAgentArgs: launchInputs.extraAgentArgs } : {}),
|
||||
...(launchInputs?.launchSource ? { launchSource: launchInputs.launchSource } : {}),
|
||||
...(launchInputs?.requestKind ? { requestKind: launchInputs.requestKind } : {}),
|
||||
...(launchInputs?.onPromptCarry ? { onPromptCarry: launchInputs.onPromptCarry } : {}),
|
||||
settings: this.store.getSettings(),
|
||||
getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo),
|
||||
|
||||
@@ -130,10 +130,13 @@ 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 startupArgs = resolveAgentStartupPlanInputs({
|
||||
agent: request.agent,
|
||||
settings,
|
||||
platform: this.getAgentLaunchPlatformForWorkspace(workspace),
|
||||
platform: launchRuntime.platform,
|
||||
shell: launchRuntime.quotingShell,
|
||||
// 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),
|
||||
@@ -180,6 +183,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
|
||||
env: startup.env,
|
||||
launchConfig: startup.launchConfig,
|
||||
launchAgent: request.agent,
|
||||
launchRuntime,
|
||||
terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol,
|
||||
startupCommandDelivery: startup.startupCommandDelivery,
|
||||
// A fresh agent this host built; the request has no surface field, so it counts as `unknown`.
|
||||
|
||||
@@ -43,6 +43,16 @@ export const BACKGROUND_TERMINAL_SPAWN_FLAGS = {
|
||||
|
||||
/** 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'>
|
||||
): Pick<TerminalCreateOptions, 'shellOverride' | 'launchRuntime'> {
|
||||
return {
|
||||
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
|
||||
...(opts.launchRuntime ? { launchRuntime: opts.launchRuntime } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function callerSpawnScope(
|
||||
opts: Pick<TerminalCreateOptions, 'signal' | 'refuseSleptWorktree' | 'spawnIntent'>
|
||||
): { signal?: AbortSignal; refuseSleptWorktree?: true; spawnIntent?: SpawnIntent } {
|
||||
|
||||
@@ -145,7 +145,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
preAllocatedHandle,
|
||||
tabId,
|
||||
leafId,
|
||||
...(launchOpts.shellOverride ? { shellOverride: launchOpts.shellOverride } : {}),
|
||||
...dependencies.spawnShellInputs(launchOpts),
|
||||
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
|
||||
terminalKittyKeyboardProtocol: launchOpts.terminalKittyKeyboardProtocol,
|
||||
...(launchOpts.agentSessionClaim
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import { canonicalizeAgentSessionIdentity } from './agent-session-claim-identity'
|
||||
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
|
||||
import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import { OrchestrationError } from './orchestration/orchestration-error'
|
||||
import {
|
||||
@@ -125,15 +124,11 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim
|
||||
if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) {
|
||||
throw new Error('Selected agent is disabled. Choose an enabled agent before resuming.')
|
||||
}
|
||||
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
|
||||
const launchRuntime = this.resolveAgentLaunchRuntime(workspace)
|
||||
const { platform, quotingShell: shell } = 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)
|
||||
const shell = resolveLocalWindowsAgentStartupShell({
|
||||
platform,
|
||||
isRemote,
|
||||
terminalWindowsShell: settings.terminalWindowsShell
|
||||
})
|
||||
const startup = buildAgentResumeStartupPlan({
|
||||
agent: request.agent,
|
||||
providerSession: identity.providerSession,
|
||||
@@ -162,6 +157,7 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim
|
||||
launchConfig: startup.launchConfig,
|
||||
startupCommandDelivery: startup.startupCommandDelivery,
|
||||
launchAgent: request.agent,
|
||||
launchRuntime,
|
||||
terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol,
|
||||
presentation: request.presentation ?? 'background',
|
||||
tabId: request.placement?.tabId,
|
||||
|
||||
@@ -46,12 +46,14 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt
|
||||
if (!isTuiAgentEnabled(opts.agent, settings.disabledTuiAgents)) {
|
||||
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
|
||||
}
|
||||
const launchRuntime = this.resolveAgentLaunchRuntime(workspace)
|
||||
const startupPlan = buildAgentStartupPlan({
|
||||
...resolveAgentStartupPlanInputs({
|
||||
agent: opts.agent,
|
||||
settings,
|
||||
// Why: mobile may be iOS while the shell host is Windows/macOS/Linux or SSH Linux; quote for the host shell.
|
||||
platform: this.getAgentLaunchPlatformForWorkspace(workspace),
|
||||
platform: launchRuntime.platform,
|
||||
shell: launchRuntime.quotingShell,
|
||||
// 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)
|
||||
}),
|
||||
|
||||
@@ -20,9 +20,6 @@ import type {
|
||||
import type { RuntimeTerminalRename } from '../../shared/runtime-types'
|
||||
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
|
||||
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
|
||||
import { terminalShellOverrideRefusal } from './terminal-shell-override-host-support'
|
||||
import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd'
|
||||
import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution'
|
||||
import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../shared/execution-host'
|
||||
import { invalidateAuthorizedRootsCacheForRepo } from '../ipc/filesystem-auth'
|
||||
import {
|
||||
@@ -33,8 +30,7 @@ import {
|
||||
import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal'
|
||||
import { retryFailedRemovalUnlessRegistered } from '../worktree-removal-table'
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import { resolveQoderTerminalCommandForWorkspace } from './qoder-terminal-command-resolution'
|
||||
import { buildRuntimeAgentTerminalStartupOptions } from './runtime-agent-terminal-startup'
|
||||
import { resolveAgentTerminalCreateOptions } from './runtime-agent-terminal-startup'
|
||||
|
||||
export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree {
|
||||
protected async resolveWorktreeRemovalTarget(
|
||||
@@ -243,69 +239,10 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
|
||||
workspace: TerminalWorkspaceLaunchScope,
|
||||
opts: TerminalCreateOptions
|
||||
): Promise<TerminalCreateOptions> {
|
||||
const launch = await this.buildAgentTerminalCreateOptions(workspace, opts)
|
||||
return resolveQoderTerminalCommandForWorkspace(
|
||||
launch,
|
||||
workspace,
|
||||
this.store,
|
||||
this.getAgentLaunchPlatformForWorkspace(workspace)
|
||||
)
|
||||
}
|
||||
|
||||
protected async buildAgentTerminalCreateOptions(
|
||||
workspace: TerminalWorkspaceLaunchScope,
|
||||
opts: TerminalCreateOptions
|
||||
): Promise<TerminalCreateOptions> {
|
||||
// Before any early return: every create lane funnels through here, and a host that cannot
|
||||
// apply the requested shell must refuse rather than spawn its default one.
|
||||
const shellRefusal = terminalShellOverrideRefusal({
|
||||
shellOverride: opts.shellOverride,
|
||||
connectionId: workspace.connectionId,
|
||||
platform: process.platform,
|
||||
projectRuntime:
|
||||
opts.shellOverride && this.store
|
||||
? resolveLocalProjectRuntimeForWorktreeId(this.store, workspace.id)
|
||||
: undefined,
|
||||
// Same resolution as the spawn lanes below, so the refusal judges the cwd the PTY gets.
|
||||
cwd: resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path,
|
||||
workspacePath: workspace.path
|
||||
return resolveAgentTerminalCreateOptions(workspace, opts, {
|
||||
store: this.store,
|
||||
hostIdentity: this.runtimeId,
|
||||
toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences)
|
||||
})
|
||||
if (shellRefusal) {
|
||||
throw shellRefusal
|
||||
}
|
||||
// Why: raw shell commands like `codex exec` must remain user-authored shell.
|
||||
// Only unmanaged, repo-backed, bare agent launches get Settings defaults.
|
||||
const callerSuppliedLaunch =
|
||||
opts.env ||
|
||||
opts.launchConfig ||
|
||||
opts.launchAgent ||
|
||||
opts.startupCommandDelivery ||
|
||||
opts.claudeAgentTeamsSourceCommand
|
||||
const store = this.store
|
||||
if (opts.startupAgent) {
|
||||
// Why: falling through unresolved would spawn a bare shell that can only time
|
||||
// out waiting for an agent. A caller-supplied launch contradicts the agent:
|
||||
// `command` would be overwritten, `resumeProviderSession` would pair resume
|
||||
// identity with a fresh launch.
|
||||
if (callerSuppliedLaunch || opts.command || opts.resumeProviderSession) {
|
||||
throw new Error(
|
||||
`startupAgent ${opts.startupAgent} cannot combine with a caller-supplied launch.`
|
||||
)
|
||||
}
|
||||
if (!store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
} else if (callerSuppliedLaunch || !store || !opts.command || !workspace.repo) {
|
||||
return opts
|
||||
}
|
||||
|
||||
return buildRuntimeAgentTerminalStartupOptions(
|
||||
workspace,
|
||||
opts,
|
||||
store.getSettings(),
|
||||
this.getAgentLaunchPlatformForWorkspace(workspace),
|
||||
this.toAgentSessionOptions(opts.launchPreferences),
|
||||
this.runtimeId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,16 @@ import type { Repo } from '../../shared/repo-types'
|
||||
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
|
||||
import {
|
||||
getLocalProjectGhExecOptions,
|
||||
resolveLocalProjectRuntimeForRepo,
|
||||
type LocalProjectGhExecOptions
|
||||
} from '../project-runtime-git-options'
|
||||
import { getAgentLaunchPlatformForRepo } from './runtime-agent-launch-resolution'
|
||||
import {
|
||||
agentLaunchPlatformForRepo,
|
||||
agentLaunchPlatformForScope,
|
||||
resolveAgentLaunchRuntime,
|
||||
resolvePreC1AgentLaunchRuntime
|
||||
} from './agent-launch-scope-runtime'
|
||||
import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime'
|
||||
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { parseAppSshPtyId } from '../../shared/ssh-pty-id'
|
||||
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import type { StructuredAgentId } from '../../shared/agent-session-provider-handle'
|
||||
@@ -265,21 +268,25 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
|
||||
}
|
||||
|
||||
protected getAgentLaunchPlatformForRepo(repo: Repo): NodeJS.Platform {
|
||||
const projectRuntime = repo.connectionId
|
||||
? undefined
|
||||
: resolveLocalProjectRuntimeForRepo(this.requireStore(), repo)
|
||||
return getAgentLaunchPlatformForRepo(repo, projectRuntime)
|
||||
return agentLaunchPlatformForRepo(this.requireStore(), repo)
|
||||
}
|
||||
|
||||
protected getAgentLaunchPlatformForWorkspace(
|
||||
scope: TerminalWorkspaceLaunchScope
|
||||
): NodeJS.Platform {
|
||||
if (scope.repo) {
|
||||
return this.getAgentLaunchPlatformForRepo(scope.repo)
|
||||
}
|
||||
if (scope.connectionId) {
|
||||
return isWindowsAbsolutePathLike(scope.path) ? 'win32' : 'linux'
|
||||
}
|
||||
return isWslUncPath(scope.path) ? 'linux' : process.platform
|
||||
return agentLaunchPlatformForScope(this.requireStore(), scope)
|
||||
}
|
||||
|
||||
protected resolveAgentLaunchRuntime(
|
||||
scope: TerminalWorkspaceLaunchScope,
|
||||
requestedShellOverride?: string
|
||||
): AgentLaunchRuntime {
|
||||
return resolveAgentLaunchRuntime(this.requireStore(), scope, requestedShellOverride)
|
||||
}
|
||||
|
||||
protected resolvePreC1AgentLaunchRuntime(
|
||||
scope: TerminalWorkspaceLaunchScope
|
||||
): AgentLaunchRuntime {
|
||||
return resolvePreC1AgentLaunchRuntime(this.requireStore(), scope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,45 +2,10 @@ import type { PreflightRuntimeContext } from '../preflight/agent-detection'
|
||||
import { detectAgentCommandsOnHost } from '../preflight/agent-detection'
|
||||
import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||
import {
|
||||
resolveStartupShell,
|
||||
tokenizeStartupCommand,
|
||||
type AgentStartupShell
|
||||
} from '../../shared/tui-agent-startup-shell'
|
||||
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
|
||||
import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
|
||||
|
||||
export async function resolveQoderTerminalCommandForWorkspace(
|
||||
launch: TerminalCreateOptions,
|
||||
workspace: TerminalWorkspaceLaunchScope,
|
||||
store: RuntimeStore | null | undefined,
|
||||
platform: NodeJS.Platform
|
||||
): Promise<TerminalCreateOptions> {
|
||||
if (launch.launchAgent !== 'qoder') {
|
||||
return launch
|
||||
}
|
||||
return resolveQoderTerminalCommand(launch, {
|
||||
connectionId: workspace.connectionId,
|
||||
context:
|
||||
!workspace.connectionId && store
|
||||
? {
|
||||
projectRuntime: resolveLocalProjectRuntimeForWorktreeId(store, workspace.id),
|
||||
wslDistro: parseWslUncPath(workspace.path)?.distro
|
||||
}
|
||||
: undefined,
|
||||
shell: resolveStartupShell(
|
||||
platform,
|
||||
resolveLocalWindowsAgentStartupShell({
|
||||
platform,
|
||||
isRemote: Boolean(workspace.connectionId),
|
||||
terminalWindowsShell: launch.shellOverride ?? store?.getSettings().terminalWindowsShell
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveQoderTerminalCommand<
|
||||
T extends Pick<TerminalCreateOptions, 'launchAgent' | 'command' | 'launchConfig'>
|
||||
|
||||
@@ -54,7 +54,11 @@ async function agentLaunchIntent(
|
||||
...(params.cwd ? { cwd: params.cwd } : {}),
|
||||
...(params.launchSource ? { launchSource: params.launchSource } : {}),
|
||||
...(paneKey ? { paneKey } : {}),
|
||||
...(params.sessionId ? { sessionId: params.sessionId } : {})
|
||||
...(params.sessionId ? { sessionId: params.sessionId } : {}),
|
||||
...(params.title ? { title: params.title } : {}),
|
||||
...(params.extraAgentArgs ? { extraAgentArgs: params.extraAgentArgs } : {}),
|
||||
...(params.requestKind ? { requestKind: params.requestKind } : {}),
|
||||
...(params.terminalOnly ? { terminalOnly: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,10 @@ export function agentLaunchSurfaceFactory(
|
||||
launchSource,
|
||||
paneKey,
|
||||
options,
|
||||
viewMode
|
||||
viewMode,
|
||||
title,
|
||||
extraAgentArgs,
|
||||
requestKind
|
||||
}) => {
|
||||
const launchPreferences = toAgentLaunchPreferences(options)
|
||||
let promptRodeLaunchCommand = false
|
||||
@@ -157,6 +160,9 @@ export function agentLaunchSurfaceFactory(
|
||||
// A live reserved pane would be attached, not launched into, so the runtime refuses it.
|
||||
...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}),
|
||||
...(launchSource ? { launchSource } : {}),
|
||||
...(requestKind ? { requestKind } : {}),
|
||||
...(title ? { title } : {}),
|
||||
...(extraAgentArgs ? { extraAgentArgs } : {}),
|
||||
...(viewMode ? { viewMode } : {}),
|
||||
...(earlyTab?.windowShowsTab() || !movesHostWindow ? { surfaceOwner: false as const } : {}),
|
||||
onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched
|
||||
|
||||
@@ -47,7 +47,10 @@ export function agentLaunchWorkspaceFactory(
|
||||
cwd,
|
||||
launchSource,
|
||||
paneKey,
|
||||
options
|
||||
options,
|
||||
title,
|
||||
extraAgentArgs,
|
||||
requestKind
|
||||
}) => {
|
||||
const startupLaunchPreferences = toAgentLaunchPreferences(options)
|
||||
let promptRodeLaunchCommand = false
|
||||
@@ -93,6 +96,9 @@ export function agentLaunchWorkspaceFactory(
|
||||
: {}),
|
||||
...(cwd ? { startupCwd: cwd } : {}),
|
||||
...(launchSource ? { startupLaunchSource: launchSource } : {}),
|
||||
...(requestKind ? { startupRequestKind: requestKind } : {}),
|
||||
...(title ? { startupTitle: title } : {}),
|
||||
...(extraAgentArgs ? { startupExtraAgentArgs: extraAgentArgs } : {}),
|
||||
...(paneKey ? { startupPaneKey: paneKey } : {}),
|
||||
...(startupLaunchPreferences ? { startupLaunchPreferences } : {}),
|
||||
// The launch owns the agent whichever surface it settles on, so the workspace records
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* error. So `worktree.create` keeps that meaning verbatim, forever, and everything that has to
|
||||
* choose a surface comes here instead, behind a negotiated capability.
|
||||
*
|
||||
* A caller therefore never asks for a mode, and must read `outcome.kind` rather than assume one:
|
||||
* the receipt always says which surface ran and why, so a downgrade is never silent.
|
||||
* A caller never asks for a chat (`terminalOnly` only rules one out) and must read `outcome.kind`
|
||||
* rather than assume one: the receipt always says which surface ran and why, so a downgrade is never
|
||||
* silent.
|
||||
*
|
||||
* A launch is also the one call whose retry is most expensive to get wrong — a lost reply means the
|
||||
* caller cannot tell "never ran" from "ran, answer lost" — so a caller may name the operation with
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime'
|
||||
import { detectAgentCommandsOnHost } from '../preflight/agent-detection'
|
||||
import { resolveAgentTerminalCreateOptions } from './runtime-agent-terminal-startup'
|
||||
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
|
||||
|
||||
vi.mock('../preflight/agent-detection', () => ({
|
||||
detectAgentCommandsOnHost: vi.fn(async () => new Set(['qoder']))
|
||||
}))
|
||||
|
||||
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.
|
||||
store: { getSettings: () => ({}) } as never,
|
||||
hostIdentity: 'host-1',
|
||||
toSessionOptions: () => undefined
|
||||
}
|
||||
const CMD: AgentLaunchRuntime = {
|
||||
platform: 'win32',
|
||||
quotingShell: 'cmd',
|
||||
ptyShellOverride: 'cmd.exe',
|
||||
wslDistro: null
|
||||
}
|
||||
const plan = (opts: TerminalCreateOptions, launchRuntime: AgentLaunchRuntime = CMD) =>
|
||||
resolveAgentTerminalCreateOptions(workspace, { ...opts, launchRuntime }, host)
|
||||
|
||||
// The host planner's inputs. Relied on by C2 (pane spawns run this plan), C3 and C7.
|
||||
describe('host agent planner', () => {
|
||||
it("quotes for the fact's shell and hands that same fact to the spawn", async () => {
|
||||
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.
|
||||
const own = await resolveAgentTerminalCreateOptions(
|
||||
workspace,
|
||||
{ command: 'claude', launchAgent: 'claude' },
|
||||
host
|
||||
)
|
||||
expect(own.launchRuntime).toMatchObject({ platform: process.platform, wslDistro: null })
|
||||
})
|
||||
|
||||
it('merges extra arguments and reports the request kind (C3 resume, C7 automations)', async () => {
|
||||
const launch = await plan({
|
||||
startupAgent: 'claude',
|
||||
agentArgs: null,
|
||||
extraAgentArgs: '--model opus',
|
||||
requestKind: 'resume'
|
||||
})
|
||||
expect(launch.command).toBe('claude "--model" "opus"')
|
||||
expect(launch.telemetry?.request_kind).toBe('resume')
|
||||
})
|
||||
|
||||
it("keeps a long prompt on the command line under the window's rule (C3, C7)", async () => {
|
||||
const prompt = 'x'.repeat(600)
|
||||
const carried: boolean[] = []
|
||||
const opts = { startupAgent: 'claude' as const, startupPrompt: prompt }
|
||||
const onStartupPromptCarry = (value: boolean) => carried.push(value)
|
||||
expect((await plan({ ...opts, onStartupPromptCarry })).command).not.toContain(prompt)
|
||||
expect((await plan({ ...opts, promptCarry: 'argv', onStartupPromptCarry })).command).toContain(
|
||||
prompt
|
||||
)
|
||||
expect(carried).toEqual([false, true])
|
||||
})
|
||||
|
||||
it('makes a promptless Codex wait for the shell only when asked (C8 deletes the choice)', async () => {
|
||||
expect((await plan({ startupAgent: 'codex' })).startupCommandDelivery).toBeUndefined()
|
||||
expect(
|
||||
(await plan({ startupAgent: 'codex', codexNoPromptShellWait: true })).startupCommandDelivery
|
||||
).toBe('shell-ready')
|
||||
})
|
||||
|
||||
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')
|
||||
expect(detectAgentCommandsOnHost).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(detectAgentCommandsOnHost).mock.calls[0]?.[1]).toMatchObject({
|
||||
context: { wslDistro: 'Ubuntu', wslDefault: true }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,15 @@
|
||||
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 type { RuntimeStore } from './runtime-store-contract'
|
||||
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
|
||||
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
|
||||
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
|
||||
import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution'
|
||||
import { planExecutionHostStartupWithPromptCandidate } from '../opencode/opencode-model-startup-plan'
|
||||
import {
|
||||
buildExecutionHostAgentStartupPlan,
|
||||
planExecutionHostStartupWithPromptCandidate
|
||||
} from '../opencode/opencode-model-startup-plan'
|
||||
import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup'
|
||||
import {
|
||||
launchHostProvesAgentInFront,
|
||||
@@ -13,14 +18,119 @@ import {
|
||||
import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd'
|
||||
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
|
||||
import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry'
|
||||
import { terminalShellOverrideRefusal } from './terminal-shell-override-host-support'
|
||||
import {
|
||||
resolveLocalLaunchScopeRuntime,
|
||||
resolveLocalProjectRuntimeForWorktreeId
|
||||
} 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 { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
|
||||
|
||||
export async function buildRuntimeAgentTerminalStartupOptions(
|
||||
type AgentTerminalPlanHost = {
|
||||
store: RuntimeStore | null | undefined
|
||||
hostIdentity: string
|
||||
toSessionOptions: (
|
||||
preferences: AgentLaunchPreferences | undefined
|
||||
) => Record<string, SessionOptionValue> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function resolveAgentTerminalCreateOptions(
|
||||
workspace: TerminalWorkspaceLaunchScope,
|
||||
opts: TerminalCreateOptions,
|
||||
host: AgentTerminalPlanHost
|
||||
): Promise<TerminalCreateOptions> {
|
||||
const store = host.store ?? undefined
|
||||
// Why: raw shell commands like `codex exec` must remain user-authored shell.
|
||||
// Only unmanaged, repo-backed, bare agent launches get Settings defaults.
|
||||
const callerSuppliedLaunch =
|
||||
opts.env ||
|
||||
opts.launchConfig ||
|
||||
opts.launchAgent ||
|
||||
opts.startupCommandDelivery ||
|
||||
opts.claudeAgentTeamsSourceCommand
|
||||
const plannedAgent =
|
||||
opts.startupAgent ??
|
||||
(!callerSuppliedLaunch && store && opts.command && workspace.repo
|
||||
? resolveBareAgentLaunchCommand({
|
||||
command: opts.command,
|
||||
settings: store.getSettings(),
|
||||
platform: agentLaunchPlatformForScope(store, workspace),
|
||||
isRemote: Boolean(workspace.connectionId)
|
||||
})
|
||||
: null)
|
||||
const launchAgent = plannedAgent ?? opts.launchAgent
|
||||
// Before any early return: a host that cannot apply the requested shell must refuse rather than
|
||||
// spawn another one. Judged against the runtime this PTY's shell will come from.
|
||||
const shellRefusal = terminalShellOverrideRefusal({
|
||||
shellOverride: opts.shellOverride,
|
||||
connectionId: workspace.connectionId,
|
||||
platform: process.platform,
|
||||
projectRuntime:
|
||||
opts.shellOverride && store
|
||||
? launchAgent
|
||||
? resolveLocalLaunchScopeRuntime(store, workspace)
|
||||
: resolveLocalProjectRuntimeForWorktreeId(store, workspace.id)
|
||||
: undefined,
|
||||
cwd: resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path,
|
||||
workspacePath: workspace.path
|
||||
})
|
||||
if (shellRefusal) {
|
||||
throw shellRefusal
|
||||
}
|
||||
if (opts.startupAgent) {
|
||||
// Why: falling through unresolved would spawn a bare shell that can only time out waiting for
|
||||
// an agent. A caller-supplied launch contradicts the agent: `command` would be overwritten,
|
||||
// `resumeProviderSession` would pair resume identity with a fresh launch.
|
||||
if (callerSuppliedLaunch || opts.command || opts.resumeProviderSession) {
|
||||
throw new Error(
|
||||
`startupAgent ${opts.startupAgent} cannot combine with a caller-supplied launch.`
|
||||
)
|
||||
}
|
||||
if (!store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
}
|
||||
if (!launchAgent || !store) {
|
||||
return opts
|
||||
}
|
||||
const runtime =
|
||||
opts.launchRuntime ?? resolveAgentLaunchRuntime(store, workspace, opts.shellOverride)
|
||||
const launch = plannedAgent
|
||||
? await buildRuntimeAgentTerminalStartupOptions(
|
||||
workspace,
|
||||
opts,
|
||||
plannedAgent,
|
||||
store.getSettings(),
|
||||
runtime,
|
||||
host
|
||||
)
|
||||
: opts
|
||||
if (!launch.launchAgent) {
|
||||
return launch
|
||||
}
|
||||
return {
|
||||
...(await resolveQoderLaunchCommand(launch, workspace, runtime)),
|
||||
launchRuntime: runtime
|
||||
}
|
||||
}
|
||||
|
||||
async function buildRuntimeAgentTerminalStartupOptions(
|
||||
workspace: TerminalWorkspaceLaunchScope,
|
||||
opts: TerminalCreateOptions,
|
||||
agent: TuiAgent,
|
||||
settings: ReturnType<RuntimeStore['getSettings']>,
|
||||
platform: NodeJS.Platform,
|
||||
sessionOptions: Record<string, SessionOptionValue> | undefined,
|
||||
hostIdentity: string
|
||||
runtime: AgentLaunchRuntime,
|
||||
host: AgentTerminalPlanHost
|
||||
): Promise<TerminalCreateOptions> {
|
||||
// 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.
|
||||
@@ -28,48 +138,45 @@ export async function buildRuntimeAgentTerminalStartupOptions(
|
||||
if (opts.startupAgent && !isTuiAgentEnabled(opts.startupAgent, settings.disabledTuiAgents)) {
|
||||
throw new Error(`Agent ${opts.startupAgent} is disabled. Choose an enabled agent.`)
|
||||
}
|
||||
const agent =
|
||||
opts.startupAgent ??
|
||||
resolveBareAgentLaunchCommand({
|
||||
command: opts.command,
|
||||
settings,
|
||||
platform,
|
||||
isRemote
|
||||
})
|
||||
if (!agent) {
|
||||
return opts
|
||||
}
|
||||
|
||||
// A prompt this launch command cannot carry has nowhere to go from here — the create returns
|
||||
// options, not a live PTY — so refuse rather than spawn the agent and drop the text.
|
||||
if (opts.startupPrompt && !agentPromptRidesLaunchCommand(agent)) {
|
||||
throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`)
|
||||
}
|
||||
const { plan: startupPlan, promptCarried } = await planExecutionHostStartupWithPromptCandidate({
|
||||
const startup = {
|
||||
inputs: resolveAgentStartupPlanInputs({
|
||||
agent,
|
||||
settings,
|
||||
platform,
|
||||
platform: runtime.platform,
|
||||
isRemote,
|
||||
...(opts.agentArgs !== undefined ? { agentArgs: opts.agentArgs } : {}),
|
||||
// A requested shell is the one this PTY will actually be, so it owns the quoting family.
|
||||
windowsShellOverride: opts.shellOverride,
|
||||
sessionOptions: sessionOptions
|
||||
...(opts.extraAgentArgs ? { extraAgentArgs: opts.extraAgentArgs } : {}),
|
||||
shell: runtime.quotingShell,
|
||||
sessionOptions: host.toSessionOptions(opts.launchPreferences)
|
||||
}),
|
||||
prompt: opts.startupPrompt ?? '',
|
||||
cwd: resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path,
|
||||
hostIdentity,
|
||||
host: {
|
||||
shellName: nameLocalTypedLineShell({
|
||||
isRemote,
|
||||
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
|
||||
...(settings.terminalDefaultShell
|
||||
? { defaultShellSetting: settings.terminalDefaultShell }
|
||||
: {})
|
||||
}),
|
||||
provesAgentInFront: launchHostProvesAgentInFront({ isRemote, launchPlatform: platform })
|
||||
}
|
||||
})
|
||||
hostIdentity: host.hostIdentity
|
||||
}
|
||||
const { plan: startupPlan, promptCarried } =
|
||||
opts.promptCarry === 'argv'
|
||||
? {
|
||||
plan: await buildExecutionHostAgentStartupPlan(startup),
|
||||
promptCarried: startup.prompt.trim() !== ''
|
||||
}
|
||||
: await planExecutionHostStartupWithPromptCandidate({
|
||||
...startup,
|
||||
host: {
|
||||
shellName: nameLocalTypedLineShell({
|
||||
isRemote,
|
||||
...(runtime.ptyShellOverride ? { shellOverride: runtime.ptyShellOverride } : {})
|
||||
}),
|
||||
provesAgentInFront: launchHostProvesAgentInFront({
|
||||
isRemote,
|
||||
launchPlatform: runtime.platform
|
||||
})
|
||||
}
|
||||
})
|
||||
if (!startupPlan) {
|
||||
// Why: an explicit agent that yields no plan would otherwise spawn a bare
|
||||
// shell that never reaches agent readiness.
|
||||
@@ -81,6 +188,11 @@ export async function buildRuntimeAgentTerminalStartupOptions(
|
||||
if (opts.startupPrompt) {
|
||||
opts.onStartupPromptCarry?.(promptCarried)
|
||||
}
|
||||
const startupCommandDelivery =
|
||||
startupPlan.startupCommandDelivery ??
|
||||
(opts.codexNoPromptShellWait && agent === 'codex' && !startup.prompt.trim()
|
||||
? 'shell-ready'
|
||||
: undefined)
|
||||
|
||||
return {
|
||||
...opts,
|
||||
@@ -88,8 +200,27 @@ export async function buildRuntimeAgentTerminalStartupOptions(
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
launchConfig: startupPlan.launchConfig,
|
||||
launchAgent: agent,
|
||||
startupCommandDelivery: startupPlan.startupCommandDelivery,
|
||||
startupCommandDelivery,
|
||||
// A bare command the user typed stays out of launch accounting, as before.
|
||||
...(opts.startupAgent ? { telemetry: agentStartedTelemetry(agent, opts.launchSource) } : {})
|
||||
...(opts.startupAgent
|
||||
? { telemetry: agentStartedTelemetry(agent, opts.launchSource, opts.requestKind) }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Qoder ships under two command names; the launch runs whichever its execution host has. */
|
||||
function resolveQoderLaunchCommand(
|
||||
launch: TerminalCreateOptions,
|
||||
workspace: TerminalWorkspaceLaunchScope,
|
||||
runtime: AgentLaunchRuntime
|
||||
): Promise<TerminalCreateOptions> {
|
||||
return resolveQoderTerminalCommand(launch, {
|
||||
connectionId: workspace.connectionId,
|
||||
context: workspace.connectionId
|
||||
? undefined
|
||||
: isWslShellName(runtime.ptyShellOverride)
|
||||
? { wslDistro: runtime.wslDistro, wslDefault: true }
|
||||
: {},
|
||||
shell: runtime.quotingShell
|
||||
})
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ export async function createRuntimeFolderWorktree(args: {
|
||||
const terminal = await deps.createTerminal(`id:${worktree.id}`, {
|
||||
command: args.startup.command,
|
||||
...(request.startupCwd ? { cwd: request.startupCwd } : {}),
|
||||
...(request.startupTitle ? { title: request.startupTitle } : {}),
|
||||
...paneIdentity(request.startupPaneKey),
|
||||
env: args.startup.env,
|
||||
...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}),
|
||||
|
||||
@@ -97,6 +97,7 @@ export async function startRuntimeLocalWorktreeTerminals(args: {
|
||||
const terminal = await ports.createTerminal(`id:${worktree.id}`, {
|
||||
command: sequencedStartup.command,
|
||||
...(request.startupCwd ? { cwd: request.startupCwd } : {}),
|
||||
...(request.startupTitle ? { title: request.startupTitle } : {}),
|
||||
...paneIdentity(request.startupPaneKey),
|
||||
...(setup && startup ? { claudeAgentTeamsSourceCommand: startup.command } : {}),
|
||||
env: sequencedStartup.env,
|
||||
|
||||
@@ -61,8 +61,12 @@ export type RuntimeManagedWorktreeCreateArgs = {
|
||||
/** Main-internal: an automation's saved extras, merged over the startup agent's arguments. */
|
||||
startupExtraAgentArgs?: string
|
||||
startupCwd?: string
|
||||
/** The startup terminal's tab title. */
|
||||
startupTitle?: string
|
||||
/** The surface behind a host-built startup agent (`startupAgent` or `startupDraft`). */
|
||||
startupLaunchSource?: string
|
||||
/** `agent_started.request_kind` for a host-built `startupAgent`; absent is `new`. */
|
||||
startupRequestKind?: string
|
||||
/** A caller-minted `tabId:leafId` for the startup terminal's pane. */
|
||||
startupPaneKey?: string
|
||||
pendingFirstAgentMessageRename?: boolean
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime'
|
||||
import type {
|
||||
AutomaticWorkResumeOutcome,
|
||||
AutomaticWorkStatus,
|
||||
@@ -72,6 +73,8 @@ export type RuntimePtyController = {
|
||||
sessionId?: string
|
||||
/** Windows shell to spawn AS this PTY, instead of the host default. */
|
||||
shellOverride?: string
|
||||
/** The agent launch's runtime fact; when present the PTY shell comes only from it. */
|
||||
launchRuntime?: AgentLaunchRuntime
|
||||
isNewSession?: boolean
|
||||
/** No renderer view exists at spawn; main owns delivery and query replies until one mounts. */
|
||||
initiallyHidden?: boolean
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime'
|
||||
import type { SpawnIntent } from '../../shared/host-spawn-admission'
|
||||
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
|
||||
import type {
|
||||
@@ -64,6 +65,22 @@ export type TerminalCreateOptions = {
|
||||
* the runtime still builds, so overriding them does not take the launch away from it.
|
||||
*/
|
||||
agentArgs?: string | null
|
||||
/** An automation's saved extras, merged over the launch's arguments; invalid extras throw. */
|
||||
extraAgentArgs?: string
|
||||
/** `agent_started.request_kind` for a `startupAgent` launch; absent or unknown is `new`. */
|
||||
requestKind?: string
|
||||
/**
|
||||
* Launch-convergence parity: how a `startupPrompt` this line cannot carry is handled. Absent is
|
||||
* the host's rule (start clean, the caller pastes it); `argv` is the window's rule (always on the
|
||||
* command line, where long lines are staged). Dies with prompt-at-start.
|
||||
*/
|
||||
promptCarry?: 'argv'
|
||||
/** 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. */
|
||||
launchRuntime?: AgentLaunchRuntime
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
terminalKittyKeyboardProtocol?: boolean
|
||||
terminalColorQueryReplies?: TerminalOscColorQueryReplyColors
|
||||
|
||||
@@ -36,6 +36,7 @@ type StartupEnvironment = {
|
||||
extraAgentArgs?: string
|
||||
/** Caller-supplied telemetry attribution, validated leniently at the host boundary. */
|
||||
launchSource?: string
|
||||
requestKind?: string
|
||||
}
|
||||
|
||||
/** The agent a linked draft starts: the requested one, else the default, else one detected on the
|
||||
@@ -194,7 +195,7 @@ export function buildWorktreeStartupForAgent(
|
||||
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
|
||||
: {}),
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
telemetry: agentStartedTelemetry(agent, environment.launchSource)
|
||||
telemetry: agentStartedTelemetry(agent, environment.launchSource, environment.requestKind)
|
||||
},
|
||||
...(startupPlan.followupPrompt
|
||||
? {
|
||||
@@ -217,6 +218,7 @@ export function resolveWorktreeCreateAgentStartup(
|
||||
agentArgs?: string | null
|
||||
extraAgentArgs?: string
|
||||
launchSource?: string
|
||||
requestKind?: string
|
||||
onPromptCarry?: (carried: boolean) => void
|
||||
}
|
||||
) => { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup }
|
||||
@@ -228,6 +230,7 @@ export function resolveWorktreeCreateAgentStartup(
|
||||
...(args.startupAgentArgs !== undefined ? { agentArgs: args.startupAgentArgs } : {}),
|
||||
...(args.startupExtraAgentArgs ? { extraAgentArgs: args.startupExtraAgentArgs } : {}),
|
||||
...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {}),
|
||||
...(args.startupRequestKind ? { requestKind: args.startupRequestKind } : {}),
|
||||
...(args.onStartupPromptCarry ? { onPromptCarry: args.onStartupPromptCarry } : {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ParsedExecutionHost
|
||||
} from '../../../shared/execution-host'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
|
||||
import { executionRuntimePlatform } from '../../../shared/agent-launch-runtime'
|
||||
import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status'
|
||||
import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
|
||||
@@ -52,19 +53,6 @@ const RUNTIME_PLATFORM_UPDATE =
|
||||
'This Orca server does not report its operating system. Update the server to launch agents on it.'
|
||||
const HOST_UNRESOLVED_WITHHELD = 'Orca cannot tell which host owns this workspace yet.'
|
||||
|
||||
function resolveLocalPlatform(
|
||||
projectRuntime: ProjectExecutionRuntimeResolution | undefined,
|
||||
workspacePath: string | null | undefined
|
||||
): NodeJS.Platform {
|
||||
if (projectRuntime?.status === 'repair-required') {
|
||||
return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM
|
||||
}
|
||||
if (projectRuntime?.status === 'resolved') {
|
||||
return projectRuntime.runtime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM
|
||||
}
|
||||
return workspacePath && isWslUncPath(workspacePath) ? 'linux' : CLIENT_PLATFORM
|
||||
}
|
||||
|
||||
function resolveHostPlatform(
|
||||
state: HostFactsState,
|
||||
args: {
|
||||
@@ -82,7 +70,11 @@ function resolveHostPlatform(
|
||||
return { kind: 'withheld', reason: HOST_UNRESOLVED_WITHHELD }
|
||||
}
|
||||
if (host.kind === 'local') {
|
||||
const platform = resolveLocalPlatform(args.projectRuntime(), args.workspacePath)
|
||||
const platform = executionRuntimePlatform(
|
||||
args.projectRuntime(),
|
||||
args.workspacePath,
|
||||
CLIENT_PLATFORM
|
||||
)
|
||||
return { kind: 'known', platform, hostKind: 'local' }
|
||||
}
|
||||
const sshTargetId = host.kind === 'ssh' ? host.targetId : args.nestedSshTargetId
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
* resolves it once — settings default plus per-launch feasibility from
|
||||
* `structured-native-chat-launch-route` — so no surface carries its own copy of that decision.
|
||||
*
|
||||
* The intent deliberately does NOT name a mode. A caller states what it wants to happen, not how
|
||||
* The intent deliberately does NOT pick a chat. A caller states what it wants to happen, not how
|
||||
* to deliver it; picking structured vs terminal is the host's job and is reported back in the
|
||||
* receipt rather than requested here.
|
||||
* receipt. `terminalOnly` only rules a chat out, for a caller whose surface is a terminal tab.
|
||||
*/
|
||||
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
@@ -98,6 +98,13 @@ export type AgentLaunchIntent = {
|
||||
/** The caller-minted id of the chat session a structured launch creates. Not a route input;
|
||||
* refused when that session already exists. */
|
||||
sessionId?: string
|
||||
/** A terminal launch's tab title. */
|
||||
title?: string
|
||||
/** Saved extras merged over the launch's arguments; terminal-only, like `agentArgs`. */
|
||||
extraAgentArgs?: string
|
||||
/** `agent_started.request_kind`, as leniently as `launchSource`. */
|
||||
requestKind?: string
|
||||
terminalOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,11 @@ const OTHER_PANE_KEY = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d:6fa459ea-ee8a-4ca4-
|
||||
* not merely that the input type has no name for it.
|
||||
*/
|
||||
function fingerprintOfWirePayload(
|
||||
params: AgentLaunchFingerprintInput & { launchSource?: string }
|
||||
params: AgentLaunchFingerprintInput & {
|
||||
launchSource?: string
|
||||
title?: string
|
||||
requestKind?: string
|
||||
}
|
||||
): string {
|
||||
return computeAgentLaunchFingerprint(params)
|
||||
}
|
||||
@@ -82,6 +86,14 @@ describe('fields the launch fingerprint deliberately ignores', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
describe('compatibility with rows written before these fields existed', () => {
|
||||
/**
|
||||
* A client that sends none of the new fields must still digest to what the previous build
|
||||
|
||||
@@ -48,6 +48,10 @@ export type AgentLaunchFingerprintInput = {
|
||||
/** In: a retry that minted another session is a different request, since replaying would answer
|
||||
* with a conversation this caller did not mint. */
|
||||
sessionId?: string
|
||||
/** In: they change the command, as `agentArgs` does. */
|
||||
extraAgentArgs?: string
|
||||
/** In: it decides the surface, as `cwd` can. */
|
||||
terminalOnly?: boolean
|
||||
/**
|
||||
* `launchSource` is deliberately absent, and this is the reasoned exclusion rather than an
|
||||
* oversight: it is telemetry, so two launches differing only in which button produced them do the
|
||||
@@ -56,8 +60,9 @@ export type AgentLaunchFingerprintInput = {
|
||||
* digest covers what the call DOES — and the cost of leaving it out is only that a replay reports
|
||||
* the first attempt's attribution, which is the truthful answer: one launch happened.
|
||||
*
|
||||
* `placement` and `presentation` are absent by the same rule: they say where the tab sits and whose
|
||||
* view moves, not what runs, so a retry that moved them replays the first answer.
|
||||
* `placement`, `presentation` and `title` are absent by the same rule: they say where the tab sits,
|
||||
* whose view moves and what it is called, not what runs, so a retry that changed them replays the
|
||||
* first answer. `requestKind` is telemetry, excluded as `launchSource` is.
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -75,7 +80,9 @@ export function computeAgentLaunchFingerprint(input: AgentLaunchFingerprintInput
|
||||
cwd: input.cwd,
|
||||
// Absent keys are dropped by the canonical form, so every digest without one is unchanged.
|
||||
paneKey: input.paneKey,
|
||||
sessionId: input.sessionId
|
||||
sessionId: input.sessionId,
|
||||
extraAgentArgs: input.extraAgentArgs,
|
||||
terminalOnly: input.terminalOnly
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ export const AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY = 'agent.launch.placement
|
||||
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`).
|
||||
export const AGENT_LAUNCH_TERMINAL_INPUTS_RUNTIME_CAPABILITY =
|
||||
'agent.launch.terminal-inputs.v1' as const
|
||||
|
||||
// Client-advertised only: the client reads a listed launch tab with no terminal yet as "not
|
||||
// started", never as proof its agent runs. The host publishes a paired caller's tab before the
|
||||
// spawn only for a client that says so.
|
||||
@@ -62,5 +67,6 @@ export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [
|
||||
AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY
|
||||
AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_TERMINAL_INPUTS_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
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 { 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.
|
||||
*/
|
||||
export type AgentLaunchRuntime = {
|
||||
/** The platform the command is quoted for: `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
|
||||
}
|
||||
|
||||
/** A local workspace's platform: its project runtime, else a `\\wsl$` path is Linux, else the host. */
|
||||
export function executionRuntimePlatform(
|
||||
projectRuntime: ProjectExecutionRuntimeResolution | undefined,
|
||||
workspacePath: string | null | undefined,
|
||||
hostPlatform: NodeJS.Platform
|
||||
): NodeJS.Platform {
|
||||
if (projectRuntime?.status === 'repair-required') {
|
||||
return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : hostPlatform
|
||||
}
|
||||
if (projectRuntime?.status === 'resolved') {
|
||||
return projectRuntime.runtime.kind === 'wsl' ? 'linux' : hostPlatform
|
||||
}
|
||||
return workspacePath && isWslUncPath(workspacePath) ? 'linux' : hostPlatform
|
||||
}
|
||||
|
||||
/** A remote workspace is quoted for its own path's family; its host picks the shell. */
|
||||
export function remoteExecutionPlatform(workspacePath: string): NodeJS.Platform {
|
||||
return isWindowsAbsolutePathLike(workspacePath) ? 'win32' : 'linux'
|
||||
}
|
||||
|
||||
export function deriveAgentLaunchRuntime(args: {
|
||||
hostPlatform: NodeJS.Platform
|
||||
remote: boolean
|
||||
workspacePath: string
|
||||
projectRuntime: ProjectExecutionRuntimeResolution | undefined
|
||||
requestedShellOverride: string | undefined
|
||||
settings: Partial<
|
||||
Pick<
|
||||
GlobalSettings,
|
||||
'terminalWindowsShell' | 'terminalWindowsWslDistro' | 'terminalDefaultShell'
|
||||
>
|
||||
>
|
||||
/** 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 {
|
||||
if (args.remote) {
|
||||
const platform = remoteExecutionPlatform(args.workspacePath)
|
||||
return {
|
||||
platform,
|
||||
quotingShell: resolveStartupShell(platform),
|
||||
ptyShellOverride: undefined,
|
||||
wslDistro: 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
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 {}
|
||||
}
|
||||
@@ -79,13 +79,13 @@ describe('resolveAgentStartupPlanInputs', () => {
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lets a requested shell override the configured one', () => {
|
||||
it("lets the host's resolved shell override the configured one", () => {
|
||||
const inputs = resolveAgentStartupPlanInputs({
|
||||
agent: 'claude',
|
||||
settings: { terminalWindowsShell: 'git-bash' },
|
||||
platform: 'win32',
|
||||
isRemote: false,
|
||||
windowsShellOverride: 'cmd.exe'
|
||||
shell: 'cmd'
|
||||
})
|
||||
|
||||
expect(inputs.shell).toBe('cmd')
|
||||
|
||||
@@ -46,8 +46,8 @@ export function resolveAgentStartupPlanInputs(args: {
|
||||
agentArgs?: string | null
|
||||
/** An automation's saved extras, merged over the resolved args; invalid extras throw. */
|
||||
extraAgentArgs?: string
|
||||
/** A requested shell is the one this PTY will be, so it owns the quoting family. */
|
||||
windowsShellOverride?: string | null
|
||||
/** The shell family the execution host resolved for this PTY; absent derives it from Settings. */
|
||||
shell?: AgentStartupShell
|
||||
sessionOptions?: Record<string, SessionOptionValue> | undefined
|
||||
}): AgentStartupPlanInputs {
|
||||
const { agent, settings, platform, isRemote, sessionOptions } = args
|
||||
@@ -57,11 +57,13 @@ export function resolveAgentStartupPlanInputs(args: {
|
||||
args.agentArgs !== undefined
|
||||
? args.agentArgs
|
||||
: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs)
|
||||
const shell = resolveLocalWindowsAgentStartupShell({
|
||||
platform,
|
||||
isRemote,
|
||||
terminalWindowsShell: args.windowsShellOverride ?? settings.terminalWindowsShell
|
||||
})
|
||||
const shell =
|
||||
args.shell ??
|
||||
resolveLocalWindowsAgentStartupShell({
|
||||
platform,
|
||||
isRemote,
|
||||
terminalWindowsShell: settings.terminalWindowsShell
|
||||
})
|
||||
if (hasExtraAgentArgs(args.extraAgentArgs)) {
|
||||
const merged = mergeExtraAgentArgs({
|
||||
agent,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* The wire shape of `agent.launch`, mirroring `AgentLaunchIntent`.
|
||||
*
|
||||
* A caller states WHERE the agent lands and WHAT it should say; it never names a mode. There is
|
||||
* deliberately no `structured` / `terminal` field and no startup-agent field on the create
|
||||
* payload — the host decides, and `withoutReservedAgentCreateFields` strips a stale one out of a
|
||||
* payload a caller migrated over from `worktree.create`.
|
||||
* A caller states WHERE the agent lands and WHAT it should say; it does not pick a chat. Its one
|
||||
* mode input is `terminalOnly`, for a caller whose surface is a terminal tab, and there is no
|
||||
* startup-agent field on the create payload — the host decides, and
|
||||
* `withoutReservedAgentCreateFields` strips a stale one out of a payload a caller migrated over
|
||||
* from `worktree.create`.
|
||||
*
|
||||
* Shared rather than main-side because every field resolves to a shared schema: a remote client
|
||||
* that sends this method needs `RpcSendParams<'agent.launch'>` to exist, and a method missing from
|
||||
@@ -92,6 +93,15 @@ export const AgentLaunchFields = z.object({
|
||||
* structured launch that carries one rather than ignoring it. */
|
||||
cwd: z.string().min(1, 'Empty launch cwd').optional(),
|
||||
launchSource: LaunchSourceParam.optional(),
|
||||
// These four are read by hosts advertising `agent.launch.terminal-inputs.v1`; older hosts drop them.
|
||||
/** The terminal tab's title. Ignored by a structured launch and a reused terminal. */
|
||||
title: z.string().optional(),
|
||||
/** An automation's saved extras, merged over the resolved arguments; invalid extras refuse. */
|
||||
extraAgentArgs: z.string().optional(),
|
||||
/** `agent_started.request_kind`; open like `launchSource`, so an unknown word reads as `new`. */
|
||||
requestKind: z.string().optional(),
|
||||
/** Start a terminal agent whatever the chat default says: the caller's surface is a terminal. */
|
||||
terminalOnly: z.boolean().optional(),
|
||||
/**
|
||||
* The pane a terminal launch should create, minted by a caller that places its own tabs.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user