Files
orca/src/main/runtime/orca-runtime-activate-managed-worktree.ts
T
Brennan Benson f608a90f39 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).
2026-10-11 17:16:57 -07:00

345 lines
14 KiB
TypeScript

// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithListManagedWorktrees } from './orca-runtime-list-managed-worktrees'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import {
navigationTargetsClients,
navigationTargetsHost,
resolveRuntimeNavigationTarget
} from '../../shared/runtime-navigation'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { Repo } from '../../shared/repo-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type {
WorktreeStartupDraftPaste,
WorktreeStartupFollowup
} from './runtime-worktree-agent-startup'
import {
buildWorktreeStartupForAgent,
buildWorktreeStartupForDraft,
resolveWorktreeStartupDraftAgent
} from './runtime-worktree-agent-startup'
import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority'
import type { Worktree } from '../../shared/worktree/types'
import type { WorktreeLineageResolution } from './runtime-worktree-lineage-resolution'
import type {
WorkspaceLineage,
WorktreeLineage,
WorktreeLineageWarning
} from '../../shared/worktree/lineage-types'
import { recordCreatedWorktreeLineage as recordCreatedWorktreeLineageState } from './runtime-worktree-lineage-recording'
import {
deliverWorktreeStartupFollowup,
pasteWorktreeStartupDraftWhenReady,
sendWorktreeStartupFollowupWhenReady,
waitForWorktreeStartupDraft
} from './runtime-worktree-startup-readiness'
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning'
import { readFreshComposerHold } from './launched-agent-composer-readiness'
import { buildTerminalWaitText } from './terminal-wait-tail-state'
import {
readLaunchedAgentForeground,
type LaunchedAgentForeground
} from './launched-agent-foreground'
export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees {
async activateManagedWorktree(
worktreeSelector: string,
opts: {
notifyClients?: boolean
clientKind?: 'mobile' | 'runtime'
navigation?: RuntimeNavigationTarget
} = {}
): Promise<{
repoId: string
worktreeId: string
activated: boolean
/** Mobile-scoped slept-agent wake outcome. `unsupported-headless` means no
* renderer holds the sleeping records (headless `orca serve`), so nothing
* woke — clients must not present the worktree's agents as resumed. */
sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable'
}> {
this.assertGraphReady()
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
const repo = this.store?.getRepo(worktree.repoId)
if (!repo) {
throw new Error('repo_not_found')
}
const navigation = resolveRuntimeNavigationTarget({ ...opts, defaultTarget: 'host' })
const targetsHost = navigationTargetsHost(navigation)
const targetsClients = navigationTargetsClients(navigation)
if (!targetsHost && this.store?.getWorktreeMeta(worktree.id)?.isUnread) {
// Why: mobile/web session activation intentionally bypasses renderer
// selection, so the runtime must acknowledge the unread state itself.
this.store.setWorktreeMeta(worktree.id, { isUnread: false })
this.notifyWorktreesChanged(repo.id)
}
let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' =
'not-applicable'
if (targetsHost || targetsClients) {
// Why: inactive worktree terminal panes are renderer-owned and may not have
// live PTYs until the desktop activates the worktree and mounts them.
if (targetsHost) {
this.notifyHostActivateWorktree(repo.id, worktree.id)
}
if (targetsClients) {
this.notifyClientsActivateWorktree(
repo.id,
worktree.id,
undefined,
undefined,
undefined,
navigation
)
}
}
if (!targetsHost) {
// Why: mobile/web selection needs fresh session surfaces without forcing
// every attached desktop renderer to navigate to the phone's workspace.
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, {
allowAttachedWindow: true
})
await this.refreshMobileSessionPtyRecords()
this.notifyMobileSessionTabsChanged(worktree.id)
// Why: a phone open must also wake the worktree's slept agents (experimental
// agent sleep). Only the host renderer holds the sleeping records + wake
// authority, so fire-and-forget ask it — mobile-scoped so web/desktop are
// unaffected. Headless serve has no renderer to wake anything, so report
// that explicitly instead of letting mobile assume the agents resumed.
if (opts.clientKind === 'mobile') {
if (this.getAvailableAuthoritativeWindow()) {
this.notifier?.resumeSleepingAgents?.(worktree.id)
sleepingAgentWake = 'requested'
} else if (
// Why: sleeping records are partitioned by execution host; reading
// only the local partition would miss slept agents on SSH-host
// worktrees and skip the headless warning for them.
Object.values(
this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo))
.sleepingAgentSessionsByPaneKey ?? {}
).some((record) => record.worktreeId === worktree.id)
) {
// Why: headless is only degraded when this worktree actually has a
// persisted resume record. Ordinary mobile activation must not show
// an unsupported warning merely because no desktop window is open.
sleepingAgentWake = 'unsupported-headless'
}
}
}
return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake }
}
/** The agent a create's linked draft starts, chosen as `buildStartupForDraft` chooses it, so a
* launch can name the agent before the create runs. */
async resolveStartupDraftAgent(repo: Repo, requestedAgent?: TuiAgent): Promise<TuiAgent | null> {
if (!this.store) {
return null
}
return resolveWorktreeStartupDraftAgent({
repo,
settings: this.store.getSettings(),
...(requestedAgent ? { requestedAgent } : {})
})
}
protected async buildStartupForDraft(
repo: Repo,
draft: string,
requestedAgent?: TuiAgent,
launchSource?: string
): Promise<{
agent: TuiAgent
startup: WorktreeStartupLaunch
draftPaste?: WorktreeStartupDraftPaste
} | null> {
if (!this.store) {
return null
}
return buildWorktreeStartupForDraft({
repo,
draft,
...(requestedAgent ? { requestedAgent } : {}),
...(launchSource ? { launchSource } : {}),
settings: this.store.getSettings(),
getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo)
})
}
protected buildStartupForAgent(
repo: Repo,
agent: TuiAgent,
prompt: string | undefined,
launchPreferences?: AgentLaunchPreferences,
launchInputs?: {
agentArgs?: string | null
extraAgentArgs?: string
launchSource?: string
requestKind?: string
onPromptCarry?: (carried: boolean) => void
}
): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } {
if (!this.store) {
throw new Error('runtime_unavailable')
}
return buildWorktreeStartupForAgent({
repo,
agent,
...(prompt !== undefined ? { prompt } : {}),
...(launchPreferences ? { launchPreferences } : {}),
...(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),
toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences)
})
}
protected recordCreatedWorktreeLineage(
worktree: Pick<Worktree, 'id' | 'instanceId'>,
lineageResolution: WorktreeLineageResolution
): {
lineage: WorktreeLineage | null
workspaceLineage: WorkspaceLineage | null
warnings: WorktreeLineageWarning[]
} {
return recordCreatedWorktreeLineageState(this.store, worktree, lineageResolution)
}
protected pasteStartupDraftWhenReady(handle: string, draft: WorktreeStartupDraftPaste): void {
pasteWorktreeStartupDraftWhenReady(this.getWorktreeStartupReadinessHost(), handle, draft)
}
/**
* Only for a newly launched agent, before its first input. Settles when the agent's composer
* signal fires on a screen with no startup dialog and no Codex provisional header; with
* `stopOnDialog`, a dialog ends the wait so the caller's idle wait can report it.
*/
async waitForFreshWorkerComposer(
handle: string,
agent: TuiAgent,
timeoutMs: number,
{
requireComposerMarker = true,
stopOnDialog = false,
signal
}: { requireComposerMarker?: boolean; stopOnDialog?: boolean; signal?: AbortSignal } = {}
): Promise<RuntimeTerminalWait> {
const initialPtyId =
this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId
const stop = new AbortController()
const onAbort = (): void => stop.abort()
signal?.addEventListener('abort', onAbort, { once: true })
const ptyId = await waitForWorktreeStartupDraft(
{ ...this.getWorktreeStartupReadinessHost(), getPtyId: () => initialPtyId },
handle,
agent,
{
timeoutMs,
requireComposerMarker,
// Every caller pastes and then presses Enter: a worker brief or a launch prompt.
submit: true,
signal: stop.signal,
isShellInFront: async (ownerPtyId) =>
(await this.readLaunchedAgentForeground(ownerPtyId, agent)) === 'shell',
accept: (readyPtyId) => {
const pty = this.ptysById.get(readyPtyId)
const hold = pty
? readFreshComposerHold(
buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview),
this.readLiveTerminalScreenLines(readyPtyId)
)
: null
if (hold === 'dialog' && stopOnDialog) {
stop.abort()
}
return hold === null
}
}
)
signal?.removeEventListener('abort', onAbort)
if (!ptyId) {
throw new Error(
signal?.aborted
? 'request_aborted'
: stop.signal.aborted
? 'agent_startup_dialog'
: 'timeout'
)
}
this.assertLiveTerminalHandleTargetsPty(handle, ptyId)
if (!this.ptysById.get(ptyId)?.connected) {
throw new Error('terminal_handle_stale')
}
return this.buildTuiIdleProbeResult(handle, null)
}
/** What holds the terminal a launch started its agent in, read fresh from the execution host. */
readLaunchedAgentForeground(ptyId: string, agent: TuiAgent): Promise<LaunchedAgentForeground> {
return readLaunchedAgentForeground(
this.ptyController,
this.launchedAgentHost(ptyId),
ptyId,
agent
)
}
/** Whether the pane's execution host can find a launched agent in front: a Windows one cannot. */
launchedAgentHostProvesAgent(ptyId: string): boolean {
return !this.launchedAgentHost(ptyId).windows
}
private launchedAgentHost(ptyId: string): { remote: boolean; windows: boolean } {
const pty = this.ptysById.get(ptyId)
const remote = !!pty?.connectionId
// A local WSL pane still runs on a Windows host, whose process reads cannot see into it.
return {
remote,
windows: remote ? this.pathFlavorForPty(pty) === 'win32' : process.platform === 'win32'
}
}
protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void {
sendWorktreeStartupFollowupWhenReady(this.getWorktreeStartupReadinessHost(), handle, followup)
}
/** The same follow-up write, awaited: true once the prompt was typed into the agent. */
deliverStartupFollowup(handle: string, followup: WorktreeStartupFollowup): Promise<boolean> {
return deliverWorktreeStartupFollowup(this.getWorktreeStartupReadinessHost(), handle, followup)
}
protected async provisionManagedWorktreeTerminals(
args: {
worktreeSelector: string
worktreeId: string
worktreePath: string
setup?: CreateWorktreeResult['setup']
defaultTabs?: CreateWorktreeResult['defaultTabs']
primaryTerminalHandle?: string | null
hasStartupTerminal: boolean
setupCommandPlatform: 'windows' | 'posix'
observeSetupCompletion?: boolean
// Why: when the agent startup is sequenced to wait for setup
// (waitForAgentStartup), the startup PTY runs a wrapper that already embeds
// the setup command. Pass that wrapped command through so the Setup tab runs
// the same script the agent is waiting on instead of a bare runner.
wrappedSetupCommand?: string
// Why: a workspace provisioned in the background must not pull the sidebar
// to itself; the user never asked to look at these tabs.
surfaceOwner?: false
},
createdWorktree?: Worktree
): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> {
return provisionWorktreeTerminals(
this.getWorktreeTerminalProvisioningHost(createdWorktree),
args
)
}
}