diff --git a/src/renderer/src/lib/launch-structured-codex-session.ts b/src/renderer/src/lib/launch-structured-codex-session.ts index b4deb731c28..5ba98707b91 100644 --- a/src/renderer/src/lib/launch-structured-codex-session.ts +++ b/src/renderer/src/lib/launch-structured-codex-session.ts @@ -16,6 +16,8 @@ import { resolveWebSessionVisibleTabId } from '@/runtime/web-session-focus-intent' import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-session-tabs-sync' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' type StructuredAgentSessionCreateParams = { envelope: AgentSessionMutationEnvelope @@ -26,6 +28,8 @@ type StructuredAgentSessionCreateParams = { export type StructuredAgentSessionLaunchIntent = { sessionId: string worktreeId: string + /** Execution host selected when the intent was created (old callers omit it). */ + target?: RuntimeClientTarget params: StructuredAgentSessionCreateParams } @@ -37,8 +41,12 @@ export function createStructuredCodexSessionLaunchIntent( const sessionId = `codex_${crypto.randomUUID().replaceAll('-', '_')}` const fields = { worktree: toRuntimeWorktreeSelector(worktreeId), agent: 'codex' as const } const state = useAppStore.getState() + const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + const target: RuntimeClientTarget = environmentId + ? { kind: 'environment', environmentId } + : { kind: 'local' } recordWebSessionFocusIntent( - { environmentId: LOCAL_STRUCTURED_SESSION_OWNER }, + { environmentId: environmentId ?? LOCAL_STRUCTURED_SESSION_OWNER }, worktreeId, `agent-session:${sessionId}`, undefined, @@ -47,6 +55,7 @@ export function createStructuredCodexSessionLaunchIntent( return { sessionId, worktreeId, + target, params: { envelope: { sessionId, @@ -67,7 +76,12 @@ export function abandonStructuredAgentSessionLaunchIntent( intent: StructuredAgentSessionLaunchIntent ): void { clearWebSessionFocusIntentIfMatches( - { environmentId: LOCAL_STRUCTURED_SESSION_OWNER }, + { + environmentId: + intent.target?.kind === 'environment' + ? intent.target.environmentId + : LOCAL_STRUCTURED_SESSION_OWNER + }, intent.worktreeId, `agent-session:${intent.sessionId}` ) @@ -78,7 +92,7 @@ export async function launchStructuredCodexSession( ): Promise { const result = await callStructuredAgentSession< AgentSessionMutationResult - >({ kind: 'local' }, 'agentSession.create', intent.params) + >(intent.target ?? { kind: 'local' }, 'agentSession.create', intent.params) if (!result.ok) { abandonStructuredAgentSessionLaunchIntent(intent) throw new StructuredAgentSessionCreateRefusalError(result.refusal.message) diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 01a4cd386ac..0ce3e715fbb 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -1,7 +1,5 @@ import { toast } from 'sonner' import { launchStructuredAgentSession } from '@/lib/launch-structured-agent-session' -import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync' -import { translate } from '@/i18n/i18n' import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { agentSessionProviderLabel } from '../../../shared/agent-session-provider-label' import { @@ -10,6 +8,10 @@ import { StructuredAgentSessionCreateRefusalError, type StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-codex-session' +import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { translate } from '@/i18n/i18n' type StructuredLaunchState = { promise: Promise @@ -150,7 +152,15 @@ const pendingCodexLaunches = new Map< >() async function verifyCodexPublished(intent: StructuredAgentSessionLaunchIntent): Promise { - const snapshots = await refreshLocalStructuredSessionTabs() + const target = intent.target ?? { kind: 'local' as const } + const snapshots = + target.kind === 'local' + ? await refreshLocalStructuredSessionTabs() + : await callStructuredAgentSession<{ snapshots?: RuntimeMobileSessionTabsResult[] }>( + target, + 'session.tabs.listAll', + {} + ).then((result) => result.snapshots ?? []) if ( !snapshots.some( (snapshot) => diff --git a/src/renderer/src/lib/structured-native-chat-availability.ts b/src/renderer/src/lib/structured-native-chat-availability.ts index bc14ccfd4f0..37079d13c09 100644 --- a/src/renderer/src/lib/structured-native-chat-availability.ts +++ b/src/renderer/src/lib/structured-native-chat-availability.ts @@ -1,7 +1,11 @@ import type { AppState } from '@/store/types' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' import { getRendererAppPlatform } from '@/lib/renderer-app-platform' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' export function canUseStructuredNativeChat(state: AppState, worktreeId: string): boolean { if (state.settings?.experimentalStructuredNativeChat !== true) { @@ -13,8 +17,19 @@ export function canUseStructuredNativeChat(state: AppState, worktreeId: string): if (state.settings?.openAgentTabsInChatByDefault !== true) { return false } - if (getExecutionHostIdForWorktree(state, worktreeId) !== 'local') { - return false + const executionHostId = getExecutionHostIdForWorktree(state, worktreeId) + if (executionHostId !== 'local') { + // Runtime-owned worktrees may use the paired structured host when its + // negotiated status is already known. SSH ownership deliberately remains + // on the legacy terminal bridge: it has no agent-session RPC transport. + if (!executionHostId.startsWith('runtime:')) { + return false + } + const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + const status = environmentId + ? state.runtimeStatusByEnvironmentId?.get(environmentId)?.status + : undefined + return status?.capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) === true } // The shipped Windows process-tree addon may not expose creation time. Until // the host advertises that proof, refuse every local Windows execution path — diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index ed235094473..1113d2348b3 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -166,6 +166,8 @@ export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 3797f08ffb9..c0f1cc36772 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -1,5 +1,7 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, @@ -19,6 +21,10 @@ export function remoteRuntimeClientCapabilities( SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + // Paired runtimes only expose the structured agent-session surface when + // the client explicitly proves it can render and drive those tabs. + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-shared-control-protocol.ts b/src/shared/remote-runtime-shared-control-protocol.ts index e7857a08d69..dad7301a288 100644 --- a/src/shared/remote-runtime-shared-control-protocol.ts +++ b/src/shared/remote-runtime-shared-control-protocol.ts @@ -95,6 +95,22 @@ export function getCleanupRequest( params: { subscriptionId: subscription.requestId } } } + // Structured agent sessions use a per-request subscription id so several + // surfaces can watch one session over a shared relay without evicting each + // other. Replay the exact id on close to release the host hold. + if (subscription.method === 'agentSession.subscribe') { + const sessionId = + typeof subscription.params === 'object' && subscription.params !== null + ? (subscription.params as { sessionId?: unknown }).sessionId + : undefined + if (typeof sessionId !== 'string' || sessionId.length === 0) { + return null + } + return { + method: 'agentSession.unsubscribe', + params: { sessionId, subscriptionId: subscription.requestId } + } + } return null }