feat(remote): route structured native chat to paired hosts

This commit is contained in:
Merge Sim
2026-08-31 17:44:31 -07:00
parent 42947128ec
commit e95fb2cc86
6 changed files with 72 additions and 9 deletions
@@ -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<string> {
const result = await callStructuredAgentSession<
AgentSessionMutationResult<AgentSessionAttachResult>
>({ 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)
@@ -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<string>
@@ -150,7 +152,15 @@ const pendingCodexLaunches = new Map<
>()
async function verifyCodexPublished(intent: StructuredAgentSessionLaunchIntent): Promise<string> {
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) =>
@@ -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 —
+2
View File
@@ -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,
@@ -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,
@@ -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
}