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:
Brennan Benson
2026-10-11 17:16:57 -07:00
parent 8e01e6d9f3
commit f608a90f39
44 changed files with 1101 additions and 291 deletions
@@ -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
+3 -15
View File
@@ -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)', () => {
+7 -2
View File
@@ -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?.(),
+3
View File
@@ -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
+3 -2
View File
@@ -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 } : {})
})
}
+6 -14
View File
@@ -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
+9 -2
View File
@@ -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
}
/**
+13 -1
View File
@@ -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
+10 -3
View File
@@ -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
+74
View File
@@ -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
})
})
})
+103
View File
@@ -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 {}
}
+2 -2
View File
@@ -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')
+9 -7
View File
@@ -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,
+14 -4
View File
@@ -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.
*