From f608a90f3901f7fa971996efe8254ffa24f1b49e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:16:57 -0700 Subject: [PATCH] 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). --- .../agent-launch/agent-launch-execution.ts | 4 +- .../agent-launch/agent-launch-executor.ts | 18 +- .../agent-launch-surface-factories.ts | 106 +++++---- .../agent-launch/agent-started-telemetry.ts | 18 +- .../spawn-preflight-requested-shell.test.ts | 19 ++ src/main/ipc/pty/runtime/spawn-preflight.ts | 9 +- src/main/ipc/pty/runtime/spawn-state.ts | 3 + src/main/local-project-runtime-resolution.ts | 60 +++++ .../preflight/workspace-agent-detection.ts | 26 +-- .../agent-launch-scope-runtime.test.ts | 146 +++++++++++++ .../runtime/agent-launch-scope-runtime.ts | 115 ++++++++++ .../orca-runtime-activate-managed-worktree.ts | 2 + .../orca-runtime-create-agent-session.ts | 6 +- ...ca-runtime-create-terminal-dependencies.ts | 10 + .../runtime/orca-runtime-create-terminal.ts | 2 +- ...e-get-agent-session-execution-namespace.ts | 10 +- ...resolve-mobile-session-terminal-command.ts | 4 +- ...runtime-resolve-worktree-removal-target.ts | 73 +------ ...tore-structured-agent-session-tabs-once.ts | 37 ++-- .../qoder-terminal-command-resolution.ts | 35 --- .../methods/agent-launch-intent-resolution.ts | 6 +- .../rpc/methods/agent-launch-surfaces.ts | 8 +- .../methods/agent-launch-worktree-creation.ts | 8 +- src/main/runtime/rpc/methods/agent-launch.ts | 5 +- .../runtime-agent-terminal-startup.test.ts | 84 +++++++ .../runtime/runtime-agent-terminal-startup.ts | 205 ++++++++++++++---- .../runtime/runtime-folder-worktree-create.ts | 1 + ...runtime-local-worktree-terminal-startup.ts | 1 + .../runtime-managed-worktree-create-types.ts | 4 + .../runtime-pty-controller-contract.ts | 3 + .../runtime/runtime-terminal-contracts.ts | 17 ++ .../runtime/runtime-worktree-agent-startup.ts | 5 +- src/renderer/src/lib/execution-host-facts.ts | 20 +- src/shared/agent-launch-intent.ts | 11 +- src/shared/agent-launch-operation.test.ts | 14 +- src/shared/agent-launch-operation.ts | 13 +- src/shared/agent-launch-runtime-capability.ts | 8 +- src/shared/agent-launch-runtime.test.ts | 74 +++++++ src/shared/agent-launch-runtime.ts | 103 +++++++++ .../agent-launch-terminal-inputs.test.ts | 33 +++ src/shared/agent-launch-terminal-inputs.ts | 28 +++ src/shared/agent-startup-plan-inputs.test.ts | 4 +- src/shared/agent-startup-plan-inputs.ts | 16 +- .../rpc-contract/agent-launch-params.ts | 18 +- 44 files changed, 1101 insertions(+), 291 deletions(-) create mode 100644 src/main/runtime/agent-launch-scope-runtime.test.ts create mode 100644 src/main/runtime/agent-launch-scope-runtime.ts create mode 100644 src/main/runtime/runtime-agent-terminal-startup.test.ts create mode 100644 src/shared/agent-launch-runtime.test.ts create mode 100644 src/shared/agent-launch-runtime.ts create mode 100644 src/shared/agent-launch-terminal-inputs.test.ts create mode 100644 src/shared/agent-launch-terminal-inputs.ts diff --git a/src/main/agent-launch/agent-launch-execution.ts b/src/main/agent-launch/agent-launch-execution.ts index 4568c3a70bd..38625fa109a 100644 --- a/src/main/agent-launch/agent-launch-execution.ts +++ b/src/main/agent-launch/agent-launch-execution.ts @@ -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 diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 45caee5d144..a481c906e08 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -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. diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 9166bba4fa8..daf3f8f6a70 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -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 - createTerminalAgent(args: { - worktreeId: string - agent: TuiAgent - options?: Readonly> - /** Offered only for an agent whose CLI takes the prompt on argv. It rides the launch command - * only when the typed line can carry it; `promptRodeLaunchCommand` reports which happened. */ - startupPrompt?: string - /** Replaces the settings default for this launch only; `null` means no arguments at all. */ - agentArgs?: string | null - cwd?: string - /** The one member of the `agent_started` triple the host cannot derive for itself. */ - launchSource?: string - /** The caller-minted pane to create; refused with `AgentLaunchPaneAlreadyLiveError` if live. */ - paneKey?: string - /** The tab's first view, derived on the host by the window's own rule. */ - viewMode?: 'terminal' | 'chat' - }): Promise<{ + createTerminalAgent( + args: { + worktreeId: string + agent: TuiAgent + options?: Readonly> + /** Offered only for an agent whose CLI takes the prompt on argv. It rides the launch command + * only when the typed line can carry it; `promptRodeLaunchCommand` reports which happened. */ + startupPrompt?: string + /** Replaces the settings default for this launch only; `null` means no arguments at all. */ + agentArgs?: string | null + cwd?: string + /** The `agent_started` surface, which the host cannot derive for itself. */ + launchSource?: string + /** The caller-minted pane to create; refused with `AgentLaunchPaneAlreadyLiveError` if live. */ + paneKey?: string + /** The tab's first view, derived on the host by the window's own rule. */ + viewMode?: 'terminal' | 'chat' + } & AgentLaunchTerminalInputs + ): Promise<{ 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> - /** 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> - }): Promise<{ + createWorktree( + args: { + create: Readonly> + /** Set only when the settled mode is a terminal agent: agent-first creation sequences the + * agent's startup command behind the setup runner, which is how a PTY launch gets its + * wait-for-setup gate for free. A structured launch has no startup command to sequence and + * must await that gate explicitly instead. */ + startupAgent: TuiAgent | undefined + /** Offered only alongside a `startupAgent` whose CLI takes the prompt on argv: agent-first + * creation builds the startup command, so that is where the typed line is measured. */ + startupPrompt?: string + /** Set instead of `startupPrompt` under the `legacy-host` prompt policy: this create owns the + * text for any agent and length, as `worktree.create` always has. */ + legacyPrompt?: AgentLaunchPrompt + /** Inputs needed when this terminal is created as the worktree's startup surface. */ + agentArgs?: string | null + cwd?: string + launchSource?: string + paneKey?: string + /** The launch's session options, read as the startup terminal's model/effort/mode preferences. */ + options?: Readonly> + } & 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> }): 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 } : {}) + } +} diff --git a/src/main/agent-launch/agent-started-telemetry.ts b/src/main/agent-launch/agent-started-telemetry.ts index 2d1ff76179d..6274b5e20f6 100644 --- a/src/main/agent-launch/agent-started-telemetry.ts +++ b/src/main/agent-launch/agent-started-telemetry.ts @@ -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 { 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' } } diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index b586de7c0de..99632bbf80e 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -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)', () => { diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 22d33bf708d..64575112ef1 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -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?.(), diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index d4f8472fa09..cbcf100f719 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -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 diff --git a/src/main/local-project-runtime-resolution.ts b/src/main/local-project-runtime-resolution.ts index 2c9d3779b38..2f3c2008567 100644 --- a/src/main/local-project-runtime-resolution.ts +++ b/src/main/local-project-runtime-resolution.ts @@ -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 | 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[] diff --git a/src/main/preflight/workspace-agent-detection.ts b/src/main/preflight/workspace-agent-detection.ts index 15b8e49cc0e..640c5275a77 100644 --- a/src/main/preflight/workspace-agent-detection.ts +++ b/src/main/preflight/workspace-agent-detection.ts @@ -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 } diff --git a/src/main/runtime/agent-launch-scope-runtime.test.ts b/src/main/runtime/agent-launch-scope-runtime.test.ts new file mode 100644 index 00000000000..fc7a3245aa5 --- /dev/null +++ b/src/main/runtime/agent-launch-scope-runtime.test.ts @@ -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') +}) diff --git a/src/main/runtime/agent-launch-scope-runtime.ts b/src/main/runtime/agent-launch-scope-runtime.ts new file mode 100644 index 00000000000..30d8e8e5441 --- /dev/null +++ b/src/main/runtime/agent-launch-scope-runtime.ts @@ -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 & + Pick + +/** The platform an agent for this repo is quoted for before any workspace exists, by the window's + * rule (`resolveRepoExecutionHostPlatform`). */ +export function agentLaunchPlatformForRepo( + store: LaunchScopeRuntimeStore, + repo: Repo +): NodeJS.Platform { + if (repo.connectionId) { + return remoteExecutionPlatform(repo.path) + } + return executionRuntimePlatform( + resolveLocalWorkspaceRuntime(store, repo, repo.path), + repo.path, + process.platform + ) +} + +/** The platform an agent in this workspace is quoted for; never throws, unlike the full fact. */ +export function agentLaunchPlatformForScope( + store: LaunchScopeRuntimeStore, + 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 + }) + ) + } +} diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 193f410cc2f..f0a055a138b 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -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), diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index dc8ea3f2aee..2d80d078263 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -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`. diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 42c5782c411..925fec8c8c8 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -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 +): Pick { + return { + ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), + ...(opts.launchRuntime ? { launchRuntime: opts.launchRuntime } : {}) + } +} + export function callerSpawnScope( opts: Pick ): { signal?: AbortSignal; refuseSleptWorktree?: true; spawnIntent?: SpawnIntent } { diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 35c4667720d..3b640a47af5 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index 5aa3ba9195a..b0677ab0523 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -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, diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index 095ee58a37f..42bdd3cdb52 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -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) }), diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index ed4aa8981b4..79c3726653e 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -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 { - const launch = await this.buildAgentTerminalCreateOptions(workspace, opts) - return resolveQoderTerminalCommandForWorkspace( - launch, - workspace, - this.store, - this.getAgentLaunchPlatformForWorkspace(workspace) - ) - } - - protected async buildAgentTerminalCreateOptions( - workspace: TerminalWorkspaceLaunchScope, - opts: TerminalCreateOptions - ): Promise { - // 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 - ) } } diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index dc8354f0874..3af57c4991f 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -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) } } diff --git a/src/main/runtime/qoder-terminal-command-resolution.ts b/src/main/runtime/qoder-terminal-command-resolution.ts index 7e872db933c..123f2db058e 100644 --- a/src/main/runtime/qoder-terminal-command-resolution.ts +++ b/src/main/runtime/qoder-terminal-command-resolution.ts @@ -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 { - 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 diff --git a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts index be113c90c28..b76a7a3daac 100644 --- a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts +++ b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts @@ -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 } : {}) } } diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 0727f31bef0..138bbaef952 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index 02f1bb86911..54387eee63e 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 5a37362e916..d0bd6d8d404 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -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 diff --git a/src/main/runtime/runtime-agent-terminal-startup.test.ts b/src/main/runtime/runtime-agent-terminal-startup.test.ts new file mode 100644 index 00000000000..4cf75502774 --- /dev/null +++ b/src/main/runtime/runtime-agent-terminal-startup.test.ts @@ -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 } + }) + }) +}) diff --git a/src/main/runtime/runtime-agent-terminal-startup.ts b/src/main/runtime/runtime-agent-terminal-startup.ts index 68932fc0058..f4342d8c969 100644 --- a/src/main/runtime/runtime-agent-terminal-startup.ts +++ b/src/main/runtime/runtime-agent-terminal-startup.ts @@ -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 | 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 { + 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, - platform: NodeJS.Platform, - sessionOptions: Record | undefined, - hostIdentity: string + runtime: AgentLaunchRuntime, + host: AgentTerminalPlanHost ): Promise { // 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 { + return resolveQoderTerminalCommand(launch, { + connectionId: workspace.connectionId, + context: workspace.connectionId + ? undefined + : isWslShellName(runtime.ptyShellOverride) + ? { wslDistro: runtime.wslDistro, wslDefault: true } + : {}, + shell: runtime.quotingShell + }) +} diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index 6534f84e199..7adc0e3533b 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -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 } : {}), diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index 9034228b37d..f19125ece6f 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -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, diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index 0a9d241d8c7..8a568aba209 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -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 diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 157439f3b43..bc7a2c91a54 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -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 diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 8f7212f07e5..d0a2778f087 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -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 diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 72b083b82a7..fe796d28613 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -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 } : {}) }) } diff --git a/src/renderer/src/lib/execution-host-facts.ts b/src/renderer/src/lib/execution-host-facts.ts index d6635931161..e240faa1fd6 100644 --- a/src/renderer/src/lib/execution-host-facts.ts +++ b/src/renderer/src/lib/execution-host-facts.ts @@ -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 diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index 0fbf3699562..61ef355d03b 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -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 } /** diff --git a/src/shared/agent-launch-operation.test.ts b/src/shared/agent-launch-operation.test.ts index 6b24b934fb3..4a84a16711a 100644 --- a/src/shared/agent-launch-operation.test.ts +++ b/src/shared/agent-launch-operation.test.ts @@ -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 diff --git a/src/shared/agent-launch-operation.ts b/src/shared/agent-launch-operation.ts index 920841d568c..fdd2b9b21f4 100644 --- a/src/shared/agent-launch-operation.ts +++ b/src/shared/agent-launch-operation.ts @@ -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 }) } diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index b751c13b9c4..0eddcc617ae 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -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 diff --git a/src/shared/agent-launch-runtime.test.ts b/src/shared/agent-launch-runtime.test.ts new file mode 100644 index 00000000000..ac1953838f6 --- /dev/null +++ b/src/shared/agent-launch-runtime.test.ts @@ -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 + }) + }) +}) diff --git a/src/shared/agent-launch-runtime.ts b/src/shared/agent-launch-runtime.ts new file mode 100644 index 00000000000..d8ab2d6c34c --- /dev/null +++ b/src/shared/agent-launch-runtime.ts @@ -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 + } +} diff --git a/src/shared/agent-launch-terminal-inputs.test.ts b/src/shared/agent-launch-terminal-inputs.test.ts new file mode 100644 index 00000000000..5530cbcd06d --- /dev/null +++ b/src/shared/agent-launch-terminal-inputs.test.ts @@ -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) + }) +}) diff --git a/src/shared/agent-launch-terminal-inputs.ts b/src/shared/agent-launch-terminal-inputs.ts new file mode 100644 index 00000000000..9cf26fdecf5 --- /dev/null +++ b/src/shared/agent-launch-terminal-inputs.ts @@ -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 {} +} diff --git a/src/shared/agent-startup-plan-inputs.test.ts b/src/shared/agent-startup-plan-inputs.test.ts index e1c25407eec..c241fbb4d15 100644 --- a/src/shared/agent-startup-plan-inputs.test.ts +++ b/src/shared/agent-startup-plan-inputs.test.ts @@ -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') diff --git a/src/shared/agent-startup-plan-inputs.ts b/src/shared/agent-startup-plan-inputs.ts index ead55a43715..66b937dd0c9 100644 --- a/src/shared/agent-startup-plan-inputs.ts +++ b/src/shared/agent-startup-plan-inputs.ts @@ -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 | 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, diff --git a/src/shared/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts index 641f2eb4eb1..56cd220fc38 100644 --- a/src/shared/rpc-contract/agent-launch-params.ts +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -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. *