fix(agent-launch): round 2: keep request fields for C2, extend main's resolver, one answer per workspace

Problem: the first round still carried request fields and window-parity
switches that only the next step sends, added a new shared resolver that
also changed window code, and left some background launches using a
different runtime answer than the one they spawn with.

- Move the agent.launch request fields, their capability and fingerprint
  handling, and the prompt/Codex parity switches out to the next step;
  today's host behaviour is kept.
- Drop the shared resolver: main's resolveLocalWorkspaceRuntime now finds
  the project by repo id or source repos, folders borrow their single
  local repo, and a \\wsl$ path is not refused on a stale distro list.
  Window code is back to main.
- Agents started in an existing worktree use that worktree's answer; the
  phone's fallback paths spawn with the answer they quoted for, and a
  runtime that needs repair shows its message instead of a plain shell.
- The OpenCode model check looks where the agent will run.
- A Windows --shell in a \\wsl$ workspace names the path, not a runtime
  setting that cannot change it.
- The temporary folder rule names its follow-up, STA-10231.
This commit is contained in:
Brennan Benson
2026-10-11 22:25:46 -07:00
parent 5326c88d0d
commit 1df8cf20e7
41 changed files with 423 additions and 630 deletions
@@ -41,8 +41,8 @@ export type AgentLaunchSurfaceExecution = AgentLaunchExecutionBase & {
intent: AgentLaunchIntent
surfaces: AgentLaunchSurfaceFactory
workspaces?: AgentLaunchWorkspaceFactory
/** 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`). */
/** Host-internal, never on the wire: settles a terminal agent whatever the chat default says, for
* a caller whose contract is a terminal handle. */
terminalOnly?: boolean
/** The launch's own delivery: argv only when the typed line carries it, else a paste. */
promptPolicy?: undefined
+15 -3
View File
@@ -61,7 +61,6 @@ import {
} from './agent-launch-legacy-host'
import {
AgentLaunchStructuredSessionRefusedError,
terminalLaunchInputs,
type AgentLaunchStructuredSurface
} from './agent-launch-surface-factories'
import type {
@@ -94,7 +93,7 @@ export async function executeAgentLaunch(
...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {})
},
settings: readAgentLaunchModeSettings(runtime),
...(execution.terminalOnly || intent.terminalOnly ? { terminalOnly: true } : {}),
...(execution.terminalOnly ? { terminalOnly: true } : {}),
vocabulary
})
// The create's startup terminal is this launch's only surface, and the create delivers the text.
@@ -320,7 +319,7 @@ async function createSurface(
function ignoredStructuredAgentArgsWarning(
intent: AgentLaunchIntent
): { warning: string } | undefined {
return intent.agentArgs === undefined && intent.extraAgentArgs === undefined
return intent.agentArgs === undefined
? undefined
: {
warning:
@@ -328,6 +327,19 @@ 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.
+2 -12
View File
@@ -130,14 +130,13 @@ const HOST_SUPPORT_REASON: Record<
export function decideAgentLaunchMode(args: {
placement: AgentLaunchModePlacement
settings: AgentLaunchModeSettings | null | undefined
/** The caller asked for a terminal (its contract is a terminal handle), so the chat default
* cannot apply. */
/** Host-internal: the caller's contract is a terminal handle, so the chat default cannot apply. */
terminalOnly?: boolean
vocabulary?: AgentLaunchModeVocabulary
}): AgentLaunchModeReceipt {
const { placement, settings } = args
const vocabulary = args.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY
if (!isNativeChatEnabled(settings)) {
if (args.terminalOnly || !isNativeChatEnabled(settings)) {
return {
mode: 'terminal',
preferred: 'terminal',
@@ -145,15 +144,6 @@ export function decideAgentLaunchMode(args: {
detail: `Started ${vocabulary.terminal}, the default for new agent tabs in your settings.`
}
}
if (args.terminalOnly) {
// No reason word says "the caller asked" yet; a new one needs a capability older peers read.
return {
mode: 'terminal',
preferred: 'terminal',
reason: 'user_default',
detail: `Started ${vocabulary.terminal} because this launch asked for one.`
}
}
// A worker placed on another runtime starts through federation, which creates terminal agents
// only; this host cannot answer for that runtime's structured support.
if (placement.on) {
@@ -4,7 +4,7 @@
* implements, not part of the sequencing it runs.
*/
import type { AgentLaunchIntent, AgentLaunchPrompt } from '../../shared/agent-launch-intent'
import type { AgentLaunchPrompt } from '../../shared/agent-launch-intent'
import type { StructuredAgentId } from '../../shared/agent-session-provider-handle'
import type { TuiAgent } from '../../shared/tui-agent'
@@ -39,10 +39,6 @@ export type AgentLaunchSurfaceFactory = {
paneKey?: string
/** The tab's first view, derived on the host by the window's own rule. */
viewMode?: 'terminal' | 'chat'
/** As on the intent: the tab's title, saved extra arguments, and `agent_started.request_kind`. */
title?: AgentLaunchIntent['title']
extraAgentArgs?: AgentLaunchIntent['extraAgentArgs']
requestKind?: AgentLaunchIntent['requestKind']
}): Promise<{
handle: string
/** The pane this create minted; a factory whose runtime reports none omits it, never invents. */
@@ -125,10 +121,6 @@ export type AgentLaunchWorkspaceFactory = {
paneKey?: string
/** The launch's session options, read as the startup terminal's model/effort/mode preferences. */
options?: Readonly<Record<string, unknown>>
/** As on the intent: the tab's title, saved extra arguments, and `agent_started.request_kind`. */
title?: AgentLaunchIntent['title']
extraAgentArgs?: AgentLaunchIntent['extraAgentArgs']
requestKind?: AgentLaunchIntent['requestKind']
}): Promise<{
worktreeId: string
/** The new workspace's SSH connection; `null` is local. The executor carries it but nothing
@@ -148,19 +140,3 @@ export type AgentLaunchWorkspaceFactory = {
create: Readonly<Record<string, unknown>>
}): Promise<{ worktreeId: string; connectionId: string | null }>
}
/** What every route that builds a terminal agent passes on, so the startup terminal of a new
* workspace and the terminal of an existing one start the same agent. */
export function terminalLaunchInputs(intent: AgentLaunchIntent) {
return {
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
// `null` is a value the caller meant, so this tests for absence rather than falsiness.
...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}),
...(intent.cwd ? { cwd: intent.cwd } : {}),
...(intent.launchSource ? { launchSource: intent.launchSource } : {}),
...(intent.paneKey ? { paneKey: intent.paneKey } : {}),
...(intent.title ? { title: intent.title } : {}),
...(intent.extraAgentArgs ? { extraAgentArgs: intent.extraAgentArgs } : {}),
...(intent.requestKind ? { requestKind: intent.requestKind } : {})
}
}
@@ -1,10 +1,10 @@
/**
* The `agent_started` triple for an agent launch the host builds; `launch_source` and
* `request_kind` come from the caller.
* The `agent_started` triple for an agent launch the host builds, of which only `launch_source`
* comes from the caller.
*
* `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`.
* `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.
*
* 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,20 +13,18 @@
*/
import { tuiAgentToAgentKind } from '../../shared/agent-kind'
import { launchSourceSchema, requestKindSchema } from '../../shared/telemetry-property-schemas'
import { launchSourceSchema } 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,
requestKind?: string
launchSource: string | undefined
): NonNullable<WorktreeStartupLaunch['telemetry']> {
const parsed = launchSourceSchema.safeParse(launchSource)
const kind = requestKindSchema.safeParse(requestKind)
return {
agent_kind: tuiAgentToAgentKind(agent),
launch_source: parsed.success ? parsed.data : 'unknown',
request_kind: kind.success ? kind.data : 'new'
request_kind: 'new'
}
}
@@ -138,7 +138,6 @@ describe('runtime pty spawn preflight: requested shell on a local Windows host',
shellOverride: 'cmd.exe',
launchRuntime: {
platform: 'linux',
remote: false,
pty: { shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' }
}
})
+96 -39
View File
@@ -5,10 +5,12 @@ import type { Repo } from '../shared/repo-types'
import type { FolderWorkspace } from '../shared/folder-workspace-types'
import type { ProjectGroup } from '../shared/project-group-types'
import {
findLocalFolderWorkspaceRuntimeRepo,
resolveLocalWorkspaceExecutionRuntime
} from '../shared/local-workspace-execution-runtime'
findFolderWorkspaceCandidateRepos,
resolveFolderWorkspaceHost,
type FolderWorkspaceHostState
} from '../shared/folder-workspace-execution-host'
import {
getWorkspaceRuntimePreference,
resolveProjectExecutionRuntime,
type ProjectExecutionRuntimeResolution
} from '../shared/project-execution-runtime'
@@ -19,8 +21,10 @@ import {
hasCachedWslDistros
} from './wsl'
import { getRepoIdFromWorktreeId } from '../shared/worktree/id'
import { resolveLocalPtyRuntimeOptions } from '../shared/agent-launch-runtime'
import type { LocalWindowsTerminalRuntimeOptions } from '../shared/local-windows-terminal-runtime'
import {
resolveLocalWindowsTerminalRuntimeOptions,
type LocalWindowsTerminalRuntimeOptions
} from '../shared/local-windows-terminal-runtime'
import { resolveManagedSshHostLoginShell } from './pty/managed-ssh-host-login-shell'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
@@ -61,27 +65,20 @@ function resolveLocalProjectRuntime(
project: Pick<Project, 'id' | 'localWindowsRuntimePreference'>,
settings: ReturnType<ResolvableStore['getSettings']> = store.getSettings()
): ProjectExecutionRuntimeResolution {
const wslAvailable = hasCachedWslAvailability()
? (getCachedWslAvailability() ?? undefined)
: undefined
const availableWslDistros = hasCachedWslDistros() ? getCachedWslDistros() : null
return resolveProjectExecutionRuntime({
appPlatform: process.platform,
projectId: project.id,
projectRuntimePreference: project.localWindowsRuntimePreference,
globalWindowsRuntimeDefault: settings.localWindowsRuntimeDefault,
...cachedWslFacts()
wslAvailable,
availableWslDistros
})
}
function cachedWslFacts(): {
wslAvailable?: boolean
availableWslDistros: readonly string[] | null
} {
return {
wslAvailable: hasCachedWslAvailability()
? (getCachedWslAvailability() ?? undefined)
: undefined,
availableWslDistros: hasCachedWslDistros() ? getCachedWslDistros() : null
}
}
export function resolveLocalProjectRuntimeForRepo(
store: ProjectRuntimeResolutionStore,
repo: Repo
@@ -99,8 +96,8 @@ export function resolveLocalProjectRuntimeForRepo(
return resolveLocalProjectRuntime(store, project)
}
/** The window's runtime for a repo workspace (`resolveLocalWorkspaceExecutionRuntime`), read from
* main's store and WSL caches. Works for a repo with no project too. */
/** Same choice the renderer makes for a workspace: the project's runtime, else the distro of a
* WSL-share path, else the global default. Works for a repo with no project too. */
export function resolveLocalWorkspaceRuntime(
store: ProjectRuntimeResolutionStore,
repo: Repo,
@@ -112,14 +109,46 @@ export function resolveLocalWorkspaceRuntime(
) {
return undefined
}
return resolveLocalWorkspaceExecutionRuntime({
appPlatform: process.platform,
projects: store.getProjects(),
settings: store.getSettings(),
repoId: repo.id,
workspacePath,
...cachedWslFacts()
})
const project = store
.getProjects()
.find((entry) => entry.id === repo.id || entry.sourceRepoIds.includes(repo.id))
const preference = getWorkspaceRuntimePreference(
project?.localWindowsRuntimePreference,
workspacePath
)
const id = project?.id ?? repo.id
// A `\\wsl$` path proves its distro exists, and main's distro cache can be stale; the window
// checks such a path against no cache either.
return !project?.localWindowsRuntimePreference && preference.kind === 'wsl'
? resolveProjectExecutionRuntime({
appPlatform: process.platform,
projectId: id,
projectRuntimePreference: preference,
globalWindowsRuntimeDefault: store.getSettings().localWindowsRuntimeDefault
})
: resolveLocalProjectRuntime(store, { id, localWindowsRuntimePreference: preference })
}
/** The one local repo whose project runs a folder workspace on this machine; none for a folder
* pinned to another host or spanning several repos. */
export function findLocalFolderWorkspaceRuntimeRepo(
state: FolderWorkspaceHostState,
folderWorkspaceId: string
): Repo | undefined {
const folder = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
if (!folder || resolveFolderWorkspaceHost(state, folder.id).kind !== 'local') {
return undefined
}
const group = state.projectGroups.find((entry) => entry.id === folder.projectGroupId)
if (
(folder.executionHostId ?? group?.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !==
LOCAL_EXECUTION_HOST_ID
) {
return undefined
}
const candidates = findFolderWorkspaceCandidateRepos(state, folder.id)
const repo = candidates.length === 1 ? candidates[0] : undefined
return repo && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID ? repo : undefined
}
export type LaunchScopeRuntimeStore = ProjectRuntimeResolutionStore & {
@@ -128,8 +157,8 @@ export type LaunchScopeRuntimeStore = ProjectRuntimeResolutionStore & {
getProjectGroups?: () => ProjectGroup[]
}
/** 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. */
/** A launch scope's runtime, as the window reads it for a workspace. A remote scope has none: its
* host plans for itself. */
export function resolveLocalLaunchScopeRuntime(
store: LaunchScopeRuntimeStore,
scope: {
@@ -207,19 +236,47 @@ export function resolveLocalProjectRuntimeForWorktreeId(
return repo ? resolveLocalProjectRuntimeForRepo(store, repo) : undefined
}
/** A plain terminal's PTY on this host: the spawn's rule when no agent plan brought its own. */
/** A local PTY's shell and distro. Throws for a runtime that needs repair. */
export function resolveLocalPtyOptions(
settings:
| Partial<
Pick<
GlobalSettings,
'terminalWindowsShell' | 'terminalWindowsWslDistro' | 'terminalDefaultShell'
>
>
| undefined,
projectRuntime: ProjectExecutionRuntimeResolution | undefined,
requestedShellOverride: string | undefined,
/** Replaces a `wsl.exe` setting on a Windows-host runtime; absent is `powershell.exe`. */
fallbackHostShell?: string
): LocalWindowsTerminalRuntimeOptions {
return process.platform === 'win32'
? resolveLocalWindowsTerminalRuntimeOptions({
requestedShellOverride,
settings,
projectRuntime,
fallbackHostShell,
sshLoginShell: resolveManagedSshHostLoginShell()
})
: {
shellOverride: requestedShellOverride ?? (settings?.terminalDefaultShell || undefined),
terminalWindowsWslDistro: null
}
}
/** A plain terminal's PTY: the project-only runtime and `COMSPEC`, as the spawn picks when no
* agent plan brought its own. */
export function resolvePlainTerminalPtyOptions(
store: ProjectRuntimeResolutionStore | undefined,
settings: Parameters<typeof resolveLocalPtyRuntimeOptions>[0]['settings'],
settings: Parameters<typeof resolveLocalPtyOptions>[0],
worktreeId: string | undefined,
requestedShellOverride: string | undefined
): LocalWindowsTerminalRuntimeOptions {
return resolveLocalPtyRuntimeOptions({
hostPlatform: process.platform,
requestedShellOverride,
return resolveLocalPtyOptions(
settings,
projectRuntime: resolveLocalProjectRuntimeForWorktreeId(store, worktreeId),
fallbackHostShell: process.env.COMSPEC || 'powershell.exe',
sshLoginShell: resolveManagedSshHostLoginShell()
})
resolveLocalProjectRuntimeForWorktreeId(store, worktreeId),
requestedShellOverride,
process.env.COMSPEC || 'powershell.exe'
)
}
@@ -6,8 +6,8 @@ import {
} from '../../shared/execution-host'
import { resolveFolderWorkspaceHost } from '../../shared/folder-workspace-execution-host'
import type { Repo } from '../../shared/repo-types'
import { findLocalFolderWorkspaceRuntimeRepo } from '../../shared/local-workspace-execution-runtime'
import {
findLocalFolderWorkspaceRuntimeRepo,
resolveLocalWorkspaceRuntime,
type LaunchScopeRuntimeStore
} from '../local-project-runtime-resolution'
@@ -21,7 +21,7 @@ const scope = (
path: string,
kind: { repo?: Repo; folder?: FolderWorkspace }
): TerminalWorkspaceLaunchScope => ({
id: 'w',
id: kind.repo ? 'r' : 'f',
path,
connectionId: null,
repo: kind.repo ?? null,
@@ -29,6 +29,7 @@ const scope = (
})
function store(args: {
repos?: Repo[]
/** Repo `r`'s project runtime. */
project?: unknown
global?: unknown
folder?: FolderWorkspace
@@ -36,17 +37,13 @@ function store(args: {
}) {
const fake = {
getProjects: () => [
{
id: 'p',
sourceRepoIds: ['r'],
localWindowsRuntimePreference: args.project
}
{ id: 'p', sourceRepoIds: ['r'], localWindowsRuntimePreference: args.project }
],
getSettings: () => ({
terminalWindowsShell: args.shell ?? 'cmd.exe',
localWindowsRuntimeDefault: args.global
}),
getRepo: () => args.repos?.[0],
getRepo: (id: string) => (id === 'r' ? repo(WIN) : undefined),
getRepos: () => args.repos ?? [],
getFolderWorkspaces: () => (args.folder ? [args.folder] : []),
getProjectGroups: () => [{ id: 'g', name: 'g', parentPath: null, connectionId: null }]
@@ -75,13 +72,7 @@ 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',
'repo whose project follows a WSL global default',
store({ global: UBUNTU }),
scope(WIN, { repo: repo(WIN) }),
'linux'
@@ -101,8 +92,7 @@ describe("the window's runtime rule, on the host", () => {
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)
})
@@ -115,45 +105,48 @@ it("answers from main's WSL cache, which the window may not have read yet", () =
resolveAgentLaunchRuntime(
store({ project: UBUNTU }),
scope(WIN, { repo: repo(WIN) }),
undefined,
'host-legacy'
undefined
)
).toThrow('wsl-distro-missing')
// A `\\wsl$` path proves its distro, so a stale cache never refuses it.
expect(
resolveAgentLaunchRuntime(store({}), scope(SHARE, { repo: repo(SHARE) }), undefined).platform
).toBe('linux')
// A repo-less `\\wsl$` folder has no runtime to repair, as in the window: its path places it.
expect(
resolveAgentLaunchRuntime(
store({}),
scope(SHARE, { folder: folder(SHARE) }),
undefined,
'host-legacy'
).platform
resolveAgentLaunchRuntime(store({}), scope(SHARE, { folder: folder(SHARE) }), undefined)
.platform
).toBe('linux')
})
// Relied on by C2: the pane's pty:spawn takes the fact as is, for quoting and shell alike.
it('quotes for the shell the PTY gets, not a setting the runtime replaced', () => {
const windowsHost = store({ project: { kind: 'windows-host' }, shell: 'wsl.exe' })
const local = resolveAgentLaunchRuntime(
windowsHost,
scope(WIN, { repo: repo(WIN) }),
undefined,
'host-legacy'
)
const local = resolveAgentLaunchRuntime(windowsHost, scope(WIN, { repo: repo(WIN) }), undefined)
expect(local.pty.shellOverride).toBe('powershell.exe')
expect(agentLaunchQuotingShell(local)).toBe('powershell')
})
// TEMPORARY until C8, which decides how to model a global default nobody chose.
it('keeps a WSL Terminal shell where no project chose a runtime, until C8', () => {
const s = store({ global: { kind: 'windows-host' }, shell: 'wsl.exe' })
const launchScope = scope(WIN, { repo: repo(WIN) })
const legacy = resolveAgentLaunchRuntime(s, launchScope, undefined, 'host-legacy')
expect([legacy.platform, legacy.pty.shellOverride, agentLaunchQuotingShell(legacy)]).toEqual([
'win32',
'wsl.exe',
'posix'
])
expect(resolveAgentLaunchRuntime(s, launchScope, undefined, 'window').pty.shellOverride).toBe(
'powershell.exe'
)
})
// TEMPORARY, removed by the WSL-shell default decision (STA-10231). Only folders reach it: main
// finds no project runtime for a folder, while every local repo has a project.
it.each([['follows the global default'], ['is set to Windows host', { kind: 'windows-host' }]])(
"keeps a folder's WSL Terminal shell where its repo's project %s",
(_name, project?: unknown) => {
const legacy = resolveAgentLaunchRuntime(
store({
project,
global: { kind: 'windows-host' },
shell: 'wsl.exe',
repos: [repo(WIN)],
folder: folder('C:\\work')
}),
scope('C:\\work', { folder: folder('C:\\work') }),
undefined
)
expect([legacy.platform, legacy.pty.shellOverride, agentLaunchQuotingShell(legacy)]).toEqual([
'win32',
'wsl.exe',
'posix'
])
}
)
+49 -47
View File
@@ -1,18 +1,17 @@
import type { Repo } from '../../shared/repo-types'
import {
deriveAgentLaunchRuntime,
agentLaunchQuotingShell,
executionRuntimePlatform,
remoteExecutionPlatform,
type AgentLaunchRuntime
} from '../../shared/agent-launch-runtime'
import { getRepoSshConnectionId } from '../../shared/execution-host'
import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import {
resolveLocalLaunchScopeRuntime,
resolveLocalProjectRuntimeForWorktreeId,
resolveLocalPtyOptions,
type LaunchScopeRuntimeStore
} from '../local-project-runtime-resolution'
import { isWslShellName } from '../../shared/local-windows-terminal-runtime'
import { resolveManagedSshHostLoginShell } from '../pty/managed-ssh-host-login-shell'
import type { RuntimeStore } from './runtime-store-contract'
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
@@ -34,65 +33,68 @@ export function agentLaunchPlatformForScope(
}
/**
* TEMPORARY, removed by C8. `host-legacy` keeps today's host outcome for a Windows scope with no
* project runtime of its own whose global default is the Windows host: a `wsl.exe` Terminal shell
* still runs it in WSL. An explicit "Windows host" default cannot yet be told apart from the
* shipped one. `window` is the "+" button's rule.
* The runtime an agent launched here runs in: the window's rule, except where main's host had no
* runtime for this scope and the rule answers "Windows host". There it keeps main's spawn in the
* Terminal shell, since an explicit Windows-host default can't be told from the shipped one.
* TEMPORARY (reachable only for folders): removed by the WSL-shell default decision, STA-10231.
*/
export type WslShellRule = 'host-legacy' | 'window'
export function agentLaunchProjectRuntime(
store: LaunchScopeRuntimeStore,
scope: TerminalWorkspaceLaunchScope
): ProjectExecutionRuntimeResolution | undefined {
const runtime = resolveLocalLaunchScopeRuntime(store, scope)
return runtime?.status === 'resolved' &&
runtime.runtime.kind === 'windows-host' &&
!resolveLocalProjectRuntimeForWorktreeId(store, scope.id)
? undefined
: runtime
}
/** 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,
wslShellRule: WslShellRule
requestedShellOverride: string | undefined
): AgentLaunchRuntime {
const settings = store.getSettings()
const projectRuntime = resolveLocalLaunchScopeRuntime(store, scope)
const keepsWslShell =
wslShellRule === 'host-legacy' &&
process.platform === 'win32' &&
projectRuntime?.status === 'resolved' &&
projectRuntime.runtime.kind === 'windows-host' &&
projectRuntime.runtime.reason === 'global-default' &&
isWslShellName(settings.terminalWindowsShell) &&
!resolveLocalProjectRuntimeForWorktreeId(store, scope.id)
return deriveAgentLaunchRuntime({
hostPlatform: process.platform,
remote: Boolean(scope.connectionId),
workspacePath: scope.path,
// With no runtime, the spawn runs the Terminal shell, as main's host did for these scopes.
projectRuntime: keepsWslShell ? undefined : projectRuntime,
requestedShellOverride,
settings,
sshLoginShell: resolveManagedSshHostLoginShell()
})
if (scope.connectionId) {
return {
platform: remoteExecutionPlatform(scope.path),
pty: { shellOverride: undefined, terminalWindowsWslDistro: null }
}
}
const projectRuntime = agentLaunchProjectRuntime(store, scope)
return {
platform: executionRuntimePlatform(projectRuntime, scope.path, process.platform),
pty: resolveLocalPtyOptions(store.getSettings(), projectRuntime, requestedShellOverride)
}
}
/**
* The fact for an agent a new workspace of this repo starts, built before the workspace exists. A
* runtime that needs repair returns its error with the platform to quote for, so the create goes on
* and the startup fails with that error as its spawn would.
* The fact for a startup agent, planned before its terminal is created. A runtime that needs repair
* gives its error instead, so a worktree create goes on and the startup fails with that error as its
* spawn would; that startup is quoted for the Settings shell.
*/
export function resolveRepoAgentLaunchRuntime(
export function resolveStartupAgentLaunchRuntime(
store: AgentLaunchRuntimeStore,
repo: Repo
): AgentLaunchRuntime | { platform: NodeJS.Platform; repairError: Error } {
const scope = {
id: repo.id,
path: repo.path,
connectionId: getRepoSshConnectionId(repo),
repo,
folderWorkspace: null
}
scope: TerminalWorkspaceLaunchScope
): {
platform: NodeJS.Platform
shell: AgentStartupShell | undefined
launchRuntime: AgentLaunchRuntime | Error
} {
try {
return resolveAgentLaunchRuntime(store, scope, undefined, 'host-legacy')
const launchRuntime = resolveAgentLaunchRuntime(store, scope, undefined)
return {
platform: launchRuntime.platform,
shell: agentLaunchQuotingShell(launchRuntime),
launchRuntime
}
} catch (error) {
return {
platform: agentLaunchPlatformForScope(store, scope),
repairError: error instanceof Error ? error : new Error(String(error))
shell: undefined,
launchRuntime: error instanceof Error ? error : new Error(String(error))
}
}
}
@@ -8,6 +8,7 @@ import {
} from '../../shared/runtime-navigation'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { Repo } from '../../shared/repo-types'
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts'
import type {
@@ -160,7 +161,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
...(requestedAgent ? { requestedAgent } : {}),
...(launchSource ? { launchSource } : {}),
settings: this.store.getSettings(),
getLaunchRuntime: () => this.resolveRepoAgentLaunchRuntime(repo)
getLaunchRuntime: () => this.resolveStartupAgentLaunchRuntime(repo)
})
}
@@ -173,8 +174,9 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
agentArgs?: string | null
extraAgentArgs?: string
launchSource?: string
requestKind?: string
onPromptCarry?: (carried: boolean) => void
/** The existing workspace it starts in; absent is a new workspace of `repo`. */
launchScope?: TerminalWorkspaceLaunchScope
}
): ReturnType<typeof buildWorktreeStartupForAgent> {
if (!this.store) {
@@ -188,10 +190,10 @@ 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(),
getLaunchRuntime: () => this.resolveRepoAgentLaunchRuntime(repo),
getLaunchRuntime: () =>
this.resolveStartupAgentLaunchRuntime(repo, launchInputs?.launchScope),
toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences)
})
}
@@ -78,6 +78,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
: null
const effectiveStartup = args.startup ?? agentStartup?.startup ?? draftStartup?.startup
const effectiveStartupFollowup = agentStartup?.followup
// The repo's fact: the startup is planned before the new worktree's path exists.
const startupLaunchRuntime = args.startup
? undefined
: (agentStartup ?? draftStartup)?.launchRuntime
@@ -19,8 +19,8 @@ import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-termi
import { resolveStartupShell, type AgentStartupShell } from '../../shared/tui-agent-startup-shell'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { resolveConfiguredWorkerAgent } from './orchestration/configured-worker-agent-selector'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { resolveLocalProjectRuntimeForRepo } from '../project-runtime-git-options'
import { resolveAgentLaunchRuntime } from './agent-launch-scope-runtime'
import { resolveWslSessionContext } from '../daemon/wsl-session-context'
import { prepareOpenCodeModelStartupInputs } from '../opencode/opencode-model-startup-plan'
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
@@ -244,29 +244,24 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd
return false
}
const store = this.requireStore()
const settings = store.getSettings()
const path = workspace?.path
const unc = path ? parseWslUncPath(path) : null
const projectRuntime = executionRepo
? resolveLocalProjectRuntimeForRepo(store, executionRepo)
: null
if (projectRuntime?.status === 'repair-required') {
return false
}
const wsl = unc
? { distro: unc.distro }
: projectRuntime?.runtime.kind === 'wsl'
? { distro: projectRuntime.runtime.distro }
: undefined
if (!path) {
return false
}
// The launch's own runtime, so the probe checks the machine the agent will run on.
let launchRuntime
try {
launchRuntime = resolveAgentLaunchRuntime(store, workspace, undefined)
} catch {
return false
}
const wsl = resolveWslSessionContext({ cwd: path, ...launchRuntime.pty })
try {
await prepareOpenCodeModelStartupInputs({
inputs: resolveAgentStartupPlanInputs({
agent: 'opencode',
settings,
platform: wsl ? 'linux' : process.platform,
settings: store.getSettings(),
platform: launchRuntime.platform,
isRemote: false,
// A yes/no model probe; nothing is typed into a shell.
shell: undefined,
@@ -174,14 +174,19 @@ export class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh extends OrcaRu
ReturnType<OrcaRuntimeService['resolveMobileSessionTerminalCommand']>
> = {}
if (tab.launchAgent) {
let workspace
try {
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
agentStartup = await this.resolveMobileSessionTerminalCommand(workspace, {
agent: tab.launchAgent
})
} catch {
// Why: a disabled or unresolvable agent must not make the tab
// untappable; fall back to the plain-shell materialize.
// untappable; fall back to the plain-shell materialize. A runtime that needs repair
// rethrows its message here instead of leaving a plain shell under the agent icon.
if (workspace) {
this.resolveAgentLaunchRuntime(workspace)
}
}
}
try {
@@ -4,7 +4,7 @@ import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './orca-ru
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration'
import { seedStructuredAgentSessionTabIndex } from './structured-agent-session-tab-index-seed'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { getRepoSshConnectionId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type {
RuntimeMobileSessionAgentTab,
RuntimeMobileSessionTabsSnapshot,
@@ -21,7 +21,7 @@ import {
import {
agentLaunchPlatformForScope,
resolveAgentLaunchRuntime,
resolveRepoAgentLaunchRuntime
resolveStartupAgentLaunchRuntime
} from './agent-launch-scope-runtime'
import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime'
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
@@ -272,21 +272,24 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
return agentLaunchPlatformForScope(this.requireStore(), scope)
}
protected resolveAgentLaunchRuntime(
scope: TerminalWorkspaceLaunchScope,
requestedShellOverride?: string
): AgentLaunchRuntime {
return resolveAgentLaunchRuntime(
this.requireStore(),
scope,
requestedShellOverride,
'host-legacy'
)
protected resolveAgentLaunchRuntime(scope: TerminalWorkspaceLaunchScope): AgentLaunchRuntime {
return resolveAgentLaunchRuntime(this.requireStore(), scope, undefined)
}
protected resolveRepoAgentLaunchRuntime(
repo: Repo
): ReturnType<typeof resolveRepoAgentLaunchRuntime> {
return resolveRepoAgentLaunchRuntime(this.requireStore(), repo)
/** A startup's fact: the workspace it starts in, else (a create, before it exists) the repo. */
protected resolveStartupAgentLaunchRuntime(
repo: Repo,
scope?: TerminalWorkspaceLaunchScope
): ReturnType<typeof resolveStartupAgentLaunchRuntime> {
return resolveStartupAgentLaunchRuntime(
this.requireStore(),
scope ?? {
id: repo.id,
path: repo.path,
connectionId: getRepoSshConnectionId(repo),
repo,
folderWorkspace: null
}
)
}
}
@@ -234,6 +234,7 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi
targetGroupId: opts.targetGroupId,
supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement,
launchConfig: startupCommand.launchConfig,
launchRuntime: startupCommand.launchRuntime,
signal: opts.signal
}
)
@@ -166,13 +166,10 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC
if (!repo) {
throw new Error('Repository for the selected workspace is no longer available.')
}
const startup = this.buildStartupForAgent(
repo,
opts.agent,
opts.prompt,
undefined,
opts.extraAgentArgs ? { extraAgentArgs: opts.extraAgentArgs } : undefined
)
const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt, undefined, {
...(opts.extraAgentArgs ? { extraAgentArgs: opts.extraAgentArgs } : {}),
launchScope: await this.resolveTerminalWorkspaceLaunchScope(`id:${worktree.id}`)
})
if (startup.launchRuntime instanceof Error) {
throw startup.launchRuntime
}
@@ -54,11 +54,7 @@ async function agentLaunchIntent(
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.launchSource ? { launchSource: params.launchSource } : {}),
...(paneKey ? { paneKey } : {}),
...(params.sessionId ? { sessionId: params.sessionId } : {}),
...(params.title ? { title: params.title } : {}),
...(params.extraAgentArgs ? { extraAgentArgs: params.extraAgentArgs } : {}),
...(params.requestKind ? { requestKind: params.requestKind } : {}),
...(params.terminalOnly ? { terminalOnly: true } : {})
...(params.sessionId ? { sessionId: params.sessionId } : {})
}
}
@@ -128,10 +128,7 @@ export function agentLaunchSurfaceFactory(
launchSource,
paneKey,
options,
viewMode,
title,
extraAgentArgs,
requestKind
viewMode
}) => {
const launchPreferences = toAgentLaunchPreferences(options)
let promptRodeLaunchCommand = false
@@ -160,9 +157,6 @@ 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
@@ -177,8 +177,7 @@ export async function publishAgentLaunchTabEarly(
workspacePath: workspace.path,
...(params.cwd ? { cwd: params.cwd } : {})
},
settings,
...(params.terminalOnly ? { terminalOnly: true } : {})
settings
})
// A chat's tab is the session's; it appears when the session is created.
if (preflight.mode !== 'terminal') {
@@ -47,10 +47,7 @@ export function agentLaunchWorkspaceFactory(
cwd,
launchSource,
paneKey,
options,
title,
extraAgentArgs,
requestKind
options
}) => {
const startupLaunchPreferences = toAgentLaunchPreferences(options)
let promptRodeLaunchCommand = false
@@ -96,9 +93,6 @@ 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
+2 -3
View File
@@ -9,9 +9,8 @@
* 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 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 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 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
@@ -17,13 +17,12 @@ const host = {
}
const CMD: AgentLaunchRuntime = {
platform: 'win32',
remote: false,
pty: { shellOverride: 'cmd.exe', terminalWindowsWslDistro: 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.
// The host planner's inputs. Relied on by C2, whose pane spawns run this plan.
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' })
@@ -38,36 +37,6 @@ describe('host agent planner', () => {
expect(own.launchRuntime).toBeUndefined()
})
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 platform = process.platform
@@ -77,7 +46,6 @@ describe('host agent planner', () => {
{ startupAgent: 'qoder', agentArgs: null },
{
platform: 'linux',
remote: false,
pty: { shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Ubuntu' }
}
)
@@ -10,10 +10,7 @@ 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 { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution'
import {
buildExecutionHostAgentStartupPlan,
planExecutionHostStartupWithPromptCandidate
} from '../opencode/opencode-model-startup-plan'
import { planExecutionHostStartupWithPromptCandidate } from '../opencode/opencode-model-startup-plan'
import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup'
import {
launchHostProvesAgentInFront,
@@ -24,17 +21,16 @@ import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-i
import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry'
import { terminalShellOverrideRefusal } from './terminal-shell-override-host-support'
import {
resolveLocalLaunchScopeRuntime,
resolveLocalProjectRuntimeForWorktreeId,
resolvePlainTerminalPtyOptions
} from '../local-project-runtime-resolution'
import {
agentLaunchPlatformForScope,
agentLaunchProjectRuntime,
resolveAgentLaunchRuntime
} from './agent-launch-scope-runtime'
import { resolveQoderTerminalCommand } from './qoder-terminal-command-resolution'
import { resolveStartupShell } from '../../shared/tui-agent-startup-shell'
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
import { resolveWslSessionContext } from '../daemon/wsl-session-context'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
@@ -85,7 +81,7 @@ export async function resolveAgentTerminalCreateOptions(
projectRuntime:
opts.shellOverride && store
? plannedAgent || opts.launchRuntime
? resolveLocalLaunchScopeRuntime(store, workspace)
? agentLaunchProjectRuntime(store, workspace)
: resolveLocalProjectRuntimeForWorktreeId(store, workspace.id)
: undefined,
cwd: resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path,
@@ -112,14 +108,7 @@ export async function resolveAgentTerminalCreateOptions(
}
const runtime =
opts.launchRuntime ??
(plannedAgent
? resolveAgentLaunchRuntime(
store,
workspace,
opts.shellOverride,
opts.wslShellRule ?? 'host-legacy'
)
: undefined)
(plannedAgent ? resolveAgentLaunchRuntime(store, workspace, opts.shellOverride) : undefined)
const launch =
plannedAgent && runtime
? await buildRuntimeAgentTerminalStartupOptions(
@@ -157,40 +146,30 @@ async function buildRuntimeAgentTerminalStartupOptions(
if (opts.startupPrompt && !agentPromptRidesLaunchCommand(agent)) {
throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`)
}
const startup = {
const { plan: startupPlan, promptCarried } = await planExecutionHostStartupWithPromptCandidate({
inputs: resolveAgentStartupPlanInputs({
agent,
settings,
platform: runtime.platform,
isRemote,
...(opts.agentArgs !== undefined ? { agentArgs: opts.agentArgs } : {}),
...(opts.extraAgentArgs ? { extraAgentArgs: opts.extraAgentArgs } : {}),
shell: agentLaunchQuotingShell(runtime),
sessionOptions: host.toSessionOptions(opts.launchPreferences)
}),
prompt: opts.startupPrompt ?? '',
cwd: resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path,
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.pty.shellOverride ? { shellOverride: runtime.pty.shellOverride } : {})
}),
provesAgentInFront: launchHostProvesAgentInFront({
isRemote,
launchPlatform: runtime.platform
})
}
})
hostIdentity: host.hostIdentity,
host: {
shellName: nameLocalTypedLineShell({
isRemote,
...(runtime.pty.shellOverride ? { shellOverride: runtime.pty.shellOverride } : {})
}),
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.
@@ -202,11 +181,6 @@ 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,
@@ -214,11 +188,9 @@ async function buildRuntimeAgentTerminalStartupOptions(
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
launchAgent: agent,
startupCommandDelivery,
startupCommandDelivery: startupPlan.startupCommandDelivery,
// A bare command the user typed stays out of launch accounting, as before.
...(opts.startupAgent
? { telemetry: agentStartedTelemetry(agent, opts.launchSource, opts.requestKind) }
: {})
...(opts.startupAgent ? { telemetry: agentStartedTelemetry(agent, opts.launchSource) } : {})
}
}
@@ -238,29 +210,24 @@ function resolveQoderLaunchCommand(
shell: resolveStartupShell(remoteExecutionPlatform(workspace.path))
})
}
const settings = store.getSettings()
const pty =
runtime?.pty ??
resolvePlainTerminalPtyOptions(store, settings, workspace.id, launch.shellOverride)
// A command its caller quoted runs under the spawn's own rule; its tokens are what is searched.
const fact = runtime ?? {
platform: agentLaunchPlatformForScope(store, workspace),
pty: resolvePlainTerminalPtyOptions(
store,
store.getSettings(),
workspace.id,
launch.shellOverride
)
}
// Where the process runs: a `\\wsl$` cwd first, then a WSL shell, as the spawn decides it.
const wsl = resolveWslSessionContext({
cwd: resolveTerminalStartupCwd(workspace.path, launch.cwd) ?? workspace.path,
shellOverride: pty.shellOverride,
terminalWindowsWslDistro: pty.terminalWindowsWslDistro
shellOverride: fact.pty.shellOverride,
terminalWindowsWslDistro: fact.pty.terminalWindowsWslDistro
})
const platform = runtime?.platform ?? agentLaunchPlatformForScope(store, workspace)
return resolveQoderTerminalCommand(launch, {
context: wsl ? { wslDistro: wsl.distro } : {},
// A caller-quoted command was quoted by the Settings shell, as the window quotes.
shell: runtime
? agentLaunchQuotingShell(runtime)
: resolveStartupShell(
platform,
resolveLocalWindowsAgentStartupShell({
platform,
isRemote: false,
terminalWindowsShell: launch.shellOverride ?? settings.terminalWindowsShell
})
)
shell: agentLaunchQuotingShell(fact)
})
}
@@ -142,7 +142,6 @@ 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 } : {}),
@@ -104,7 +104,6 @@ export async function startRuntimeLocalWorktreeTerminals(args: {
const terminal = await ports.createTerminal(`id:${worktree.id}`, {
command: sequencedStartup.command,
...(request.startupCwd ? { cwd: request.startupCwd } : {}),
...(request.startupTitle ? { title: request.startupTitle } : {}),
...paneIdentity(request.startupPaneKey),
...(setup && startup ? { claudeAgentTeamsSourceCommand: startup.command } : {}),
env: sequencedStartup.env,
@@ -61,12 +61,8 @@ export type RuntimeManagedWorktreeCreateArgs = {
/** Main-internal: an automation's saved extras, merged over the startup agent's arguments. */
startupExtraAgentArgs?: string
startupCwd?: string
/** The startup terminal's tab title. */
startupTitle?: string
/** The surface behind a host-built startup agent (`startupAgent` or `startupDraft`). */
startupLaunchSource?: string
/** `agent_started.request_kind` for a host-built `startupAgent`; absent is `new`. */
startupRequestKind?: string
/** A caller-minted `tabId:leafId` for the startup terminal's pane. */
startupPaneKey?: string
pendingFirstAgentMessageRename?: boolean
@@ -1,5 +1,4 @@
import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime'
import type { WslShellRule } from './agent-launch-scope-runtime'
import type { SpawnIntent } from '../../shared/host-spawn-admission'
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
import type {
@@ -66,22 +65,6 @@ 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
/** Launch-convergence parity: absent is `host-legacy`, today's host WSL-shell outcome. Dies in
* C8 (`WslShellRule`). */
wslShellRule?: WslShellRule
/** Main-internal: the runtime fact this agent's command was quoted for; its spawn takes the PTY
* shell from it. Set by the host planner or a host caller that quoted with it, never a client. */
launchRuntime?: AgentLaunchRuntime
@@ -33,11 +33,11 @@ function makeRepo(fields: Partial<Repo>): Repo {
}
}
const LINUX: AgentLaunchRuntime = {
const LINUX_FACT: AgentLaunchRuntime = {
platform: 'linux',
remote: false,
pty: { shellOverride: undefined, terminalWindowsWslDistro: null }
}
const LINUX = { platform: 'linux' as const, shell: 'posix' as const, launchRuntime: LINUX_FACT }
const settings = {
...getDefaultSettings('/tmp'),
@@ -123,7 +123,6 @@ describe('buildWorktreeStartupForAgent host resolution', () => {
it('quotes a worktree startup for its fact, not the Settings shell, and hands the fact on', () => {
const powershell: AgentLaunchRuntime = {
platform: 'win32',
remote: false,
pty: { shellOverride: 'powershell.exe', terminalWindowsWslDistro: null }
}
const result = buildWorktreeStartupForAgent({
@@ -131,7 +130,11 @@ it('quotes a worktree startup for its fact, not the Settings shell, and hands th
settings: { ...settings, terminalWindowsShell: 'wsl.exe' },
agent: 'claude',
prompt: "fix Bob's bug",
getLaunchRuntime: () => powershell,
getLaunchRuntime: () => ({
platform: 'win32' as const,
shell: 'powershell' as const,
launchRuntime: powershell
}),
toSessionOptions: () => undefined
})
expect(result.startup.command).toContain("'fix Bob''s bug'")
@@ -147,7 +150,10 @@ describe('buildWorktreeStartupForAgent prompt carry', () => {
prompt: 'summarize the diff\nthen list the risks',
getLaunchRuntime: () => ({
...LINUX,
pty: { shellOverride: terminalDefaultShell, terminalWindowsWslDistro: null }
launchRuntime: {
...LINUX_FACT,
pty: { shellOverride: terminalDefaultShell, terminalWindowsWslDistro: null }
}
}),
toSessionOptions: () => undefined,
...(onPromptCarry ? { onPromptCarry } : {})
@@ -295,7 +301,7 @@ describe('buildWorktreeStartupForAgent extra agent args', () => {
const build = vi.fn(() => ({
agent: 'claude' as const,
startup: { command: 'claude' },
launchRuntime: LINUX
launchRuntime: LINUX_FACT
}))
const createArgs: RuntimeManagedWorktreeCreateArgs = {
repoSelector: 'repo-1',
@@ -8,7 +8,8 @@ import { getRepoSshConnectionId } from '../../shared/execution-host'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selection'
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
import { agentLaunchQuotingShell, type AgentLaunchRuntime } from '../../shared/agent-launch-runtime'
import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime'
import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup'
import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry'
import {
@@ -30,21 +31,19 @@ type AttributedWorktreeStartupLaunch = WorktreeStartupLaunch & {
type StartupEnvironment = {
repo: Repo
settings: ReturnType<RuntimeStore['getSettings']>
/** Where the agent runs; its command is quoted for this and the create spawns with it. A runtime
* that needs repair gives its error instead, which the startup spawn then fails with. */
getLaunchRuntime: () => AgentLaunchRuntime | { platform: NodeJS.Platform; repairError: Error }
/** Where the agent runs (`resolveStartupAgentLaunchRuntime`): what its command is quoted for, and
* the fact the create spawns with, or the repair error its startup fails with. */
getLaunchRuntime: () => {
platform: NodeJS.Platform
shell: AgentStartupShell | undefined
launchRuntime: AgentLaunchRuntime | Error
}
/** Replaces the configured arguments for this launch; `null` means none. */
agentArgs?: string | null
/** An automation's saved extras, merged over the launch's arguments. */
extraAgentArgs?: string
/** Caller-supplied telemetry attribution, validated leniently at the host boundary. */
launchSource?: string
requestKind?: string
}
/** A startup that will fail with its repair error is quoted for the Settings shell, as before. */
function startupQuotingShell(launchRuntime: AgentLaunchRuntime | Error) {
return launchRuntime instanceof Error ? undefined : agentLaunchQuotingShell(launchRuntime)
}
/** The agent a linked draft starts: the requested one, else the default, else one detected on the
@@ -94,13 +93,12 @@ export async function buildWorktreeStartupForDraft(
return null
}
const resolved = environment.getLaunchRuntime()
const launchRuntime = 'repairError' in resolved ? resolved.repairError : resolved
const { platform, shell, launchRuntime } = environment.getLaunchRuntime()
const launchArgs = resolveAgentStartupPlanInputs({
agent,
settings,
platform: resolved.platform,
shell: startupQuotingShell(launchRuntime),
platform,
shell,
isRemote: repoIsRemote(repo),
...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {})
})
@@ -167,13 +165,12 @@ export function buildWorktreeStartupForAgent(
if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) {
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
}
const resolved = environment.getLaunchRuntime()
const launchRuntime = 'repairError' in resolved ? resolved.repairError : resolved
const { platform, shell, launchRuntime } = environment.getLaunchRuntime()
const planInputs = resolveAgentStartupPlanInputs({
agent,
settings,
platform: resolved.platform,
shell: startupQuotingShell(launchRuntime),
platform,
shell,
isRemote: repoIsRemote(repo),
...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {}),
...(environment.extraAgentArgs ? { extraAgentArgs: environment.extraAgentArgs } : {}),
@@ -191,7 +188,7 @@ export function buildWorktreeStartupForAgent(
}),
provesAgentInFront: launchHostProvesAgentInFront({
isRemote: repoIsRemote(repo),
launchPlatform: resolved.platform
launchPlatform: platform
})
})
startupPlan = offered.plan
@@ -213,7 +210,7 @@ export function buildWorktreeStartupForAgent(
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
...(startupPlan.env ? { env: startupPlan.env } : {}),
telemetry: agentStartedTelemetry(agent, environment.launchSource, environment.requestKind)
telemetry: agentStartedTelemetry(agent, environment.launchSource)
},
launchRuntime,
...(startupPlan.followupPrompt
@@ -237,7 +234,6 @@ export function resolveWorktreeCreateAgentStartup(
agentArgs?: string | null
extraAgentArgs?: string
launchSource?: string
requestKind?: string
onPromptCarry?: (carried: boolean) => void
}
) => {
@@ -254,7 +250,6 @@ 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 } : {})
})
}
@@ -32,9 +32,10 @@ export function terminalShellOverrideRefusal(args: {
`--shell ${args.shellOverride} names a Windows shell, and this execution host is ${args.platform}, which spawns the user's login shell. No terminal was created; omit --shell.`
)
}
// The path first: no runtime setting can move a `\\wsl$` path out of WSL, so only it names a fix.
return (
projectRuntimeShellConflict(args.shellOverride, args.projectRuntime) ??
wslUncPathShellConflict(args.shellOverride, args.cwd, args.workspacePath)
wslUncPathShellConflict(args.shellOverride, args.cwd, args.workspacePath) ??
projectRuntimeShellConflict(args.shellOverride, args.projectRuntime)
)
}
@@ -59,15 +60,9 @@ function projectRuntimeShellConflict(
return null
}
// A workspace with no saved runtime of its own follows the default, so name that.
const where = runsInWsl ? 'in WSL' : 'on the Windows host'
const runs =
projectRuntime.runtime.reason === 'global-default'
? `This workspace runs its terminals ${where}, the default in Settings > Project Runtime`
: `This workspace's project runs its terminals ${where}`
const change =
projectRuntime.runtime.reason === 'global-default'
? 'that default'
: "the project's execution runtime"
const byDefault = projectRuntime.runtime.reason === 'global-default'
const runs = `${byDefault ? 'This workspace runs' : "This workspace's project runs"} its terminals ${runsInWsl ? 'in WSL' : 'on the Windows host'}${byDefault ? ', the default in Settings > Project Runtime' : ''}`
const change = byDefault ? 'that default' : "the project's execution runtime"
return new Error(
runsInWsl
? `${runs}, so --shell ${shellOverride} cannot be applied. No terminal was created. Use --shell wsl.exe, or change ${change}.`
+14 -6
View File
@@ -4,7 +4,6 @@ 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'
@@ -53,6 +52,19 @@ 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: {
@@ -70,11 +82,7 @@ function resolveHostPlatform(
return { kind: 'withheld', reason: HOST_UNRESOLVED_WITHHELD }
}
if (host.kind === 'local') {
const platform = executionRuntimePlatform(
args.projectRuntime(),
args.workspacePath,
CLIENT_PLATFORM
)
const platform = resolveLocalPlatform(args.projectRuntime(), args.workspacePath)
return { kind: 'known', platform, hostKind: 'local' }
}
const sshTargetId = host.kind === 'ssh' ? host.targetId : args.nestedSshTargetId
+42 -14
View File
@@ -3,11 +3,11 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { parseWslUncPath } from '../../../shared/wsl-paths'
import {
deriveGlobalWindowsRuntimeDefaultFromLegacySettings,
getWorkspaceRuntimePreference,
resolveProjectExecutionRuntime,
type ProjectExecutionRuntimeResolution
} from '../../../shared/project-execution-runtime'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { resolveLocalWorkspaceExecutionRuntime } from '../../../shared/local-workspace-execution-runtime'
import type { Repo } from '../../../shared/repo-types'
import type { Worktree } from '../../../shared/worktree/types'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
@@ -80,14 +80,22 @@ export function getLocalProjectExecutionRuntimeContext(
if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(worktree)) {
return undefined
}
return resolveLocalWorkspaceExecutionRuntime({
const projectId = getLocalPreflightProjectId(state, worktreeId)
const project = getLocalRuntimeProject(state, projectId, repo.id)
const projectRuntimePreference = getWorkspaceRuntimePreference(
project?.localWindowsRuntimePreference,
worktree?.path ?? repo?.path
)
return resolveProjectExecutionRuntime({
appPlatform,
projects: state.projects,
settings: state.settings,
repoId: repo.id,
projectId: getLocalPreflightProjectId(state, worktreeId),
workspacePath: worktree?.path ?? repo?.path,
...wslContext
projectId,
projectRuntimePreference,
globalWindowsRuntimeDefault:
state.settings?.localWindowsRuntimeDefault ??
deriveGlobalWindowsRuntimeDefaultFromLegacySettings(state.settings).defaultRuntime,
wslAvailable: wslContext.wslAvailable,
availableWslDistros: wslContext.availableWslDistros
})
}
@@ -130,13 +138,22 @@ export function getLocalRepoProjectExecutionRuntimeContext(
if (!isLocalRuntimeRepo(repo)) {
return undefined
}
return resolveLocalWorkspaceExecutionRuntime({
const project = getLocalRuntimeProject(state, repoId, repo.id)
const projectId = project?.id ?? repoId
const projectRuntimePreference = getWorkspaceRuntimePreference(
project?.localWindowsRuntimePreference,
repo?.path
)
return resolveProjectExecutionRuntime({
appPlatform,
projects: state.projects,
settings: state.settings,
repoId: repo.id,
workspacePath: repo.path,
...wslContext
projectId,
projectRuntimePreference,
globalWindowsRuntimeDefault:
state.settings?.localWindowsRuntimeDefault ??
deriveGlobalWindowsRuntimeDefaultFromLegacySettings(state.settings).defaultRuntime,
wslAvailable: wslContext.wslAvailable,
availableWslDistros: wslContext.availableWslDistros
})
}
@@ -295,6 +312,17 @@ function isLocalRuntimeWorktree(worktree?: Pick<Worktree, 'hostId'> | null): boo
return !worktree?.hostId || worktree.hostId === LOCAL_EXECUTION_HOST_ID
}
function getLocalRuntimeProject(
state: LocalProjectRuntimeState,
projectId: string,
repoId: string
) {
return state.projects?.find(
(entry) =>
entry.id === projectId || entry.id === repoId || entry.sourceRepoIds?.includes(repoId)
)
}
function getLocalPreflightProjectId(
state: LocalProjectRuntimeState,
worktreeId?: string | null
@@ -1,7 +1,10 @@
import type { AppState } from '@/store/types'
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { findLocalFolderWorkspaceRuntimeRepo } from '../../../shared/local-workspace-execution-runtime'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import {
findFolderWorkspaceCandidateRepos,
resolveFolderWorkspaceHost
} from '../../../shared/folder-workspace-execution-host'
import type { Worktree } from '../../../shared/worktree/types'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
@@ -30,9 +33,20 @@ export function getLocalProjectRuntimeWorkspace(
projectGroups: state.projectGroups ?? [],
repos: state.repos
}
const repo = findLocalFolderWorkspaceRuntimeRepo(folderState, scope.folderWorkspaceId)
const folder = folderState.folderWorkspaces.find((entry) => entry.id === scope.folderWorkspaceId)
return repo && folder
if (!folder || resolveFolderWorkspaceHost(folderState, folder.id).kind !== 'local') {
return null
}
const group = folderState.projectGroups.find((entry) => entry.id === folder.projectGroupId)
if (
(folder.executionHostId ?? group?.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !==
LOCAL_EXECUTION_HOST_ID
) {
return null
}
const candidates = findFolderWorkspaceCandidateRepos(folderState, folder.id)
const repo = candidates.length === 1 ? candidates[0] : undefined
return repo && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID
? {
id: targetWorktreeId,
repoId: repo.id,
+2 -9
View File
@@ -7,9 +7,9 @@
* resolves it once — settings default plus per-launch feasibility from
* `structured-native-chat-launch-route` — so no surface carries its own copy of that decision.
*
* The intent deliberately does NOT pick a chat. A caller states what it wants to happen, not how
* The intent deliberately does NOT name a mode. 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. `terminalOnly` only rules a chat out, for a caller whose surface is a terminal tab.
* receipt rather than requested here.
*/
import type { TuiAgent } from './tui-agent'
@@ -98,13 +98,6 @@ 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
}
/**
+1 -13
View File
@@ -29,11 +29,7 @@ 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
title?: string
requestKind?: string
}
params: AgentLaunchFingerprintInput & { launchSource?: string }
): string {
return computeAgentLaunchFingerprint(params)
}
@@ -84,14 +80,6 @@ describe('fields the launch fingerprint deliberately ignores', () => {
computeAgentLaunchFingerprint(BASE)
)
})
// Relied on by C2/C3/C7, which send these through agent.launchReplay.
it('ignores the tab title and request kind, but not the inputs that change what runs', () => {
const plain = fingerprintOfWirePayload(BASE)
expect(fingerprintOfWirePayload({ ...BASE, title: 'Fix', requestKind: 'resume' })).toBe(plain)
expect(fingerprintOfWirePayload({ ...BASE, extraAgentArgs: '--verbose' })).not.toBe(plain)
expect(fingerprintOfWirePayload({ ...BASE, terminalOnly: true })).not.toBe(plain)
})
})
describe('compatibility with rows written before these fields existed', () => {
+3 -10
View File
@@ -48,10 +48,6 @@ 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
@@ -60,9 +56,8 @@ 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`, `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.
* `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.
*/
}
@@ -80,9 +75,7 @@ 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,
extraAgentArgs: input.extraAgentArgs,
terminalOnly: input.terminalOnly
sessionId: input.sessionId
})
}
@@ -45,11 +45,6 @@ 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 to a host that advertises this.
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.
@@ -67,6 +62,5 @@ 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_TERMINAL_INPUTS_RUNTIME_CAPABILITY
AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY
] as const
+3 -61
View File
@@ -1,10 +1,6 @@
import type { GlobalSettings } from './global-settings-types'
import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime'
import { isWindowsAbsolutePathLike } from './cross-platform-path'
import {
resolveLocalWindowsTerminalRuntimeOptions,
type LocalWindowsTerminalRuntimeOptions
} from './local-windows-terminal-runtime'
import type { LocalWindowsTerminalRuntimeOptions } from './local-windows-terminal-runtime'
import { resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell'
import { resolveLocalWindowsAgentStartupShell } from './windows-terminal-shell'
import { isWslUncPath } from './wsl-paths'
@@ -17,7 +13,6 @@ import { isWslUncPath } from './wsl-paths'
export type AgentLaunchRuntime = {
/** `linux` for a WSL runtime. */
platform: NodeJS.Platform
remote: boolean
/** The spawn's own PTY options; empty for a remote host, which picks its own shell. */
pty: LocalWindowsTerminalRuntimeOptions
}
@@ -27,7 +22,8 @@ export function agentLaunchQuotingShell(runtime: AgentLaunchRuntime): AgentStart
runtime.platform,
resolveLocalWindowsAgentStartupShell({
platform: runtime.platform,
isRemote: runtime.remote,
// A remote fact names no shell, which quotes for its platform's default.
isRemote: false,
terminalWindowsShell: runtime.pty.shellOverride
})
)
@@ -52,57 +48,3 @@ export function executionRuntimePlatform(
export function remoteExecutionPlatform(workspacePath: string): NodeJS.Platform {
return isWindowsAbsolutePathLike(workspacePath) ? 'win32' : 'linux'
}
type LocalPtyInputs = {
hostPlatform: NodeJS.Platform
requestedShellOverride: string | undefined
settings:
| Partial<
Pick<
GlobalSettings,
'terminalWindowsShell' | 'terminalWindowsWslDistro' | 'terminalDefaultShell'
>
>
| undefined
projectRuntime: ProjectExecutionRuntimeResolution | undefined
/** The Windows shell that replaces a `wsl.exe` setting on a Windows-host runtime; absent is
* `powershell.exe`, as the window's tabs pick. */
fallbackHostShell?: string
sshLoginShell: string | undefined
}
/** A local PTY's shell and distro. Throws for a runtime that needs repair. */
export function resolveLocalPtyRuntimeOptions(
args: LocalPtyInputs
): LocalWindowsTerminalRuntimeOptions {
return args.hostPlatform === 'win32'
? resolveLocalWindowsTerminalRuntimeOptions({
requestedShellOverride: args.requestedShellOverride,
settings: args.settings,
projectRuntime: args.projectRuntime,
fallbackHostShell: args.fallbackHostShell,
sshLoginShell: args.sshLoginShell
})
: {
shellOverride:
args.requestedShellOverride ?? (args.settings?.terminalDefaultShell || undefined),
terminalWindowsWslDistro: null
}
}
export function deriveAgentLaunchRuntime(
args: LocalPtyInputs & { remote: boolean; workspacePath: string }
): AgentLaunchRuntime {
if (args.remote) {
return {
platform: remoteExecutionPlatform(args.workspacePath),
remote: true,
pty: { shellOverride: undefined, terminalWindowsWslDistro: null }
}
}
return {
platform: executionRuntimePlatform(args.projectRuntime, args.workspacePath, args.hostPlatform),
remote: false,
pty: resolveLocalPtyRuntimeOptions(args)
}
}
@@ -1,78 +0,0 @@
import type { Project } from './project-types'
import type { Repo } from './repo-types'
import {
findFolderWorkspaceCandidateRepos,
resolveFolderWorkspaceHost,
type FolderWorkspaceHostState
} from './folder-workspace-execution-host'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host'
import {
deriveGlobalWindowsRuntimeDefaultFromLegacySettings,
getWorkspaceRuntimePreference,
resolveProjectExecutionRuntime,
type LegacyWindowsRuntimeSettings,
type ProjectExecutionRuntimeResolution
} from './project-execution-runtime'
/** The one local repo whose project runs a folder workspace on this machine; none for a folder
* pinned to another host or spanning several repos. */
export function findLocalFolderWorkspaceRuntimeRepo(
state: FolderWorkspaceHostState,
folderWorkspaceId: string
): Repo | undefined {
const folder = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
if (!folder || resolveFolderWorkspaceHost(state, folder.id).kind !== 'local') {
return undefined
}
const group = state.projectGroups.find((entry) => entry.id === folder.projectGroupId)
if (
(folder.executionHostId ?? group?.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !==
LOCAL_EXECUTION_HOST_ID
) {
return undefined
}
const candidates = findFolderWorkspaceCandidateRepos(state, folder.id)
const repo = candidates.length === 1 ? candidates[0] : undefined
return repo && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID ? repo : undefined
}
/**
* A local repo workspace's runtime, read the same way by the window and the host: its project's
* saved runtime, else the distro of a WSL-share path, else the global default.
*/
export function resolveLocalWorkspaceExecutionRuntime(args: {
appPlatform: NodeJS.Platform
projects:
| readonly Pick<Project, 'id' | 'sourceRepoIds' | 'localWindowsRuntimePreference'>[]
| undefined
settings:
| (LegacyWindowsRuntimeSettings & { localWindowsRuntimeDefault?: unknown })
| null
| undefined
repoId: string
/** The workspace's own project; absent looks the project up by repo. */
projectId?: string
workspacePath: string | null | undefined
wslAvailable?: boolean
availableWslDistros?: readonly string[] | null
}): ProjectExecutionRuntimeResolution {
const project = args.projects?.find(
(entry) =>
entry.id === args.projectId ||
entry.id === args.repoId ||
entry.sourceRepoIds?.includes(args.repoId)
)
return resolveProjectExecutionRuntime({
appPlatform: args.appPlatform,
projectId: args.projectId ?? project?.id ?? args.repoId,
projectRuntimePreference: getWorkspaceRuntimePreference(
project?.localWindowsRuntimePreference,
args.workspacePath
),
globalWindowsRuntimeDefault:
args.settings?.localWindowsRuntimeDefault ??
deriveGlobalWindowsRuntimeDefaultFromLegacySettings(args.settings).defaultRuntime,
wslAvailable: args.wslAvailable,
availableWslDistros: args.availableWslDistros
})
}
+4 -14
View File
@@ -1,11 +1,10 @@
/**
* The wire shape of `agent.launch`, mirroring `AgentLaunchIntent`.
*
* 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`.
* 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`.
*
* 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
@@ -93,15 +92,6 @@ 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.
*