feat(cli): launch agents in existing workspaces (#27794)

This commit is contained in:
Brennan Benson
2026-10-12 00:38:00 -07:00
committed by GitHub
parent 88a82c0a7d
commit e3ef95dc04
38 changed files with 1223 additions and 143 deletions
@@ -217,6 +217,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/runtime/orchestration/orchestration-party-location.test.ts',
'src/main/runtime/orchestration/structured-worker-journal-page.test.ts',
'src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts',
'src/main/runtime/orca-runtime.test.ts',
'src/main/runtime/rpc/methods/agent-launch-folder-workspace-create.test.ts',
'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts',
'src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts',
+9 -3
View File
@@ -61,6 +61,12 @@ Existing-terminal handoff:
ORCA terminal send --terminal <handle> --text "<task brief>" --enter --json
```
## Starting an agent in an existing workspace
Use `ORCA agent launch --agent <id> [--worktree <selector>] [--prompt <text>] [--model <id>] [--effort <level>] [--focus] --json`. Without `--worktree`, it uses the current worktree or folder workspace. Orca follows the user's Chat UI setting, opening a chat when supported and a terminal otherwise. The result reports `outcome.kind`, `outcome.handle`, and `receipt.detail`.
If the launch reply is lost, re-issue the exact same command from the same directory and execution host with the returned `--retry-launch <operationId>`. Keep that ID on every retry. If replay cannot establish the outcome, inspect the workspace before deliberately starting another agent.
## Worktrees
An Orca worktree is Orca's tracked view of a repo checkout, its metadata, terminals, browser tabs, and UI state.
@@ -132,7 +138,7 @@ ORCA worktree create --name task --run-hooks --json
- `--activate` and `--run-hooks` reveal the new worktree. `--agent` alone stays in the background.
- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior.
- If an older installed CLI rejects `--agent`, `--prompt`, or `--setup`, create the worktree normally, then run `ORCA terminal create --worktree <selector> --command "<requested-agent>"` and `ORCA terminal send` if a prompt is needed. This can leave a fallback shell when no default tabs are configured; close it only after confirming it is unused.
- `worktree create` makes a new checkout. For a fresh agent in the **current** checkout, use `ORCA terminal create --worktree active --command "codex" --json`.
- `worktree create` makes a new checkout. For a fresh agent in the **current** checkout, use `ORCA agent launch --agent codex --json`.
## Review and issue references
@@ -195,7 +201,7 @@ ORCA terminal wait --terminal <handle> --for exit --timeout-ms 5000 --json
ORCA terminal wait --terminal <handle> --for tui-idle --timeout-ms 300000 --json
ORCA terminal create --json
ORCA terminal create --title "Worker" --json
ORCA terminal create --worktree active --command "codex" --json
ORCA terminal create --worktree active --command "pnpm dev" --json
ORCA terminal split --terminal <handle> --direction vertical --json
ORCA terminal split --terminal <handle> --direction horizontal --command "npm test" --json
ORCA terminal rename --terminal <handle> --title "New Name" --json
@@ -219,7 +225,7 @@ Terminal rules:
- `--wait-submit <seconds>` only observes the same accepted prompt. A timeout returns queued/input-accepted truth without resending; after an ambiguous transport failure, repeat the exact command with the reported `--retry-request <id>`. Both text and `--json` receipts carry the same `warnings`.
- An older host reports a legacy `old-host` fallback for an ordinary send and refuses `--wait-submit` or `--retry-request` before input, because it cannot provide durable replay.
- For structured coordination, invoke the `orchestration` skill; it uses `orca orchestration ...` commands for messages, handoffs, task DAGs, dispatches, inbox/reply flows, and coordinator loops. A receiving agent can run `orca orchestration check --peek --format --json` to render its unread mail in agent-readable form; this checks the caller's inbox and does not remotely deliver input to another terminal.
- Use `terminal create --worktree active --command "<agent>"` for a fresh agent in the current worktree. Use `worktree create --agent <agent>` only for a separate checkout (agent in the first terminal — do not also `terminal create` the same agent).
- Use `agent launch` for a fresh agent in the current workspace. Use `worktree create --agent` for a separate checkout (agent in the first terminal — do not also `terminal create` the same agent).
- Use `terminal wait --for tui-idle` for agent CLIs such as Claude Code, Gemini, Codex, OMP, Pi, and Grok; always pass `--timeout-ms`.
- For long output, use cursor reads. After a limited tail preview, page from `oldestCursor`; after a cursor read, continue with `nextCursor` while `limited` is true and `nextCursor !== latestCursor`.
- `--direction horizontal` splits left/right. `--direction vertical` splits top/bottom.
+3 -2
View File
@@ -40,8 +40,9 @@ absence included, is a checkpoint.
| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |
| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |
Model or effort selection does not make a handoff supervised. Never substitute a
non-Orca subagent tool when Orca orchestration provenance was requested.
Model or effort selection does not make a handoff supervised. For an existing
workspace, `orca agent launch` accepts `--model` and `--effort`. Never substitute
a non-Orca subagent tool when Orca orchestration provenance was requested.
## Authority and safety floor
+76
View File
@@ -0,0 +1,76 @@
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
import { classifyAgentLaunchReplayRefusal } from '../shared/agent-launch-replay-refusal'
import {
recoverableOrchestrationArgs,
resolveOrchestrationCliExecutable
} from './runtime/orchestration-recovery-command'
/** A lost reply never authorizes a new launch, even on hosts without a replay ledger. */
export async function withCliAgentLaunchRecovery<T>(
launch: Promise<T>,
recovery: {
operationId?: string
replayed?: boolean
rawArgs?: readonly string[]
params: unknown
}
): Promise<T> {
try {
return await launch
} catch (error) {
// A replay method's ordinary refusal proves no effects; legacy errors may follow a spawn.
if (
recovery.operationId &&
error instanceof RuntimeRpcFailureError &&
error.code !== 'runtime_error' &&
classifyAgentLaunchReplayRefusal(error, recovery.replayed === true) !== 'unknown'
) {
throw error
}
const args = recovery.rawArgs && recoverableOrchestrationArgs(recovery.rawArgs)
const retryArgs: string[] = []
for (let index = 0; index < (args?.length ?? 0); index++) {
const arg = args?.[index]
if (arg === '--retry-launch') {
index++
} else if (arg && !arg.startsWith('--retry-launch=')) {
retryArgs.push(arg)
}
}
const recoveryCommand =
args && recovery.operationId
? [
resolveOrchestrationCliExecutable(),
...retryArgs,
'--retry-launch',
recovery.operationId
]
: undefined
const nextSteps = recovery.operationId
? [
`Re-issue the exact same command from the same directory against the same execution host with --retry-launch ${recovery.operationId}. Keep that ID on every retry; never retry as a fresh launch.`,
'If replay reports an unknown or expired outcome, inspect the workspace and agent without sending input before deciding what to do.'
]
: [
'Do not repeat the launch: this older host cannot prove whether it started the agent or sent the prompt.',
'Inspect worktree list and terminal list on the execution host, then read the agent without sending input. Confirm the outcome before deliberately starting another agent.'
]
// Replace transport restart-and-retry advice: the accepted launch may still be running.
throw new RuntimeClientError(
'agent_session_operation_unknown',
'Orca could not confirm the launch outcome; the agent may already be running and the prompt may already be submitted.',
{
launchOutcome: 'unknown',
retrySafe: Boolean(recovery.operationId),
...(recovery.operationId ? { operationId: recovery.operationId } : {}),
launchRequest: recovery.params,
...(recoveryCommand ? { recoveryCommand } : {}),
nextSteps,
cause:
error instanceof Error
? error.message.replace(/\bRestart Orca and try again\./g, '')
: String(error)
}
)
}
}
+141
View File
@@ -0,0 +1,141 @@
/**
* How the CLI starts an agent: through `agent.launch`, the host's one launch point that the desktop
* and phone use too. The user's Chat UI setting picks a chat or a terminal, and the receipt says
* which ran and why; the CLI never picks the surface itself.
*/
import { randomUUID } from 'node:crypto'
import type { AgentLaunchPrompt, AgentLaunchResult } from '../shared/agent-launch-intent'
import { checkAgentLaunchModelPreferences } from '../shared/agent-launch-model-preferences'
import {
AGENT_LAUNCH_RUNTIME_CAPABILITY,
AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY,
AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY,
AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY
} from '../shared/agent-launch-runtime-capability'
import { createStructuredAgentSessionOperationId } from '../shared/structured-agent-session-mutation'
import { parseAgentSessionOperationTimestamp } from '../shared/agent-session-host-authority'
import type { RuntimeStatus } from '../shared/runtime-types'
import type { TuiAgent } from '../shared/tui-agent'
import { isTuiAgent } from '../shared/tui-agent-config'
import {
getOptionalStringFlag,
getRequiredStringFlagAllowingEmpty,
rejectValuelessFlag
} from './flags'
import { RuntimeClientError, type RuntimeClient, type RuntimeRpcSuccess } from './runtime-client'
import { withCliAgentLaunchRecovery } from './agent-launch-recovery'
type Flags = Map<string, string | boolean>
export type CliAgentLaunchTarget = { kind: 'existing'; worktree: string }
export type CliAgentLaunch = {
agent: TuiAgent
prompt?: AgentLaunchPrompt
sessionOptions?: Record<string, string>
operationId?: string
}
export function parseCliAgent(value: string): TuiAgent {
if (!isTuiAgent(value)) {
throw new RuntimeClientError('invalid_argument', `Unknown TUI agent "${value}"`)
}
return value
}
/** The agent, prompt, model and effort a CLI start asks for; refuses a model or effort the launch
* could not apply, by the same rule as `orchestration worker-start`. */
export function readCliAgentLaunch(flags: Flags, agent: TuiAgent): CliAgentLaunch {
const checked = checkAgentLaunchModelPreferences({
agent,
model: getOptionalStringFlag(flags, 'model'),
effort: getOptionalStringFlag(flags, 'effort'),
// The execution host verifies an OpenCode model itself before it starts one.
openCodeModelLaunchSupported: true
})
if ('refusal' in checked) {
throw new RuntimeClientError(checked.refusal.code, checked.refusal.message)
}
const prompt = flags.get('prompt')
rejectValuelessFlag(prompt, 'prompt')
const operationId = flags.has('retry-launch')
? getRequiredStringFlagAllowingEmpty(flags, 'retry-launch')
: undefined
if (operationId !== undefined && parseAgentSessionOperationTimestamp(operationId) === null) {
throw new RuntimeClientError('invalid_argument', 'Malformed --retry-launch operation ID')
}
return {
agent,
...(operationId ? { operationId } : {}),
// A blank prompt is no prompt: the agent starts with nothing sent.
...(typeof prompt === 'string' && prompt.trim()
? { prompt: { text: prompt, delivery: 'submit' as const } }
: {}),
...(checked.preferences ? { sessionOptions: { ...checked.preferences } } : {})
}
}
export async function getAgentLaunchCapabilities(
client: RuntimeClient
): Promise<readonly string[] | null> {
const status = await client.call<RuntimeStatus>('status.get')
const capabilities = status.result.capabilities ?? []
return capabilities.includes(AGENT_LAUNCH_RUNTIME_CAPABILITY) ? capabilities : null
}
export async function callAgentLaunch(
client: RuntimeClient,
launch: CliAgentLaunch,
request: { target: CliAgentLaunchTarget; presentation?: 'focused' | 'background' },
capabilities: readonly string[],
rawArgs?: readonly string[]
): Promise<RuntimeRpcSuccess<AgentLaunchResult & { operationId?: string }>> {
const replaySupported = capabilities.includes(AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY)
if (launch.operationId && !replaySupported) {
throw new RuntimeClientError(
'agent_session_operation_unknown',
'This host cannot replay the earlier launch. Return to the original execution host; do not retry without --retry-launch.'
)
}
if (launch.prompt && !capabilities.includes(AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY)) {
throw new RuntimeClientError(
'incompatible_runtime',
'This Orca host cannot safely carry launch prompts. Update Orca on the execution host.'
)
}
if (!capabilities.includes(AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY)) {
throw new RuntimeClientError(
'incompatible_runtime',
'This Orca host cannot honor CLI launch focus or background behavior. Update Orca on the execution host.'
)
}
const operationId = replaySupported
? (launch.operationId ?? createStructuredAgentSessionOperationId(randomUUID))
: undefined
const params = {
...request,
...launch,
...(operationId ? { operationId } : {}),
launchSource: 'cli'
}
const result = await withCliAgentLaunchRecovery(
client.call<AgentLaunchResult>(operationId ? 'agent.launchReplay' : 'agent.launch', params),
{ operationId, replayed: launch.operationId !== undefined, rawArgs, params }
)
return { ...result, result: { ...result.result, ...(operationId ? { operationId } : {}) } }
}
export function formatAgentLaunch(result: AgentLaunchResult & { operationId?: string }): string {
const { outcome, receipt } = result
return [
`agent: ${outcome.kind === 'structured' ? 'chat session' : 'terminal'}`,
`handle: ${outcome.handle}`,
...(outcome.kind === 'structured' ? [`sessionId: ${outcome.sessionId}`] : []),
`worktreeId: ${result.worktreeId}`,
...(result.operationId ? [`operationId: ${result.operationId}`] : []),
`why: ${receipt.detail}`,
...(result.prompt ? [`prompt: ${result.prompt.outcome}`] : []),
...(result.warning ? [`warning: ${result.warning}`] : [])
].join('\n')
}
+22
View File
@@ -0,0 +1,22 @@
import { AGENT_LAUNCH_RUNTIME_CAPABILITIES } from '../shared/agent-launch-runtime-capability'
import { okFixture } from './test-fixtures'
/** A host that runs `agent.launch`, as `status.get` reports it. */
export function agentLaunchHostFixture() {
return okFixture('req_status', { capabilities: [...AGENT_LAUNCH_RUNTIME_CAPABILITIES] })
}
/** What `agent.launch` answers when the user's Chat UI setting opened a chat. */
export function agentLaunchFixture(worktreeId: string) {
return okFixture('req_launch', {
outcome: { kind: 'structured', sessionId: 'codex_1', handle: 'agent_1' },
worktreeId,
receipt: {
mode: 'structured',
preferred: 'structured',
reason: 'user_default',
detail: 'Started a structured chat session, the default for new agent tabs in your settings.'
},
prompt: { delivery: 'submit', outcome: 'journaled', messageId: 'msg_1' }
})
}
+58
View File
@@ -0,0 +1,58 @@
import type { FolderWorkspace } from '../shared/folder-workspace-types'
import type { ProjectGroup } from '../shared/project-group-types'
import type { Repo } from '../shared/repo-types'
import { resolveFolderWorkspaceHost } from '../shared/folder-workspace-execution-host'
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
import {
getCallerExecutionHostId,
selectEnclosingWorktreeOnHost
} from './caller-host-worktree-selection'
import { RuntimeRpcFailureError, type RuntimeClient } from './runtime-client'
import { resolveCurrentWorktreeSelector } from './selectors'
/** Native folders take precedence over enclosing Git checkouts; remote cwd keeps its host rules. */
export async function resolveCurrentAgentWorkspaceSelector(
cwd: string,
client: RuntimeClient
): Promise<string> {
if (!client.isRemote && getCallerExecutionHostId() === LOCAL_EXECUTION_HOST_ID) {
try {
const folders = await client.call<{ folderWorkspaces: FolderWorkspace[] }>(
'folderWorkspace.list'
)
const catalog = folders.result.folderWorkspaces.map((folder) => ({
id: `folder:${folder.id}`,
path: folder.folderPath
}))
if (selectEnclosingWorktreeOnHost(catalog, cwd, LOCAL_EXECUTION_HOST_ID)) {
const [groups, repos] = await Promise.all([
client.call<{ groups: ProjectGroup[] }>('projectGroup.list'),
client.call<{ repos: Repo[] }>('repo.list')
])
const state = {
folderWorkspaces: folders.result.folderWorkspaces,
projectGroups: groups.result.groups,
repos: repos.result.repos
}
const nativeCatalog = catalog.filter(
(row) =>
resolveFolderWorkspaceHost(state, row.id.slice('folder:'.length)).kind === 'local'
)
const callerWorkspace = process.env.ORCA_WORKSPACE_ID ?? process.env.ORCA_WORKTREE_ID
const callerCatalog = nativeCatalog.filter((row) => row.id === callerWorkspace)
const selector =
selectEnclosingWorktreeOnHost(callerCatalog, cwd, LOCAL_EXECUTION_HOST_ID) ??
selectEnclosingWorktreeOnHost(nativeCatalog, cwd, LOCAL_EXECUTION_HOST_ID)
if (selector) {
return selector
}
}
} catch (error) {
// An older host may lack the folder catalog; a failed catalog must not select a wrong checkout.
if (!(error instanceof RuntimeRpcFailureError) || error.code !== 'method_not_found') {
throw error
}
}
}
return resolveCurrentWorktreeSelector(cwd, client)
}
File diff suppressed because one or more lines are too long
+5
View File
@@ -205,6 +205,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
keys: ['agent hooks prepare-codex', 'agent hooks status', 'agent hooks off', 'agent hooks on'],
load: async () => (await import('./handlers/agent-hooks.js')).AGENT_HOOK_HANDLERS
},
{
name: 'agent-launch',
keys: ['agent launch'],
load: async () => (await import('./handlers/agent-launch.js')).AGENT_LAUNCH_HANDLERS
},
{
name: 'profile-state',
keys: ['profile state exports', 'profile state rollback'],
+54
View File
@@ -0,0 +1,54 @@
import type { CommandHandler } from '../dispatch'
import {
callAgentLaunch,
formatAgentLaunch,
getAgentLaunchCapabilities,
parseCliAgent,
readCliAgentLaunch
} from '../agent-launch-request'
import { getRequiredStringFlag } from '../flags'
import { printResult } from '../format'
import { RuntimeClientError } from '../runtime-client'
import { getRequiredWorktreeSelector } from '../selectors'
import { resolveCurrentAgentWorkspaceSelector } from '../agent-workspace-selector'
export const AGENT_LAUNCH_HANDLERS: Record<string, CommandHandler> = {
'agent launch': async ({ flags, client, cwd, json, rawArgs }) => {
const launch = readCliAgentLaunch(flags, parseCliAgent(getRequiredStringFlag(flags, 'agent')))
if (client.isRemote && !flags.has('worktree')) {
throw new RuntimeClientError(
'invalid_argument',
'Remote agent launch requires --worktree because the client cwd cannot identify a server worktree.'
)
}
const selector = flags.get('worktree')
const current = selector === undefined || selector === 'active' || selector === 'current'
const worktree = current
? await resolveCurrentAgentWorkspaceSelector(cwd, client)
: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client)
const capabilities = await getAgentLaunchCapabilities(client)
if (!capabilities) {
if (launch.operationId) {
throw new RuntimeClientError(
'agent_session_operation_unknown',
'This host cannot replay the earlier launch. Return to the original execution host; do not retry without --retry-launch.'
)
}
throw new RuntimeClientError(
'incompatible_runtime',
'This Orca host cannot start agents from the CLI. Update Orca on the execution host.'
)
}
const result = await callAgentLaunch(
client,
launch,
{
target: { kind: 'existing', worktree },
presentation: flags.get('focus') === true ? 'focused' : 'background'
},
capabilities,
rawArgs
)
printResult(result, json, formatAgentLaunch)
}
}
+309
View File
@@ -0,0 +1,309 @@
import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness'
import { agentLaunchFixture, agentLaunchHostFixture } from './agent-launch-test-fixtures'
const WORKTREE_ID = 'repo-1::/tmp/repo'
async function runRefused(argv: string[]): Promise<string> {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(argv, '/tmp/repo')
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
return [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')
}
function launchCalls(): unknown[][] {
return callMock.mock.calls.filter(
([method]) =>
method === 'agent.launchReplay' || method === 'agent.launch' || method === 'worktree.create'
)
}
describe('orca cli agent launch', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('starts an agent in the current worktree through agent.launch', async () => {
queueFixtures(
callMock,
okFixture('req_folders', { folderWorkspaces: [] }),
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
agentLaunchHostFixture(),
agentLaunchFixture(WORKTREE_ID)
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'agent',
'launch',
'--agent',
'codex',
'--model',
'gpt-5.5',
'--effort',
'high',
'--prompt',
'Write the design doc',
'--json'
],
'/tmp/repo/src'
)
expect(callMock).toHaveBeenNthCalledWith(4, 'agent.launchReplay', {
target: { kind: 'existing', worktree: `id:${WORKTREE_ID}` },
presentation: 'background',
agent: 'codex',
prompt: { text: 'Write the design doc', delivery: 'submit' },
sessionOptions: { model: 'gpt-5.5', effort: 'high' },
launchSource: 'cli',
operationId: expect.any(String)
})
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0])).result.receipt.reason).toBe('user_default')
})
it('switches to the new tab only with --focus', async () => {
queueFixtures(callMock, agentLaunchHostFixture(), agentLaunchFixture(WORKTREE_ID))
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['agent', 'launch', '--agent', 'claude', '--worktree', `id:${WORKTREE_ID}`, '--focus'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('agent.launchReplay', {
target: { kind: 'existing', worktree: `id:${WORKTREE_ID}` },
presentation: 'focused',
agent: 'claude',
launchSource: 'cli',
operationId: expect.any(String)
})
})
it('honors the paired execution host for agent launch', async () => {
pairRuntimeEnvironment(listEnvironmentsMock, 'server-1')
queueFixtures(callMock, agentLaunchHostFixture(), agentLaunchFixture('folder:server-folder'))
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'agent',
'launch',
'--agent',
'codex',
'--environment',
'server-1',
'--worktree',
'id:folder:server-folder',
'--json'
],
'/tmp/repo'
)
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(undefined, 'server-1')
expect(callMock).toHaveBeenCalledWith(
'agent.launchReplay',
expect.objectContaining({ target: { kind: 'existing', worktree: 'id:folder:server-folder' } })
)
expect(callMock).not.toHaveBeenCalledWith('folderWorkspace.list')
})
it('returns an exact recovery command and removes blind retry advice after a lost reply', async () => {
queueFixtures(callMock, agentLaunchHostFixture())
callMock.mockRejectedValueOnce(
new RuntimeClientError(
'runtime_unavailable',
'The Orca runtime closed the connection before responding. Restart Orca and try again.'
)
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const previousExitCode = process.exitCode
const args = [
'agent',
'launch',
'--agent',
'codex',
'--worktree',
`id:${WORKTREE_ID}`,
'--prompt',
'Fix it',
'--json'
]
await main(args, '/tmp/repo')
process.exitCode = previousExitCode
const error = JSON.parse(String(logSpy.mock.calls[0]?.[0])).error
expect(error.code).toBe('agent_session_operation_unknown')
expect(error.data.retrySafe).toBe(true)
expect(error.data.recoveryCommand).toEqual([
expect.any(String),
...args,
'--retry-launch',
error.data.operationId
])
expect(JSON.stringify(error)).not.toContain('Restart Orca and try again')
expect(launchCalls()).toHaveLength(1)
})
it.each([['--retry-launch'], ['--retry-launch', ' ']])(
'rejects an invalid retry identity before launching: %j',
async (...retryArgs) => {
const output = await runRefused([
'agent',
'launch',
'--agent',
'codex',
'--worktree',
`id:${WORKTREE_ID}`,
...retryArgs,
'--json'
])
expect(JSON.parse(output).error).toMatchObject({
code: 'invalid_argument',
message: expect.stringContaining('--retry-launch')
})
expect(callMock).not.toHaveBeenCalled()
}
)
it('keeps recovery identity when an older replacement host refuses a replay method', async () => {
const operationId = `${Date.now()}-${'c'.repeat(32)}`
queueFixtures(callMock, agentLaunchHostFixture())
callMock.mockRejectedValueOnce(
new RuntimeRpcFailureError({
id: 'refused',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'replacement' }
})
)
const output = await runRefused([
'agent',
'launch',
'--agent',
'codex',
'--worktree',
`id:${WORKTREE_ID}`,
'--retry-launch',
operationId,
'--json'
])
const error = JSON.parse(output).error
expect(error.code).toBe('agent_session_operation_unknown')
expect(error.data.operationId).toBe(operationId)
expect(error.data.nextSteps.join(' ')).toContain('never retry as a fresh launch')
expect(launchCalls()).toHaveLength(1)
})
it('uses the caller folder identity after validating its native host and enclosing path', async () => {
process.env.ORCA_WORKSPACE_ID = 'folder:caller'
queueFixtures(
callMock,
okFixture('folders', {
folderWorkspaces: [
{ id: 'caller', folderPath: '/tmp/folder', projectGroupId: 'local' },
{ id: 'nested', folderPath: '/tmp/folder/src', projectGroupId: 'local' },
{ id: 'remote', folderPath: '/tmp/folder', projectGroupId: 'ssh' }
]
}),
okFixture('groups', { groups: [{ id: 'local' }, { id: 'ssh', connectionId: 'ssh-1' }] }),
okFixture('repos', { repos: [] }),
agentLaunchHostFixture(),
agentLaunchFixture('folder:caller')
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['agent', 'launch', '--agent', 'codex', '--json'], '/tmp/folder/src/nested')
expect(callMock).toHaveBeenCalledWith(
'agent.launchReplay',
expect.objectContaining({ target: { kind: 'existing', worktree: 'id:folder:caller' } })
)
expect(callMock).not.toHaveBeenCalledWith('worktree.list', expect.anything())
})
it('ignores an inherited remote folder identity and uses the local Git fallback', async () => {
process.env.ORCA_WORKSPACE_ID = 'folder:remote'
queueFixtures(
callMock,
okFixture('folders', {
folderWorkspaces: [{ id: 'remote', folderPath: '/tmp/repo', projectGroupId: 'ssh' }]
}),
okFixture('groups', { groups: [{ id: 'ssh', connectionId: 'ssh-1' }] }),
okFixture('repos', { repos: [] }),
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
agentLaunchHostFixture(),
agentLaunchFixture(WORKTREE_ID)
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['agent', 'launch', '--agent', 'codex', '--json'], '/tmp/repo/src')
expect(callMock).toHaveBeenCalledWith(
'agent.launchReplay',
expect.objectContaining({ target: { kind: 'existing', worktree: `id:${WORKTREE_ID}` } })
)
})
it('refuses on a host older than agent.launch instead of opening a terminal', async () => {
queueFixtures(callMock, okFixture('req_status', { capabilities: [] }))
const output = await runRefused([
'agent',
'launch',
'--agent',
'codex',
'--worktree',
`id:${WORKTREE_ID}`,
'--json'
])
expect(output).toContain('cannot start agents from the CLI')
expect(launchCalls()).toEqual([])
})
})
+4 -10
View File
@@ -529,10 +529,7 @@ describe('orca root help', () => {
)
expect(logSpy.mock.calls[0][0]).toContain('Agent Sessions And Worktrees:')
expect(logSpy.mock.calls[0][0]).toContain(
'`worktree create --agent` creates a new checkout with an agent.'
)
expect(logSpy.mock.calls[0][0]).toContain(
'orca terminal create --worktree active --command "codex"'
'orca agent launch --agent codex --model gpt-6-astra --effort high'
)
expect(logSpy.mock.calls[0][0]).toContain(
'orchestration worker-start Start a supervised worker locally or on a connected Orca server'
@@ -723,7 +720,7 @@ describe('orca root help', () => {
expect(callMock).not.toHaveBeenCalled()
})
it('distinguishes new worktrees from fresh agent terminals in command help', async () => {
it('points both create commands at the agent launch for starting agents', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
logSpy.mockClear()
@@ -731,17 +728,14 @@ describe('orca root help', () => {
expect(String(logSpy.mock.calls[0][0])).toContain('This creates a new checkout.')
expect(String(logSpy.mock.calls[0][0])).toContain(
'orca terminal create --worktree active --command "codex"'
'For a fresh agent in an existing worktree, use `orca agent launch`'
)
logSpy.mockClear()
await main(['terminal', 'create', '--help'], '/tmp/repo')
const terminalHelp = String(logSpy.mock.calls[0][0])
expect(terminalHelp).toContain('Use this, not worktree create')
expect(terminalHelp).toContain(
'orca terminal create --worktree active --command "codex" --json'
)
expect(terminalHelp).toContain('use `orca agent launch`')
expect(callMock).not.toHaveBeenCalled()
})
})
+3 -2
View File
@@ -37,7 +37,7 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
// so routing the other half produced one listing describing two machines at once.
commandPath.join(' ') === 'host list' ||
commandPath[0] === 'serve' ||
commandPath[0] === 'agent' ||
(commandPath[0] === 'agent' && commandPath[1] !== 'launch') ||
commandPath[0] === 'vm' ||
commandPath[0] === 'agent-context' ||
commandPath[0] === 'profile'
@@ -180,7 +180,8 @@ export async function main(
return client
},
cwd,
json
json,
rawArgs: argv
})
} catch (error) {
const worktreeSelector = parsed.flags.get('worktree')
+3
View File
@@ -84,6 +84,9 @@ export const ROOT_HELP_TEXT_PRIMARY = [
' worktree rm Remove a worktree from Orca and git',
' worktree ps Show a compact orchestration summary across worktrees',
'',
'Agents:',
' agent launch Start an agent in a worktree, as a chat or terminal per your settings',
'',
'Files:',
' file open Open a file as a tab, view stays put (--focus brings you to it)',
' file diff Open a file diff as a tab, view stays put (--focus brings you to it)',
+2 -1
View File
@@ -64,6 +64,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]',
' orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--wait-submit <seconds>] [--retry-request <id>] [--json]',
' orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]',
' orca agent launch --agent <id> [--worktree <selector>] [--prompt <text>] [--model <id>] [--effort <level>] [--focus] [--json]',
' orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--shell <shell>] [--focus] [--json]',
' orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]',
' orca terminal switch [--terminal <handle>] [--json]',
@@ -117,7 +118,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
'Agent Sessions And Worktrees:',
' `worktree create --agent` creates a new checkout with an agent.',
' To start a fresh agent in the current worktree, use:',
' orca terminal create --worktree active --command "codex"',
' orca agent launch --agent codex --model gpt-6-astra --effort high',
'',
'Browser Workflow:',
' 1. Create or navigate: orca tab create --url https://example.com',
+33
View File
@@ -0,0 +1,33 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const AGENT_LAUNCH_COMMAND_SPECS: CommandSpec[] = [
{
path: ['agent', 'launch'],
summary: 'Start an agent in an existing workspace, as a chat or a terminal per your settings',
usage:
'orca agent launch --agent <id> [--worktree <selector>] [--prompt <text>] [--model <id>] [--effort <level>] [--focus] [--retry-launch <operationId>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'agent',
'worktree',
'prompt',
'model',
'effort',
'focus',
'retry-launch'
],
notes: [
'Starts the agent the way a new agent tab does: as a chat when your Chat UI setting is on and the agent and host support it, otherwise as a terminal. The result says which (`outcome.kind`) and why (`receipt.detail`).',
'--worktree defaults to the native folder workspace or worktree containing the current directory. For a new checkout with its agent, use `orca worktree create --agent`.',
'--model takes a provider model id and --effort a reasoning effort for that model; --effort requires --model. They apply to either surface.',
'The tab opens without switching focus; pass --focus to switch to it.',
'If the launch reply is lost, re-issue the same command from the same directory on the same host with --retry-launch <operationId> from the error. Never retry as a fresh launch; older hosts require inspecting the workspace first.'
],
examples: [
'orca agent launch --agent codex --json',
'orca agent launch --agent codex --model gpt-6-astra --effort high --prompt "Write the design doc" --json',
'orca agent launch --agent claude --worktree path:/projects/myapp --focus'
]
}
]
+5 -5
View File
@@ -93,7 +93,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
],
notes: [
'Repeat --reference to attach full issue or review URLs before setup or agent startup. Cannot combine with legacy link flags.',
'This creates a new checkout. For a fresh agent in an existing worktree, use `orca terminal create --worktree active --command "codex"` instead.',
'This creates a new checkout. For a fresh agent in an existing worktree, use `orca agent launch` instead.',
'By default, Orca records the new worktree as a child of the caller context when it can infer one from the Orca terminal or current directory.',
'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.',
'Use --project with --host to create on a ready project host setup without spelling the backing repo id.',
@@ -213,15 +213,15 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'command', 'shell', 'title', 'focus'],
notes: [
'Creates a visible terminal tab without switching focus when possible; falls back to a background handle if the UI cannot adopt it. Pass --focus to switch to it.',
'Use this, not worktree create, for a fresh agent in the current checkout.',
'This creates a terminal and runs --command in it. To start an agent in an existing workspace, use `orca agent launch`, which follows your Chat UI setting.',
'--shell picks the shell the terminal IS on a Windows host (cmd.exe, powershell.exe, pwsh.exe, wsl.exe, bash.exe, git-bash); --command is typed into whatever shell the host started, so `--command cmd.exe` leaves a cmd running INSIDE the default shell and exiting it drops back to that shell.',
'A host that cannot apply --shell refuses the create rather than quietly spawning its default shell: macOS and Linux execution hosts spawn the login shell, terminals routed over SSH resolve their shell on the SSH host, a --shell that contradicts the project execution runtime (WSL vs Windows host) is refused, and an Orca host older than --shell is refused by the CLI.'
],
examples: [
'orca terminal create --json',
'orca terminal create --worktree active --command "codex" --json',
'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "opencode"',
'orca terminal create --worktree path:/projects/myapp --command "opencode" --focus',
'orca terminal create --worktree active --command "pnpm dev" --json',
'orca terminal create --worktree path:/projects/myapp --title "RUNNER" --command "npm test"',
'orca terminal create --worktree path:/projects/myapp --command "pnpm dev" --focus',
'orca terminal create --worktree path:C:/src/app --shell cmd.exe --json'
]
},
+2
View File
@@ -11,6 +11,7 @@ import { COMPUTER_COMMAND_SPECS } from './computer'
import { ENVIRONMENT_COMMAND_SPECS } from './environment'
import { MANAGED_SERVER_COMMAND_SPECS } from './managed-server'
import { AGENT_HOOK_COMMAND_SPECS } from './agent-hooks'
import { AGENT_LAUNCH_COMMAND_SPECS } from './agent-launch'
import { DIAGNOSTICS_COMMAND_SPECS } from './diagnostics'
import { EMULATOR_COMMAND_SPECS } from './emulator'
import { INTROSPECTION_COMMAND_SPECS } from './introspection'
@@ -35,6 +36,7 @@ export const COMMAND_SPECS: CommandSpec[] = [
...ORCHESTRATION_COMMAND_SPECS,
...COMPUTER_COMMAND_SPECS,
...AGENT_HOOK_COMMAND_SPECS,
...AGENT_LAUNCH_COMMAND_SPECS,
...DIAGNOSTICS_COMMAND_SPECS,
...INTROSPECTION_COMMAND_SPECS,
...ENVIRONMENT_COMMAND_SPECS,
@@ -1,4 +1,5 @@
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
import { ownerSurfacing } from './orca-runtime-core'
import type { SpawnIntent } from '../../shared/host-spawn-admission'
export type { TerminalCreateOptions }
@@ -52,3 +53,12 @@ export function callerSpawnScope(
...(opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {})
}
}
export function terminalRevealViewerOptions(
opts: Pick<TerminalCreateOptions, 'surfaceOwner' | 'agentLaunchViewer'>
) {
return {
...ownerSurfacing(opts.surfaceOwner !== false),
...(opts.agentLaunchViewer ? { agentLaunchViewer: opts.agentLaunchViewer() } : {})
}
}
@@ -27,7 +27,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null
const shouldCreateInBackground =
worktreeSelector !== undefined &&
(Boolean(opts.agentSessionClaim) ||
(Boolean(opts.agentSessionClaim || opts.requireFreshPane) ||
(!requiresRendererFocus && opts.rendererBacked !== true) ||
availableAuthoritativeWindow === null)
if (shouldCreateInBackground) {
@@ -260,7 +260,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
...dependencies.ownerSurfacing(opts.surfaceOwner !== false),
...dependencies.terminalRevealViewerOptions(opts),
tabId,
leafId
})
@@ -1,11 +1,17 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec'
import {
TEST_WORKTREE_ID,
TEST_WORKTREE_PATH,
store,
syncSinglePty
} from '../orca-runtime-test-fixtures.spec'
import { agentLaunchSurfaceFactory } from '../rpc/methods/agent-launch-surfaces'
import { publishAgentLaunchTabEarly } from '../rpc/methods/agent-launch-tab-publication'
import { AgentLaunch } from '../../../shared/rpc-contract/agent-launch-params'
import { DESKTOP_RPC_CALLER } from '../rpc/rpc-caller-identity'
import type { RpcContext } from '../rpc/core'
import type { AgentLaunchTabPublished } from '../../../shared/agent-launch-tab-publication'
const RESERVED_TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
const RESERVED_LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'
@@ -17,13 +23,19 @@ const LIVE_PANE_SPAWN = {
}
function runtimeWithDesktopWindow(spawnResult: Record<string, unknown> = { id: 'pty-launch' }) {
const requestTabCreate = vi.fn()
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { isDestroyed: () => false, send: requestTabCreate }
})
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: RESERVED_TAB_ID })
const releaseStablePaneCreate = vi.fn()
const kill = vi.fn(() => true)
const spawn = vi.fn().mockResolvedValue(spawnResult)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
claimStablePaneCreate: () => releaseStablePaneCreate,
spawn: vi.fn().mockResolvedValue(spawnResult),
spawn,
write: () => true,
kill,
getForegroundProcess: async () => null
@@ -44,7 +56,7 @@ function runtimeWithDesktopWindow(spawnResult: Record<string, unknown> = { id: '
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
return { runtime, revealTerminalSession, releaseStablePaneCreate, kill }
return { runtime, spawn, requestTabCreate, revealTerminalSession, releaseStablePaneCreate, kill }
}
describe('OrcaRuntimeService', () => {
@@ -139,6 +151,176 @@ describe('OrcaRuntimeService', () => {
expect(created.paneKey).toBe(`${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`)
})
it.each(['focused', 'background'] as const)(
'keeps a fresh %s pane host-owned when a desktop window is available',
async (presentation) => {
const { runtime, spawn, requestTabCreate, revealTerminalSession } = runtimeWithDesktopWindow()
const created = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
startupAgent: 'claude',
tabId: RESERVED_TAB_ID,
leafId: RESERVED_LEAF_ID,
requireFreshPane: true,
presentation
})
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({ tabId: RESERVED_TAB_ID, leafId: RESERVED_LEAF_ID })
)
expect(created.paneKey).toBe(`${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`)
expect(requestTabCreate).not.toHaveBeenCalled()
if (presentation === 'focused') {
expect(revealTerminalSession).toHaveBeenCalledExactlyOnceWith(
TEST_WORKTREE_ID,
expect.objectContaining({
tabId: RESERVED_TAB_ID,
leafId: RESERVED_LEAF_ID,
presentation,
activate: true
})
)
} else {
expect(revealTerminalSession).not.toHaveBeenCalled()
}
}
)
it.each(['pending', 'completed', 'failed'] as const)(
'keeps desktop publication %s separate from the reserved host spawn',
async (phase) => {
const { runtime, spawn, requestTabCreate, revealTerminalSession } = runtimeWithDesktopWindow()
let publish!: (reply: AgentLaunchTabPublished) => void
const publishing = new Promise<AgentLaunchTabPublished>((resolve) => {
publish = resolve
})
vi.spyOn(runtime, 'canPublishAgentLaunchTab').mockReturnValue(true)
vi.spyOn(runtime, 'publishAgentLaunchTab').mockReturnValue(
phase === 'failed' ? Promise.reject(new Error('renderer_unavailable')) : publishing
)
const context: RpcContext = { runtime, caller: DESKTOP_RPC_CALLER, clientKind: 'runtime' }
const early = await publishAgentLaunchTabEarly(
AgentLaunch.parse({
agent: 'claude',
target: { kind: 'existing', worktree: `id:${TEST_WORKTREE_ID}` },
paneKey: `${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`,
presentation: 'focused'
}),
context
)
expect(early).not.toBeNull()
const reply = { tabId: RESERVED_TAB_ID, created: true, placement: { groupId: 'group-1' } }
if (phase === 'completed') {
publish(reply)
await publishing
}
const surface = await agentLaunchSurfaceFactory(
context,
undefined,
undefined,
false,
undefined,
{ early, presentation: 'focused' }
).createTerminalAgent({
worktreeId: TEST_WORKTREE_ID,
agent: 'claude',
paneKey: `${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`,
startupPrompt: 'fix the build'
})
expect(surface.paneKey).toBe(`${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`)
expect(requestTabCreate).not.toHaveBeenCalled()
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
tabId: RESERVED_TAB_ID,
leafId: RESERVED_LEAF_ID,
env: expect.objectContaining({ ORCA_PANE_KEY: surface.paneKey })
})
)
expect(spawn.mock.calls[0]?.[0]?.command).toContain('fix the build')
expect(revealTerminalSession).toHaveBeenCalledExactlyOnceWith(
TEST_WORKTREE_ID,
expect.objectContaining({
tabId: RESERVED_TAB_ID,
leafId: RESERVED_LEAF_ID,
activate: false,
surfaceOwner: false,
agentLaunchViewer: phase === 'failed' ? 'focus-in-workspace' : 'none'
})
)
expect(runtime.publishAgentLaunchTab).toHaveBeenCalledWith(
expect.objectContaining({ viewer: 'focus-in-workspace' })
)
if (phase === 'pending') {
publish(reply)
}
early?.surfacePublished({
worktreeId: TEST_WORKTREE_ID,
outcome: { kind: 'terminal', ...surface },
receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: 'test' }
})
early?.finish()
}
)
it.each([
{ desktop: false, viewer: 'focus-window' },
{ desktop: true, viewer: 'focus-in-workspace' }
])('rechecks failed publication during the $viewer host spawn', async ({ desktop, viewer }) => {
const { runtime, spawn, requestTabCreate, revealTerminalSession } = runtimeWithDesktopWindow()
const publishing = Promise.withResolvers<AgentLaunchTabPublished>()
const spawning = Promise.withResolvers<{ id: string }>()
const started = Promise.withResolvers<void>()
spawn.mockImplementation(() => {
started.resolve()
return spawning.promise
})
vi.spyOn(runtime, 'canPublishAgentLaunchTab').mockReturnValue(true)
vi.spyOn(runtime, 'publishAgentLaunchTab').mockReturnValue(publishing.promise)
const context: RpcContext = {
runtime,
...(desktop ? { caller: DESKTOP_RPC_CALLER, clientKind: 'runtime' as const } : {})
}
const paneKey = `${RESERVED_TAB_ID}:${RESERVED_LEAF_ID}`
const early = await publishAgentLaunchTabEarly(
AgentLaunch.parse({
agent: 'claude',
target: { kind: 'existing', worktree: `id:${TEST_WORKTREE_ID}` },
paneKey,
presentation: 'focused'
}),
context
)
expect(early?.windowOwnsPresentation()).toBe(true)
const creating = agentLaunchSurfaceFactory(context, undefined, undefined, false, undefined, {
early,
presentation: 'focused'
}).createTerminalAgent({
worktreeId: TEST_WORKTREE_ID,
agent: 'claude',
paneKey,
startupPrompt: 'fix the build'
})
await started.promise
publishing.reject(new Error('agent_launch_tab_publish_timeout'))
await Promise.resolve()
expect(early?.windowOwnsPresentation()).toBe(false)
spawning.resolve({ id: 'pty-launch' })
const terminal = await creating
expect(terminal.paneKey).toBe(paneKey)
expect(spawn).toHaveBeenCalledOnce()
expect(spawn.mock.calls[0]?.[0]?.command).toContain('fix the build')
expect(requestTabCreate).not.toHaveBeenCalled()
expect(revealTerminalSession).toHaveBeenCalledExactlyOnceWith(
TEST_WORKTREE_ID,
expect.objectContaining({
tabId: RESERVED_TAB_ID,
leafId: RESERVED_LEAF_ID,
agentLaunchViewer: viewer,
activate: false,
surfaceOwner: false
})
)
})
it('mints its own pane when the reserved leaf is malformed, and reports the one it used', async () => {
const { runtime, revealTerminalSession } = runtimeWithDesktopWindow()
@@ -163,7 +345,8 @@ describe('OrcaRuntimeService', () => {
startupAgent: 'claude',
tabId: RESERVED_TAB_ID,
leafId: RESERVED_LEAF_ID,
requireFreshPane: true
requireFreshPane: true,
presentation: 'focused'
})
).rejects.toThrow('agent_launch_pane_already_live')
@@ -169,6 +169,28 @@ describe('a paired client launching into an existing workspace', () => {
)
})
it.each(['focused', 'background'] as const)(
'keeps a paired %s chat inactive on the host',
async (presentation) => {
const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE })
await launch({ ...EXISTING_LAUNCH, presentation }, runtime)
expect(chatActivation()).toBe(false)
expect(runtime.selectCreatedMobileSessionTabForClient).toHaveBeenCalledTimes(
presentation === 'focused' ? 1 : 0
)
}
)
it('focuses a paired terminal only for the caller', async () => {
const runtime = selectionRuntime({ settings: {}, terminalPaneKey: PANE_KEY })
await launch({ ...EXISTING_LAUNCH, presentation: 'focused' }, runtime)
expect(runtime.createTerminal).toHaveBeenCalledWith(
'id:wt-7',
expect.objectContaining({ presentation: 'background', surfaceOwner: false })
)
expect(runtime.selectCreatedMobileSessionTabForClient).toHaveBeenCalledOnce()
})
it('selects nothing when the runtime reported no pane for the terminal', async () => {
const runtime = selectionRuntime({ settings: {} })
@@ -267,16 +267,22 @@ describe('the instant tab', () => {
await replayLaunch(runtime, { presentation: 'focused' }, CLI)
expect(terminalOptions(runtime)).toMatchObject({ surfaceOwner: false })
expect(terminalOptions(runtime)).not.toHaveProperty('presentation')
})
it('reveals the spawn as before when the window never said it showed the tab', async () => {
it('defers viewer policy to the host reveal when early publication failed', async () => {
const runtime = hostWithWindow({ reply: new Error('renderer_unavailable') })
const result = await replayLaunch(runtime, {}, CLI)
expect(result.outcome.kind).toBe('terminal')
expect(result.placement).toBeUndefined()
expect(terminalOptions(runtime)).not.toHaveProperty('surfaceOwner')
expect(terminalOptions(runtime)).toMatchObject({ surfaceOwner: false })
const viewer = terminalOptions(runtime).agentLaunchViewer
expect(typeof viewer).toBe('function')
if (typeof viewer === 'function') {
expect(viewer()).toBe('reveal-owner')
}
})
it('records the pane it showed with the launch, and lets the pane attach once the agent runs', async () => {
@@ -39,7 +39,8 @@ import {
import { AgentLaunchTabClosedError } from '../../../../shared/agent-launch-tab-closed'
import {
agentLaunchMovesHostWindow,
type EarlyAgentLaunchTab
agentLaunchTabViewerRule,
type AgentLaunchView
} from './agent-launch-tab-publication'
/** Replay-safe launches keep the nested attach in the same stable caller namespace as the launch. */
@@ -52,9 +53,10 @@ export function agentLaunchSurfaceFactory(
terminalSpawn: TerminalSpawnDispatch = trackTerminalSpawnDispatch(),
// The tab shown before the launch ran; asked at spawn, since the window's answer and the user's
// close can both land after admission.
earlyTab: Pick<EarlyAgentLaunchTab, 'windowShowsTab' | 'closedByUser'> | null = null
view: AgentLaunchView = { early: null, presentation: undefined }
): AgentLaunchSurfaceFactory {
const movesHostWindow = agentLaunchMovesHostWindow(context)
const { early: earlyTab, presentation } = view
return {
createStructuredSession: async ({
worktreeId,
@@ -88,8 +90,9 @@ export function agentLaunchSurfaceFactory(
agent,
...(seeded ? { options: seeded } : {}),
...(tabId ? { tabId } : {}),
// The user asked for this chat, so it takes the surface — unlike a dispatched worker.
activate: !callerPresentsSurface
activate:
!callerPresentsSurface &&
(presentation === undefined || (presentation === 'focused' && movesHostWindow))
})
if (!created.ok) {
// The caller named this session, so a taken id is its answer, not an opaque refusal; and not
@@ -132,6 +135,7 @@ export function agentLaunchSurfaceFactory(
}) => {
const launchPreferences = toAgentLaunchPreferences(options)
let promptRodeLaunchCommand = false
const hasEarlyTab = earlyTab !== null
if (earlyTab?.closedByUser()) {
// The user closed its tab while it waited: nothing is spawned, and that is the answer.
terminalSpawn.rethrow(new AgentLaunchTabClosedError())
@@ -158,7 +162,18 @@ export function agentLaunchSurfaceFactory(
...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}),
...(launchSource ? { launchSource } : {}),
...(viewMode ? { viewMode } : {}),
...(earlyTab?.windowShowsTab() || !movesHostWindow ? { surfaceOwner: false as const } : {}),
...(presentation && (!movesHostWindow || !hasEarlyTab || presentation === 'background')
? { presentation: movesHostWindow ? presentation : ('background' as const) }
: {}),
...(hasEarlyTab || !movesHostWindow ? { surfaceOwner: false as const } : {}),
...(earlyTab
? {
agentLaunchViewer: () =>
earlyTab.windowOwnsPresentation()
? ('none' as const)
: agentLaunchTabViewerRule(context, presentation)
}
: {}),
onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched
})
const terminal = await created.catch(terminalSpawn.rethrow)
@@ -97,8 +97,8 @@ export type EarlyAgentLaunchTab = {
executing(): void
/** The surface exists; an agent in this pane lets the pane's waiting spawn attach to it now. */
surfacePublished(result: AgentLaunchResult): void
/** The window has said it shows the tab, so the spawn must bind it without a second reveal. */
windowShowsTab(): boolean
/** Pending or completed publication owns selection; a failed publication leaves it to spawn. */
windowOwnsPresentation(): boolean
/** The user closed the tab or pane: the launch must not start its agent, or must stop it. */
closedByUser(): boolean
/** The launch is over, however it ended. A tab this request made goes when nothing will ever run
@@ -252,6 +252,7 @@ function trackEarlyAgentLaunchTab(args: {
unspawnedVerdict: () => AgentLaunchPaneVerdict
}): EarlyAgentLaunchTab {
let reply: AgentLaunchTabPublished | null = null
let publicationFailed = false
let executing = false
let ranHere: boolean | null = null
const published = args.publishing.then(
@@ -260,6 +261,7 @@ function trackEarlyAgentLaunchTab(args: {
return answer
},
(error: unknown) => {
publicationFailed = true
// The tab did not appear; the launch still runs and its tab appears when it spawns.
console.warn('[agent-launch] the window did not show the launch tab early', error)
return null
@@ -278,7 +280,7 @@ function trackEarlyAgentLaunchTab(args: {
args.agentBound()
}
},
windowShowsTab: () => reply !== null,
windowOwnsPresentation: () => !publicationFailed,
closedByUser: args.closedByUser,
finish: () => {
if (ranHere === true) {
+1 -1
View File
@@ -128,7 +128,7 @@ async function runAgentLaunch(
replaySafe?.callerKey,
callerNavigationId !== null,
replaySafe?.terminalSpawn,
view.early
view
),
workspaces: agentLaunchWorkspaceFactory(context, intent.agent),
...(callerRendersLaunchedChat(context, intent.agent) ? {} : { callerRendersStructured: false }),
@@ -1,12 +1,5 @@
import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority'
import {
findCatalogModel,
findCatalogOption
} from '../../../../../../shared/agent-session-option-catalog'
import {
getAgentSessionOptionLaunchCatalog,
resolveAgentSessionOptionLaunch
} from '../../../../../../shared/agent-session-option-launch'
import { checkAgentLaunchModelPreferences } from '../../../../../../shared/agent-launch-model-preferences'
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version'
import type { TuiAgent } from '../../../../../../shared/tui-agent'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
@@ -60,72 +53,11 @@ export function resolveWorkerLaunchPreferences(args: {
preferences: AgentLaunchPreferences | undefined
receipt: OrchestrationWorkerLaunchReceipt
} {
if (args.effort && !args.model) {
throw new OrchestrationError('invalid_argument', '--effort requires --model.')
const checked = checkAgentLaunchModelPreferences(args)
if ('refusal' in checked) {
throw new OrchestrationError(checked.refusal.code, checked.refusal.message)
}
if (!args.model) {
return {
preferences: undefined,
receipt: createWorkerLaunchReceipt({ agent: args.agent })
}
}
if (args.agent === 'opencode' && args.createsWorktree) {
throw new OrchestrationError(
'capability_unsupported',
'OpenCode model selection requires an existing worktree. Use --worktree current or an existing worktree selector, or omit --model.'
)
}
if (args.agent === 'opencode' && args.openCodeModelLaunchSupported !== true) {
throw new OrchestrationError(
'capability_unsupported',
'This OpenCode TUI cannot verify launch-time model selection. Omit --model or use a supported OpenCode CLI.'
)
}
const catalog = getAgentSessionOptionLaunchCatalog(args.agent)
if (!catalog?.supportsWorkerLaunchPreferences || !catalog.modelApply.launchArgs) {
throw new OrchestrationError(
'invalid_argument',
`Agent ${args.agent} does not support launch-time model selection. Omit --model to run the model from its own config.`
)
}
if (args.effort) {
const model = findCatalogModel(catalog, args.model)
const option =
findCatalogOption(model, 'effort') ??
(!model
? catalog.unknownModelOptions?.find((candidate) => candidate.id === 'effort')
: undefined)
if (
option?.kind.type !== 'select' ||
!option.kind.choices.some((choice) => choice.value === args.effort)
) {
throw new OrchestrationError(
'invalid_argument',
`Agent ${args.agent} model ${args.model} does not support effort ${args.effort}.`
)
}
}
const requested = {
model: args.model,
...(args.effort ? { effort: args.effort } : {})
}
const resolved = resolveAgentSessionOptionLaunch(args.agent, requested, [], false)
if (
resolved.appliedValues.model !== args.model ||
resolved.appliedValues.effort !== args.effort
) {
throw new OrchestrationError(
'invalid_argument',
`Agent ${args.agent} cannot apply the requested worker launch preferences.`
)
}
const preferences: AgentLaunchPreferences = requested
const { preferences } = checked
return {
preferences,
receipt: createWorkerLaunchReceipt({ agent: args.agent, ...preferences })
@@ -2,6 +2,7 @@ import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type {
AgentLaunchTabPublished,
AgentLaunchTabViewerRule,
AgentLaunchTabPublishRequest
} from '../../shared/agent-launch-tab-publication'
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
@@ -70,6 +71,7 @@ export type RuntimeNotifier = {
activate?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
agentLaunchViewer?: AgentLaunchTabViewerRule
tabId?: string
leafId?: string
splitFromLeafId?: string
@@ -1,3 +1,4 @@
import type { AgentLaunchTabViewerRule } from '../../shared/agent-launch-tab-publication'
import type { SpawnIntent } from '../../shared/host-spawn-admission'
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
import type {
@@ -81,6 +82,8 @@ export type TerminalCreateOptions = {
activate?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
/** Main-internal: recheck early publication when the host is ready to reveal its pane. */
agentLaunchViewer?: () => AgentLaunchTabViewerRule
tabId?: string
leafId?: string
/** Refuse, rather than attach, when `tabId`/`leafId` name a pane whose PTY is already live. */
@@ -130,6 +130,7 @@ export function registerRuntimeWindowLifecycle(
activate: opts.activate !== false,
...(opts.presentation ? { presentation: opts.presentation } : {}),
...(opts.surfaceOwner === false ? { surfaceOwner: false } : {}),
...(opts.agentLaunchViewer ? { agentLaunchViewer: opts.agentLaunchViewer } : {}),
// Why: pre-minted tabId aligns the renderer tab id with the paneKey baked into the PTY env, so hook events route right.
...(opts.tabId !== undefined ? { tabId: opts.tabId } : {}),
...(opts.leafId !== undefined ? { leafId: opts.leafId } : {}),
@@ -3,6 +3,7 @@ import type { TerminalPaneSplitSource } from '../../shared/feature-education-tel
import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity'
import type {
AgentLaunchTabPublishReply,
AgentLaunchTabViewerRule,
AgentLaunchTabPublishRequest
} from '../../shared/agent-launch-tab-publication'
import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict'
@@ -38,6 +39,7 @@ export const uiTerminalAndSessionTabsApi = {
focus?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
agentLaunchViewer?: AgentLaunchTabViewerRule
tabId?: string
leafId?: string
splitFromLeafId?: string
@@ -64,6 +66,7 @@ export const uiTerminalAndSessionTabsApi = {
focus?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
agentLaunchViewer?: AgentLaunchTabViewerRule
tabId?: string
leafId?: string
splitFromLeafId?: string
+2
View File
@@ -3,6 +3,7 @@ import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type {
AgentLaunchTabPublishReply,
AgentLaunchTabViewerRule,
AgentLaunchTabPublishRequest
} from '../../shared/agent-launch-tab-publication'
import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict'
@@ -171,6 +172,7 @@ export type UiCommandEventApi = {
focus?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
agentLaunchViewer?: AgentLaunchTabViewerRule
tabId?: string
leafId?: string
splitFromLeafId?: string
@@ -1,3 +1,4 @@
import { presentAgentLaunchTab } from '@/lib/agent-launch-tab-presentation'
import { releaseAgentLaunchPaneSpawn } from '@/lib/agent-launch-pane-spawn-hold'
import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount'
import { hasRegisteredRuntimeTerminalTab } from '@/runtime/mounted-terminal-tab-registry'
@@ -38,6 +39,7 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
focus,
presentation,
surfaceOwner,
agentLaunchViewer,
tabId,
leafId,
splitFromLeafId,
@@ -51,8 +53,12 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
activate,
focus
})
const shouldActivate = terminalPresentation === 'focused'
const shouldSurfaceOwner = terminalPresentation !== 'background' && surfaceOwner !== false
const shouldActivate =
agentLaunchViewer === undefined && terminalPresentation === 'focused'
const shouldSurfaceOwner =
agentLaunchViewer === undefined &&
terminalPresentation !== 'background' &&
surfaceOwner !== false
if (shouldActivate) {
activateTerminalInitiatedWorktree(store, worktreeId)
}
@@ -96,7 +102,10 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
(ptyId
? store.createTab(worktreeId, undefined, undefined, {
initialPtyId: ptyId,
activate: shouldActivate,
activate:
shouldActivate ||
agentLaunchViewer === 'focus-window' ||
agentLaunchViewer === 'focus-in-workspace',
...(launchAgent
? {
launchAgent,
@@ -136,6 +145,9 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
store.revealWorktreeInSidebar(worktreeId)
focusTerminalInitiatedTab(tab.id, leafId, worktreeId)
}
if (agentLaunchViewer) {
presentAgentLaunchTab(agentLaunchViewer, worktreeId, tab.id, leafId)
}
// Why: only stamp the runtime title on fresh tabs; reused tabs may have a user customTitle it would overwrite on focus.
if (title && !reusedTab) {
store.setTabCustomTitle(tab.id, title, { recordInteraction: false })
@@ -0,0 +1,32 @@
import type { AgentLaunchTabViewerRule } from '../../../shared/agent-launch-tab-publication'
import {
activateTerminalInitiatedWorktree,
focusTerminalInitiatedTab
} from '../hooks/ipc-events/terminal-command-state'
import { useAppStore } from '../store'
/** Early publication and host reveal move the same viewer, under the same policy. */
export function presentAgentLaunchTab(
viewer: AgentLaunchTabViewerRule,
worktreeId: string,
tabId: string,
leafId?: string
): void {
if (viewer === 'focus-window') {
activateTerminalInitiatedWorktree(useAppStore.getState(), worktreeId, [tabId])
}
const current = useAppStore.getState()
const windowShowsTab =
viewer === 'focus-window' ||
(viewer === 'focus-in-workspace' && current.activeWorktreeId === worktreeId)
if (windowShowsTab) {
current.setActiveTabType('terminal', worktreeId)
current.setActiveTab(tabId)
}
if (windowShowsTab || viewer === 'reveal-owner') {
if (viewer !== 'focus-in-workspace') {
current.revealWorktreeInSidebar(worktreeId)
}
focusTerminalInitiatedTab(tabId, leafId, worktreeId)
}
}
@@ -12,10 +12,7 @@ import type {
AgentLaunchTabPublishRequest
} from '../../../shared/agent-launch-tab-publication'
import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree'
import {
activateTerminalInitiatedWorktree,
focusTerminalInitiatedTab
} from '../hooks/ipc-events/terminal-command-state'
import { presentAgentLaunchTab } from './agent-launch-tab-presentation'
import { useAppStore } from '../store'
import { resolveUnifiedTabCreatePlacement } from '../store/slices/tabs/tabs-create-placement'
import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion'
@@ -122,25 +119,7 @@ export function publishAgentLaunchTab(
insertUnifiedTabAfterAnchor(worktreeId, unifiedTabId, placement.anchorTabId)
}
}
if (request.viewer === 'focus-window') {
// After the tab lands: activating prunes the workspace's empty groups, the requested split too.
activateTerminalInitiatedWorktree(useAppStore.getState(), worktreeId, [tab.id])
}
const current = useAppStore.getState()
// The desktop's own launch stays in its workspace: the window follows only if it is still there.
const windowShowsTab =
request.viewer === 'focus-window' ||
(request.viewer === 'focus-in-workspace' && current.activeWorktreeId === worktreeId)
if (windowShowsTab) {
current.setActiveTabType('terminal', worktreeId)
current.setActiveTab(tab.id)
}
if (windowShowsTab || request.viewer === 'reveal-owner') {
if (request.viewer !== 'focus-in-workspace') {
current.revealWorktreeInSidebar(worktreeId)
}
focusTerminalInitiatedTab(tab.id, leafId, worktreeId)
}
presentAgentLaunchTab(request.viewer, worktreeId, tab.id, leafId)
const landed = landedGroupId(worktreeId, tab.id) ?? placement.groupId ?? ''
const fallback = placementFallback(request, landed, placement.anchorTabId !== undefined)
return {
@@ -118,3 +118,68 @@ describe('a local agent create watched to completion', () => {
expect(focusRuntimeTerminalSurface).toHaveBeenCalledWith(AGENT_TAB_ID, null, worktree.id)
})
})
describe('host reveal after early launch publication fails', () => {
it.each([
{ viewer: 'focus-in-workspace', moved: true, follows: false },
{ viewer: 'focus-in-workspace', moved: false, follows: true },
{ viewer: 'focus-window', moved: true, follows: true },
{ viewer: 'none', moved: true, follows: false }
] as const)(
'honors $viewer after navigation=$moved for new and existing panes',
({ viewer, moved, follows }) => {
const worktree = makeCreatedAgentWorktree()
const other = { ...worktree, id: `${worktree.id}-other` }
const { revealWorktreeInSidebar } = seedEmptyActivatableWorktree(worktree, {
extraWorktrees: [other]
})
const activeWorkspace = moved ? other.id : worktree.id
useAppStore.setState({ activeWorktreeId: activeWorkspace, activeTabId: null })
focusRuntimeTerminalSurface.mockClear()
const payload: CreateTerminalPayload = {
requestId: 'reveal-agent',
worktreeId: worktree.id,
ptyId: 'pty-agent',
launchAgent: 'codex',
activate: false,
surfaceOwner: false,
agentLaunchViewer: viewer,
tabId: AGENT_TAB_ID,
leafId: AGENT_LEAF_ID
}
revealTerminal(payload)
expect(useAppStore.getState().activeWorktreeId).toBe(follows ? worktree.id : activeWorkspace)
expect(useAppStore.getState().tabsByWorktree[worktree.id]?.map((tab) => tab.id)).toEqual([
AGENT_TAB_ID
])
expect(useAppStore.getState().terminalLayoutsByTabId[AGENT_TAB_ID]?.ptyIdsByLeafId).toEqual({
[AGENT_LEAF_ID]: 'pty-agent'
})
if (follows) {
expect(useAppStore.getState().activeTabId).toBe(AGENT_TAB_ID)
expect(focusRuntimeTerminalSurface).toHaveBeenCalledWith(
AGENT_TAB_ID,
AGENT_LEAF_ID,
worktree.id
)
} else {
expect(focusRuntimeTerminalSurface).not.toHaveBeenCalled()
expect(useAppStore.getState().activeTabId).toBeNull()
}
if (viewer === 'focus-in-workspace') {
expect(revealWorktreeInSidebar).not.toHaveBeenCalled()
}
// A reveal can find a tab restored while its earlier publication reply was lost.
useAppStore.setState({ activeWorktreeId: activeWorkspace, activeTabId: null })
focusRuntimeTerminalSurface.mockClear()
revealTerminal(payload)
expect(useAppStore.getState().activeWorktreeId).toBe(follows ? worktree.id : activeWorkspace)
expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
if (follows) {
expect(focusRuntimeTerminalSurface).toHaveBeenCalledOnce()
} else {
expect(focusRuntimeTerminalSurface).not.toHaveBeenCalled()
}
}
)
})
@@ -0,0 +1,99 @@
/**
* Whether a launch can start `agent` on the model and effort its caller named, by the same catalog
* the launch itself applies. One rule for every caller that takes `--model`/`--effort`, so an
* orchestration worker and a plain CLI launch refuse the same requests with the same words.
*/
import type { AgentLaunchPreferences } from './agent-session-host-authority'
import { findCatalogModel, findCatalogOption } from './agent-session-option-catalog'
import {
getAgentSessionOptionLaunchCatalog,
resolveAgentSessionOptionLaunch
} from './agent-session-option-launch'
import type { TuiAgent } from './tui-agent'
export type AgentLaunchModelPreferenceRefusal = {
code: 'invalid_argument' | 'capability_unsupported'
message: string
}
export type AgentLaunchModelPreferenceCheck =
| { preferences: AgentLaunchPreferences | undefined }
| { refusal: AgentLaunchModelPreferenceRefusal }
export function checkAgentLaunchModelPreferences(args: {
agent: TuiAgent
/** OpenCode needs the execution host to verify its CLI before it can start on a chosen model. */
openCodeModelLaunchSupported?: boolean
createsWorktree?: boolean
model?: string
effort?: string
}): AgentLaunchModelPreferenceCheck {
if (args.effort && !args.model) {
return refuse('invalid_argument', '--effort requires --model.')
}
if (!args.model) {
return { preferences: undefined }
}
if (args.agent === 'opencode' && args.createsWorktree) {
return refuse(
'capability_unsupported',
'OpenCode model selection requires an existing worktree. Use --worktree current or an existing worktree selector, or omit --model.'
)
}
if (args.agent === 'opencode' && args.openCodeModelLaunchSupported !== true) {
return refuse(
'capability_unsupported',
'This OpenCode TUI cannot verify launch-time model selection. Omit --model or use a supported OpenCode CLI.'
)
}
const catalog = getAgentSessionOptionLaunchCatalog(args.agent)
if (!catalog?.supportsWorkerLaunchPreferences || !catalog.modelApply.launchArgs) {
return refuse(
'invalid_argument',
`Agent ${args.agent} does not support launch-time model selection. Omit --model to run the model from its own config.`
)
}
if (args.effort) {
const model = findCatalogModel(catalog, args.model)
const option =
findCatalogOption(model, 'effort') ??
(!model
? catalog.unknownModelOptions?.find((candidate) => candidate.id === 'effort')
: undefined)
if (
option?.kind.type !== 'select' ||
!option.kind.choices.some((choice) => choice.value === args.effort)
) {
return refuse(
'invalid_argument',
`Agent ${args.agent} model ${args.model} does not support effort ${args.effort}.`
)
}
}
const requested: AgentLaunchPreferences = {
model: args.model,
...(args.effort ? { effort: args.effort } : {})
}
const resolved = resolveAgentSessionOptionLaunch(args.agent, requested, [], false)
if (
resolved.appliedValues.model !== args.model ||
resolved.appliedValues.effort !== args.effort
) {
return refuse(
'invalid_argument',
`Agent ${args.agent} cannot apply the requested launch preferences.`
)
}
return { preferences: requested }
}
function refuse(
code: AgentLaunchModelPreferenceRefusal['code'],
message: string
): AgentLaunchModelPreferenceCheck {
return { refusal: { code, message } }
}
@@ -38,6 +38,10 @@ export const AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY =
// may publish the launch's tab before it admits the launch.
export const AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY = 'agent.launch.placement.v1' as const
// Host-advertised: presentation reaches local chat and terminal creation, including CLI launches.
export const AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY =
'agent.launch.local-presentation.v1' as const
// Host-advertised: accepts `target.kind: 'create-folder-workspace'`. An older host refuses the
// unknown target kind, so a client offers the new-folder-workspace launch only on this. It is not
// enough on its own: the target also needs `folderWorkspace.create` authorization, which a
@@ -65,5 +69,6 @@ export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [
AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY,
AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY,
AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY,
AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY,
AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY
] as const