mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
feat(native-chat): manage MCP servers from /mcp in Claude and Codex chats
/mcp in a Claude or Codex chat now opens an MCP servers panel instead of sending the text to the agent, where SDK mode only printed a one-line summary. The panel lists each server's status and lets you sign in, reconnect, and turn servers on or off. Claude goes through the Agent SDK's control requests on the running chat, including the runtime-only mcp_authenticate and mcp_oauth_callback_url. Codex goes through app-server: mcpServerStatus/list, mcpServer/oauth/login and config/batchWrite, with short-lived app-servers for chats at rest. Sign-in opens the browser; when the agent runs on another host, the user pastes the address the browser lands on.
This commit is contained in:
@@ -19,7 +19,7 @@ import type {
|
||||
NativeChatSessionSkill,
|
||||
SlashCommandSuggestion
|
||||
} from '../../../../../src/shared/native-chat/input/native-chat-slash-commands'
|
||||
import { structuredSessionSlashCommands } from '../../../../../src/shared/agent-session/view/structured-agent-session-composer'
|
||||
import { structuredSessionSlashCommands } from '../../../../../src/shared/agent-session/view/structured-agent-session-slash-menu'
|
||||
|
||||
// Why: the menu is a plain ScrollView right above a streaming transcript, so it mounts a bounded
|
||||
// number of rows; typing narrows the rest.
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type AgentSessionAccountHome
|
||||
} from '../../../shared/agent-session/lifecycle/agent-session-account-home'
|
||||
import type { AgentSessionSlashCommand } from '../../../shared/agent-session/agent-session-wire'
|
||||
import { structuredSlashCommands } from '../../../shared/agent-session/view/structured-agent-session-composer'
|
||||
import { structuredSlashCommands } from '../../../shared/agent-session/view/structured-agent-session-slash-menu'
|
||||
import { discoverSkills } from '../../skills/discovery/skill-native-discovery'
|
||||
import { scanClaudeCommandFolders } from './claude-command-folder-scan'
|
||||
import { supportsClaudeStructuredLocation } from '../structured/launch/claude-structured-location-support'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
McpServerStatus,
|
||||
PermissionMode,
|
||||
Query,
|
||||
SDKControlInterruptResponse
|
||||
@@ -93,6 +94,88 @@ export function runClaudeControl<T>(
|
||||
})
|
||||
}
|
||||
|
||||
export type ClaudeMcpAuthentication = {
|
||||
authUrl?: string
|
||||
/** True when the CLI listens for the browser's redirect and finishes sign-in itself. */
|
||||
callbackExpected?: boolean
|
||||
}
|
||||
|
||||
export type ClaudeMcpControls = {
|
||||
status: () => Promise<McpServerStatus[]>
|
||||
/** Waits for the server to connect. */
|
||||
reconnect: (name: string) => Promise<void>
|
||||
/** Persists like the TUI's toggle. */
|
||||
toggle: (name: string, enabled: boolean) => Promise<void>
|
||||
authenticate: (name: string) => Promise<ClaudeMcpAuthentication>
|
||||
/** Exchanges the code but leaves the server needing a reconnect. */
|
||||
submitOAuthCallbackUrl: (name: string, callbackUrl: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** mcp_authenticate's reply; only the fields the panel acts on. */
|
||||
function readClaudeMcpAuthentication(reply: unknown): ClaudeMcpAuthentication {
|
||||
if (typeof reply !== 'object' || reply === null) {
|
||||
return {}
|
||||
}
|
||||
const authUrl = 'authUrl' in reply ? reply.authUrl : undefined
|
||||
const callbackExpected = 'callbackExpected' in reply ? reply.callbackExpected : undefined
|
||||
return {
|
||||
...(typeof authUrl === 'string' ? { authUrl } : {}),
|
||||
...(typeof callbackExpected === 'boolean' ? { callbackExpected } : {})
|
||||
}
|
||||
}
|
||||
|
||||
// mcpAuthenticate and mcpSubmitOAuthCallbackUrl exist at runtime but are absent from Query's
|
||||
// declaration.
|
||||
function claudeQueryMcpAuthenticator(query: Query): ((name: string) => unknown) | null {
|
||||
const authenticate = 'mcpAuthenticate' in query ? query.mcpAuthenticate : null
|
||||
return typeof authenticate === 'function'
|
||||
? (name): unknown => authenticate.call(query, name)
|
||||
: null
|
||||
}
|
||||
|
||||
function claudeQueryMcpCallbackSubmitter(
|
||||
query: Query
|
||||
): ((name: string, callbackUrl: string) => unknown) | null {
|
||||
const submit = 'mcpSubmitOAuthCallbackUrl' in query ? query.mcpSubmitOAuthCallbackUrl : null
|
||||
return typeof submit === 'function'
|
||||
? (name, callbackUrl): unknown => submit.call(query, name, callbackUrl)
|
||||
: null
|
||||
}
|
||||
|
||||
const missingClaudeControl = (subtype: string): Promise<never> =>
|
||||
Promise.reject(new ClaudeControlRequestError(subtype, `this SDK exposes no ${subtype} request`))
|
||||
|
||||
function createClaudeMcpControls(query: Query): ClaudeMcpControls {
|
||||
const authenticate = claudeQueryMcpAuthenticator(query)
|
||||
const submitCallback = claudeQueryMcpCallbackSubmitter(query)
|
||||
return {
|
||||
status: () => runClaudeControl('mcp_status', () => query.mcpServerStatus(), 15_000),
|
||||
reconnect: (name) =>
|
||||
runClaudeControl('mcp_reconnect', () => query.reconnectMcpServer(name), 30_000),
|
||||
toggle: (name, enabled) =>
|
||||
runClaudeControl('mcp_toggle', () => query.toggleMcpServer(name, enabled), 30_000),
|
||||
authenticate: (name) =>
|
||||
runClaudeControl(
|
||||
'mcp_authenticate',
|
||||
() =>
|
||||
authenticate
|
||||
? Promise.resolve(authenticate(name))
|
||||
: missingClaudeControl('mcp_authenticate'),
|
||||
15_000
|
||||
).then(readClaudeMcpAuthentication),
|
||||
// The token exchange happens inside this request.
|
||||
submitOAuthCallbackUrl: (name, callbackUrl) =>
|
||||
runClaudeControl(
|
||||
'mcp_oauth_callback_url',
|
||||
() =>
|
||||
submitCallback
|
||||
? Promise.resolve(submitCallback(name, callbackUrl))
|
||||
: missingClaudeControl('mcp_oauth_callback_url'),
|
||||
30_000
|
||||
).then(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export type ClaudeControlSurface = {
|
||||
interrupt: (
|
||||
options?: ClaudeControlOptions & { cancelQueued?: boolean }
|
||||
@@ -115,6 +198,8 @@ export type ClaudeControlSurface = {
|
||||
/** Names `sessionId` as the user's rename; refused once the CLI has moved to another session. */
|
||||
renameSession: (title: string, sessionId: string, options?: ClaudeControlOptions) => Promise<void>
|
||||
generateSessionTitle?: (description: string) => Promise<unknown>
|
||||
/** The `/mcp` panel's requests; test connections leave it out. */
|
||||
mcp?: ClaudeMcpControls
|
||||
}
|
||||
|
||||
type InterruptingQuery = {
|
||||
@@ -126,6 +211,7 @@ type InterruptingQuery = {
|
||||
export function createClaudeControlSurface(query: Query): ClaudeControlSurface {
|
||||
const title = 'generateSessionTitle' in query ? query.generateSessionTitle : null
|
||||
return {
|
||||
mcp: createClaudeMcpControls(query),
|
||||
...(typeof title === 'function'
|
||||
? {
|
||||
generateSessionTitle: (description: string) =>
|
||||
|
||||
@@ -13,6 +13,7 @@ export const CLAUDE_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = {
|
||||
compact: true,
|
||||
compactInstructions: true,
|
||||
compactBesideBackgroundTasks: true,
|
||||
mcpServers: true,
|
||||
threadGoal: false,
|
||||
contextUsage: true,
|
||||
imagePrompts: true,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { McpServerStatus } from '@anthropic-ai/claude-agent-sdk'
|
||||
import {
|
||||
agentSessionMcpFailureMessage,
|
||||
agentSessionMcpWebUrl,
|
||||
type AgentSessionMcpOperation,
|
||||
type AgentSessionMcpResult,
|
||||
type AgentSessionMcpServer,
|
||||
type AgentSessionMcpSignIn
|
||||
} from '../../../shared/agent-session/agent-session-mcp'
|
||||
import {
|
||||
isClaudeQueryClosed,
|
||||
type ClaudeMcpControls
|
||||
} from '../sdk-transport/claude-agent-sdk-control-requests'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
|
||||
// The CLI refuses OAuth for stdio and sdk servers.
|
||||
const SIGN_IN_TRANSPORTS = new Set(['http', 'sse', 'claudeai-proxy'])
|
||||
|
||||
export function claudeMcpServer(row: McpServerStatus): AgentSessionMcpServer {
|
||||
return {
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
source: row.source ?? row.scope ?? null,
|
||||
toolCount: row.status === 'connected' ? (row.tools?.length ?? 0) : null,
|
||||
error: row.status === 'failed' ? agentSessionMcpFailureMessage(row.error ?? '') : null,
|
||||
// A disabled server must be enabled before the CLI will authenticate it.
|
||||
canSignIn: SIGN_IN_TRANSPORTS.has(row.config?.type ?? 'stdio') && row.status !== 'disabled',
|
||||
canReconnect: row.status !== 'disabled',
|
||||
canToggle: true
|
||||
}
|
||||
}
|
||||
|
||||
async function runClaudeMcpOperation(
|
||||
mcp: ClaudeMcpControls,
|
||||
operation: AgentSessionMcpOperation
|
||||
): Promise<AgentSessionMcpSignIn | undefined> {
|
||||
switch (operation.action) {
|
||||
case 'status':
|
||||
return undefined
|
||||
case 'signIn': {
|
||||
const { authUrl, callbackExpected } = await mcp.authenticate(operation.server)
|
||||
const url = agentSessionMcpWebUrl(authUrl)
|
||||
// claude.ai connectors finish on claude.ai; others wait on the CLI's localhost callback.
|
||||
return url
|
||||
? { server: operation.server, url, finish: callbackExpected ? 'callback' : 'external' }
|
||||
: undefined
|
||||
}
|
||||
case 'finishSignIn':
|
||||
await mcp.submitOAuthCallbackUrl(operation.server, operation.callbackUrl)
|
||||
await mcp.reconnect(operation.server)
|
||||
return undefined
|
||||
case 'reconnect':
|
||||
await mcp.reconnect(operation.server)
|
||||
return undefined
|
||||
case 'toggle':
|
||||
await mcp.toggle(operation.server, operation.enabled)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function manageClaudeMcp(
|
||||
sessions: ReadonlyMap<string, ClaudeSession>,
|
||||
sessionId: string,
|
||||
operation: AgentSessionMcpOperation
|
||||
): Promise<AgentSessionMcpResult> {
|
||||
const session = sessions.get(sessionId)
|
||||
const connection = session?.connection
|
||||
if (!session || !connection || connection.closed) {
|
||||
return { status: 'not-running' }
|
||||
}
|
||||
const mcp = connection.mcp
|
||||
if (!mcp) {
|
||||
return { status: 'unsupported' }
|
||||
}
|
||||
const live = (): boolean =>
|
||||
sessions.get(sessionId) === session && session.connection === connection && !connection.closed
|
||||
try {
|
||||
const signIn = await runClaudeMcpOperation(mcp, operation)
|
||||
const servers = (await mcp.status()).map(claudeMcpServer)
|
||||
if (!live()) {
|
||||
return { status: 'not-running' }
|
||||
}
|
||||
return signIn ? { status: 'ok', servers, signIn } : { status: 'ok', servers }
|
||||
} catch (error) {
|
||||
if (!live() || isClaudeQueryClosed(error)) {
|
||||
return { status: 'not-running' }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { dispatchClaudeCommand } from './claude-structured-command-dispatch'
|
||||
import { isKillDeliveredExitReceipt } from './claude-kill-delivered-exit-receipt'
|
||||
import { manageClaudeMcp } from './claude-structured-mcp'
|
||||
import { claudeAcquireFailure } from '../sdk-transport/claude-startup-failure'
|
||||
import { claudeStartPublication, openClaudeStart } from './claude-structured-start-ending'
|
||||
import { AgentSessionStartEndings } from '../../native-chat/agent-session-wire/startup/structured-agent-session-start-failure'
|
||||
@@ -196,6 +197,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
this.sessions.has(sessionId) ? { supportsTaskStop: true, supportsStopAll: true } : undefined
|
||||
readCommands: NonNullable<StructuredAgentSessionAdapter['readCommands']> = (sessionId) =>
|
||||
this.sessions.get(sessionId)?.commands.commands
|
||||
manageMcp: NonNullable<StructuredAgentSessionAdapter['manageMcp']> = (record, operation) =>
|
||||
manageClaudeMcp(this.sessions, record.sessionId, operation)
|
||||
readonly atRestCommands: ClaudeStructuredSessionAdapterDeps['atRestCommands']
|
||||
holdsDispatch = (sessionId: string): boolean => claudeHoldsDispatch(this.sessions.get(sessionId))
|
||||
requiresModelTransitionRestart = claudeOptions.claudeModelTransitionRestart(this.sessions)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentSessionSlashCommand } from '../../../../shared/agent-session/agent-session-wire'
|
||||
import { structuredSlashCommands } from '../../../../shared/agent-session/view/structured-agent-session-composer'
|
||||
import { structuredSlashCommands } from '../../../../shared/agent-session/view/structured-agent-session-slash-menu'
|
||||
import type { CodexAppServerConnection } from '../../app-server/codex-app-server-connection-types'
|
||||
import {
|
||||
type CodexAppServerRpc,
|
||||
|
||||
@@ -11,6 +11,7 @@ export const CODEX_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = {
|
||||
rewind: true,
|
||||
fork: 'checkpoint',
|
||||
compact: true,
|
||||
mcpServers: true,
|
||||
// A goal change at rest starts the agent first.
|
||||
threadGoal: true,
|
||||
contextUsage: true,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { join } from 'node:path'
|
||||
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
|
||||
import { observeAgentStateFile } from '../../configuration/codex-path-observation'
|
||||
import { getSystemCodexHomePath } from '../../configuration/homes/codex-home-paths'
|
||||
import { observeCodexSettingsBaseline } from '../../configuration/settings/config-settings-baseline'
|
||||
import { readMcpServerTomlOwnership } from '../../configuration/toml/config-toml-mcp-servers'
|
||||
import { CODEX_MCP_RPC_TIMEOUT_MS, type CodexMcpRequest } from './codex-mcp-server-status'
|
||||
|
||||
export function writeCodexMcpEnabled(
|
||||
request: CodexMcpRequest,
|
||||
server: string,
|
||||
enabled: boolean
|
||||
): Promise<unknown> {
|
||||
return request(
|
||||
'config/batchWrite',
|
||||
{
|
||||
edits: [
|
||||
{ keyPath: `mcp_servers.${server}.enabled`, value: enabled, mergeStrategy: 'upsert' }
|
||||
],
|
||||
reloadUserConfig: true
|
||||
},
|
||||
{ timeoutMs: CODEX_MCP_RPC_TIMEOUT_MS }
|
||||
)
|
||||
}
|
||||
|
||||
/** A managed home re-mirrors servers declared in ~/.codex at every launch, so an `enabled` written
|
||||
* only there is reverted; those servers take the write in ~/.codex too. */
|
||||
export function codexMirrorSourceDeclaring(codexHome: string, server: string): string | null {
|
||||
const source = getSystemCodexHomePath()
|
||||
if (normalizeRuntimePathForComparison(codexHome) === normalizeRuntimePathForComparison(source)) {
|
||||
return null
|
||||
}
|
||||
const baseline = observeCodexSettingsBaseline(codexHome)
|
||||
if (baseline.kind === 'absent') {
|
||||
return null
|
||||
}
|
||||
const config = observeAgentStateFile(join(source, 'config.toml'))
|
||||
if (baseline.kind === 'indeterminate' || config.kind === 'indeterminate') {
|
||||
throw new Error("Orca couldn't read your Codex settings. Try again.")
|
||||
}
|
||||
if (config.kind === 'absent') {
|
||||
return null
|
||||
}
|
||||
const owner = readMcpServerTomlOwnership(config.value)
|
||||
return owner.ownsRoot || owner.names.has(server) ? source : null
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type {
|
||||
AgentSessionMcpServer,
|
||||
AgentSessionMcpServerStatus
|
||||
} from '../../../../shared/agent-session/agent-session-mcp'
|
||||
import type { CodexAppServerRpc } from '../../app-server/codex-app-server-session'
|
||||
|
||||
export type CodexMcpRequest = CodexAppServerRpc['request']
|
||||
|
||||
// Without a thread, Codex opens a fresh connection to every server to answer.
|
||||
export const CODEX_MCP_RPC_TIMEOUT_MS = 30_000
|
||||
const MAX_STATUS_PAGES = 20
|
||||
const ERROR_MAX_CHARS = 160
|
||||
|
||||
const RUNTIME_STATUS: Record<string, AgentSessionMcpServerStatus> = {
|
||||
connected: 'connected',
|
||||
notStarted: 'pending',
|
||||
starting: 'pending',
|
||||
authenticationRequired: 'needs-auth',
|
||||
failed: 'failed',
|
||||
cancelled: 'failed',
|
||||
disabled: 'disabled'
|
||||
}
|
||||
|
||||
type CodexMcpServerStatusEntry = {
|
||||
name: string
|
||||
runtimeStatus?: string | null
|
||||
pluginId?: string | null
|
||||
tools?: Record<string, unknown> | null
|
||||
toolsError?: string | null
|
||||
authStatus?: string | null
|
||||
}
|
||||
|
||||
type CodexMcpConfig = {
|
||||
disabled: ReadonlySet<string>
|
||||
/** Only servers in the home's own config.toml take an `enabled` write. */
|
||||
userDeclared: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
/** `threadId` reads the running thread's connections; without one Codex probes each server itself. */
|
||||
export async function listCodexMcpServers(
|
||||
request: CodexMcpRequest,
|
||||
scope: { threadId: string | null; cwd: string | null }
|
||||
): Promise<AgentSessionMcpServer[]> {
|
||||
const config = await readCodexMcpConfig(request, scope.cwd)
|
||||
const entries: CodexMcpServerStatusEntry[] = []
|
||||
let cursor: string | null = null
|
||||
for (let page = 0; page < MAX_STATUS_PAGES; page++) {
|
||||
const reply = await request(
|
||||
'mcpServerStatus/list',
|
||||
{
|
||||
detail: 'toolsAndAuthOnly',
|
||||
...(scope.threadId ? { threadId: scope.threadId } : {}),
|
||||
...(cursor ? { cursor } : {})
|
||||
},
|
||||
{ timeoutMs: CODEX_MCP_RPC_TIMEOUT_MS }
|
||||
)
|
||||
const data = isRecord(reply) && Array.isArray(reply.data) ? reply.data : []
|
||||
for (const entry of data) {
|
||||
if (isRecord(entry) && typeof entry.name === 'string') {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Codex's McpServerStatus; every field read below is optional.
|
||||
entries.push(entry as CodexMcpServerStatusEntry)
|
||||
}
|
||||
}
|
||||
cursor = isRecord(reply) && typeof reply.nextCursor === 'string' ? reply.nextCursor : null
|
||||
if (!cursor) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return entries.map((entry) => codexMcpServerRow(entry, config, scope.threadId !== null))
|
||||
}
|
||||
|
||||
async function readCodexMcpConfig(
|
||||
request: CodexMcpRequest,
|
||||
cwd: string | null
|
||||
): Promise<CodexMcpConfig> {
|
||||
const reply = await request(
|
||||
'config/read',
|
||||
{ includeLayers: true, ...(cwd ? { cwd } : {}) },
|
||||
{ timeoutMs: CODEX_MCP_RPC_TIMEOUT_MS }
|
||||
)
|
||||
const servers = isRecord(reply) && isRecord(reply.config) ? reply.config.mcp_servers : null
|
||||
const disabled = new Set(
|
||||
isRecord(servers)
|
||||
? Object.entries(servers)
|
||||
.filter(([, server]) => isRecord(server) && server.enabled === false)
|
||||
.map(([name]) => name)
|
||||
: []
|
||||
)
|
||||
const layers = isRecord(reply) && Array.isArray(reply.layers) ? reply.layers : []
|
||||
const user = layers.find(
|
||||
(layer): layer is Record<string, unknown> =>
|
||||
isRecord(layer) && isRecord(layer.name) && layer.name.type === 'user'
|
||||
)
|
||||
const userConfig = user?.config
|
||||
const userServers = isRecord(userConfig) ? userConfig.mcp_servers : null
|
||||
return { disabled, userDeclared: new Set(isRecord(userServers) ? Object.keys(userServers) : []) }
|
||||
}
|
||||
|
||||
function codexMcpServerRow(
|
||||
entry: CodexMcpServerStatusEntry,
|
||||
config: CodexMcpConfig,
|
||||
live: boolean
|
||||
): AgentSessionMcpServer {
|
||||
// Config decides disabled: a thread may still report the connection a toggle just closed.
|
||||
const status: AgentSessionMcpServerStatus = config.disabled.has(entry.name)
|
||||
? 'disabled'
|
||||
: entry.runtimeStatus
|
||||
? (RUNTIME_STATUS[entry.runtimeStatus] ?? 'pending')
|
||||
: entry.authStatus === 'notLoggedIn'
|
||||
? 'needs-auth'
|
||||
: entry.toolsError
|
||||
? 'failed'
|
||||
: 'connected'
|
||||
return {
|
||||
name: entry.name,
|
||||
status,
|
||||
source: entry.pluginId ? 'plugin' : null,
|
||||
toolCount: status === 'connected' ? Object.keys(entry.tools ?? {}).length : null,
|
||||
error: status === 'failed' ? readableCodexMcpError(entry.toolsError) : null,
|
||||
// Codex's OAuth callback listener lives in the app-server, which only a running chat keeps up.
|
||||
canSignIn:
|
||||
live &&
|
||||
status !== 'disabled' &&
|
||||
(entry.authStatus === 'notLoggedIn' || entry.authStatus === 'oAuth'),
|
||||
// `config/mcpServer/reload` restarts a failed server only on a running thread.
|
||||
canReconnect: live && status === 'failed',
|
||||
canToggle:
|
||||
!entry.pluginId && config.userDeclared.has(entry.name) && CODEX_MCP_TOML_KEY.test(entry.name)
|
||||
}
|
||||
}
|
||||
|
||||
/** The `enabled` write's key path takes the name as one bare TOML key segment. */
|
||||
export const CODEX_MCP_TOML_KEY = /^[\w-]+$/
|
||||
|
||||
/** Codex reports rmcp's error chain with Rust type paths; keep its innermost cause. */
|
||||
export function readableCodexMcpError(raw: string | null | undefined): string | null {
|
||||
const text = (raw ?? '')
|
||||
.replace(/\s*\[[^\]]*\]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
const at = text.lastIndexOf('error: ')
|
||||
const cause = (at !== -1 ? text.slice(at + 'error: '.length) : text)
|
||||
.replace(/, when send \w+ request$/, '')
|
||||
.trim()
|
||||
return cause ? `${cause[0].toUpperCase()}${cause.slice(1)}`.slice(0, ERROR_MAX_CHARS) : null
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const CALLBACK_FETCH_TIMEOUT_MS = 15_000
|
||||
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]'])
|
||||
|
||||
export function readRedirectUri(authorizationUrl: string): string | null {
|
||||
try {
|
||||
// A server-planted second redirect_uri could steer the host's GET elsewhere.
|
||||
const uris = new URL(authorizationUrl).searchParams.getAll('redirect_uri')
|
||||
return uris.length === 1 ? uris[0]! : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** A host-side GET of pasted input: only Codex's own loopback callback for this login. */
|
||||
export function matchingCodexMcpCallback(
|
||||
callbackUrl: string,
|
||||
redirectUri: string | null
|
||||
): URL | null {
|
||||
try {
|
||||
const url = new URL(callbackUrl.trim())
|
||||
const expected = new URL(redirectUri ?? '')
|
||||
return url.protocol === 'http:' &&
|
||||
LOOPBACK_HOSTS.has(url.hostname) &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
url.origin === expected.origin &&
|
||||
url.pathname === expected.pathname
|
||||
? url
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function deliverCodexMcpCallback(callback: URL): Promise<void> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(callback, {
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(CALLBACK_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
} catch {
|
||||
throw new Error('The sign-in is no longer waiting. Start it again.')
|
||||
}
|
||||
await response.body?.cancel()
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Codex refused that address (HTTP ${response.status}). Start the sign-in again.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -118,10 +118,17 @@ export async function renameCodexProviderSession(input: {
|
||||
export async function runCodexProviderAppServer<T>(
|
||||
record: AgentSessionRecord,
|
||||
invocation: CodexProviderInvocationDeps,
|
||||
options: { timeoutMs: number; signal?: AbortSignal; experimentalApi?: boolean },
|
||||
options: {
|
||||
timeoutMs: number
|
||||
signal?: AbortSignal
|
||||
experimentalApi?: boolean
|
||||
/** Another home than the chat's, e.g. the source of a mirrored home's config. */
|
||||
codexHome?: string
|
||||
},
|
||||
body: (rpc: CodexAppServerRpc, cwd: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
const codexHome = requireLegacyAgentSessionAccountHome(record.accountHome).path
|
||||
const { codexHome = requireLegacyAgentSessionAccountHome(record.accountHome).path, ...session } =
|
||||
options
|
||||
const cwd = await codexProviderProbeCwd(record, invocation)
|
||||
const { command, prefixArgs, environment } = await resolveCodexStructuredInvocation({
|
||||
...invocation,
|
||||
@@ -142,7 +149,7 @@ export async function runCodexProviderAppServer<T>(
|
||||
cliPath: command,
|
||||
env: { ...env, CODEX_HOME: codexHome },
|
||||
envToDelete: [NATIVE_CHAT_SCRATCH_DIR_ENV, LEGACY_NATIVE_CHAT_VISUALS_DIR_ENV],
|
||||
...options
|
||||
...session
|
||||
},
|
||||
(rpc) => body(rpc, cwd)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
agentSessionMcpWebUrl,
|
||||
type AgentSessionMcpResult,
|
||||
type AgentSessionMcpServer
|
||||
} from '../../../../shared/agent-session/agent-session-mcp'
|
||||
import { requireLegacyAgentSessionAccountHome } from '../../../../shared/agent-session/lifecycle/agent-session-account-home'
|
||||
import type { AgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-record'
|
||||
import {
|
||||
resolveCodexHomeProcessLockKey,
|
||||
withCodexHomeProcessLock
|
||||
} from '../../../codex-cli/codex-home-process-lock'
|
||||
import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/providers/structured-agent-session-adapter'
|
||||
import {
|
||||
CODEX_MCP_RPC_TIMEOUT_MS,
|
||||
CODEX_MCP_TOML_KEY,
|
||||
isRecord,
|
||||
listCodexMcpServers,
|
||||
type CodexMcpRequest
|
||||
} from './codex-mcp-server-status'
|
||||
import { codexMirrorSourceDeclaring, writeCodexMcpEnabled } from './codex-mcp-enabled-write'
|
||||
import {
|
||||
deliverCodexMcpCallback,
|
||||
matchingCodexMcpCallback,
|
||||
readRedirectUri
|
||||
} from './codex-mcp-sign-in-callback'
|
||||
import {
|
||||
runCodexProviderAppServer,
|
||||
type CodexProviderInvocationDeps
|
||||
} from './codex-provider-session-rename'
|
||||
import type { CodexSession } from './codex-structured-session-state'
|
||||
|
||||
const COLD_MCP_TIMEOUT_MS = 60_000
|
||||
// Codex's own default login window; its callback listener closes after it.
|
||||
const SIGN_IN_WINDOW_SECONDS = 300
|
||||
// Codex's own server-name rule (codex-mcp validate_mcp_server_name).
|
||||
const SERVER_NAME = /^[\w:@/.-]+$/
|
||||
const MISMATCH = "That address doesn't match the sign-in in progress."
|
||||
|
||||
type PendingSignIn = {
|
||||
server: string
|
||||
redirectUri: string | null
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
type CodexLogin = { authorizationUrl: string }
|
||||
|
||||
/** The `/mcp` panel's reads and actions: on the chat's own app-server while it runs, otherwise on
|
||||
* a short-lived one launched as the chat is, without resuming the thread. */
|
||||
export function manageCodexSessionMcp(
|
||||
sessions: Map<string, CodexSession>,
|
||||
readInvocation: () => CodexProviderInvocationDeps | undefined
|
||||
): NonNullable<StructuredAgentSessionAdapter['manageMcp']> {
|
||||
const signIns = new Map<string, PendingSignIn>()
|
||||
const invocationFor = (): CodexProviderInvocationDeps => {
|
||||
const invocation = readInvocation()
|
||||
if (!invocation) {
|
||||
throw new Error('Codex MCP invocation is not configured')
|
||||
}
|
||||
return invocation
|
||||
}
|
||||
|
||||
const cold = <T>(
|
||||
record: AgentSessionRecord,
|
||||
body: (request: CodexMcpRequest, cwd: string) => Promise<T>,
|
||||
codexHome = requireLegacyAgentSessionAccountHome(record.accountHome).path
|
||||
): Promise<T> =>
|
||||
withCodexHomeProcessLock(resolveCodexHomeProcessLockKey(codexHome), () =>
|
||||
runCodexProviderAppServer(
|
||||
record,
|
||||
invocationFor(),
|
||||
{ timeoutMs: COLD_MCP_TIMEOUT_MS, experimentalApi: true, codexHome },
|
||||
(rpc, cwd) => body(rpc.request, cwd)
|
||||
)
|
||||
)
|
||||
|
||||
const list = (
|
||||
record: AgentSessionRecord,
|
||||
live: CodexSession | null
|
||||
): Promise<AgentSessionMcpServer[]> => {
|
||||
if (live) {
|
||||
return listLive(live)
|
||||
}
|
||||
return cold(record, (request, cwd) => listCodexMcpServers(request, { threadId: null, cwd }))
|
||||
}
|
||||
|
||||
const toggle = async (
|
||||
record: AgentSessionRecord,
|
||||
live: CodexSession | null,
|
||||
server: string,
|
||||
enabled: boolean
|
||||
): Promise<AgentSessionMcpServer[]> => {
|
||||
const codexHome =
|
||||
live?.codexHome ?? requireLegacyAgentSessionAccountHome(record.accountHome).path
|
||||
const source = codexMirrorSourceDeclaring(codexHome, server)
|
||||
if (source) {
|
||||
await cold(record, (request) => writeCodexMcpEnabled(request, server, enabled), source)
|
||||
}
|
||||
if (live) {
|
||||
await writeCodexMcpEnabled(liveRequest(live), server, enabled)
|
||||
return listLive(live)
|
||||
}
|
||||
return cold(record, async (request, cwd) => {
|
||||
await writeCodexMcpEnabled(request, server, enabled)
|
||||
return listCodexMcpServers(request, { threadId: null, cwd })
|
||||
})
|
||||
}
|
||||
|
||||
return async (record, operation) => {
|
||||
const live = liveSession(sessions, record.sessionId)
|
||||
if (operation.action === 'status') {
|
||||
return ok(await list(record, live))
|
||||
}
|
||||
if (!SERVER_NAME.test(operation.server)) {
|
||||
throw new Error(`No MCP server named '${operation.server}'.`)
|
||||
}
|
||||
switch (operation.action) {
|
||||
case 'signIn': {
|
||||
// Codex's callback listener lives in the app-server, which only a running chat keeps up.
|
||||
if (!live) {
|
||||
return { status: 'not-running' }
|
||||
}
|
||||
const login = await requestLogin(liveRequest(live), operation.server, live.threadId)
|
||||
const servers = await listLive(live)
|
||||
signIns.set(record.sessionId, {
|
||||
server: operation.server,
|
||||
redirectUri: readRedirectUri(login.authorizationUrl),
|
||||
expiresAt: Date.now() + SIGN_IN_WINDOW_SECONDS * 1000
|
||||
})
|
||||
// The URL comes from the MCP server's OAuth metadata; clients open only web pages.
|
||||
const url = agentSessionMcpWebUrl(login.authorizationUrl)
|
||||
return url
|
||||
? { status: 'ok', servers, signIn: { server: operation.server, url, finish: 'callback' } }
|
||||
: ok(servers)
|
||||
}
|
||||
case 'finishSignIn': {
|
||||
const pending = signIns.get(record.sessionId)
|
||||
const callback =
|
||||
pending && pending.server === operation.server && pending.expiresAt > Date.now()
|
||||
? matchingCodexMcpCallback(operation.callbackUrl, pending.redirectUri)
|
||||
: null
|
||||
if (!callback) {
|
||||
throw new Error(MISMATCH)
|
||||
}
|
||||
await deliverCodexMcpCallback(callback)
|
||||
signIns.delete(record.sessionId)
|
||||
return ok(await list(record, live))
|
||||
}
|
||||
case 'reconnect': {
|
||||
if (!live) {
|
||||
return { status: 'not-running' }
|
||||
}
|
||||
await liveRequest(live)('config/mcpServer/reload', undefined, {
|
||||
timeoutMs: CODEX_MCP_RPC_TIMEOUT_MS
|
||||
})
|
||||
return ok(await listLive(live))
|
||||
}
|
||||
case 'toggle':
|
||||
if (!CODEX_MCP_TOML_KEY.test(operation.server)) {
|
||||
throw new Error(`Orca can't turn ${operation.server} on or off.`)
|
||||
}
|
||||
return ok(await toggle(record, live, operation.server, operation.enabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ok = (servers: AgentSessionMcpServer[]): AgentSessionMcpResult => ({ status: 'ok', servers })
|
||||
|
||||
function liveSession(sessions: Map<string, CodexSession>, sessionId: string): CodexSession | null {
|
||||
const session = sessions.get(sessionId)
|
||||
return session && !session.ended && !session.connection.closed ? session : null
|
||||
}
|
||||
|
||||
const liveRequest =
|
||||
(live: CodexSession): CodexMcpRequest =>
|
||||
(method, params, options) =>
|
||||
live.connection.request(method, params, options)
|
||||
|
||||
const listLive = (live: CodexSession): Promise<AgentSessionMcpServer[]> =>
|
||||
listCodexMcpServers(liveRequest(live), {
|
||||
threadId: live.threadId,
|
||||
cwd: live.permissions?.cwd ?? null
|
||||
})
|
||||
|
||||
async function requestLogin(
|
||||
request: CodexMcpRequest,
|
||||
name: string,
|
||||
threadId: string | null
|
||||
): Promise<CodexLogin> {
|
||||
const reply = await request(
|
||||
'mcpServer/oauth/login',
|
||||
{ name, timeoutSecs: SIGN_IN_WINDOW_SECONDS, ...(threadId ? { threadId } : {}) },
|
||||
{ timeoutMs: CODEX_MCP_RPC_TIMEOUT_MS }
|
||||
)
|
||||
const authorizationUrl = readStringField(reply, 'authorizationUrl')
|
||||
if (!authorizationUrl) {
|
||||
throw new Error(`Codex did not start a sign-in for ${name}.`)
|
||||
}
|
||||
return { authorizationUrl }
|
||||
}
|
||||
|
||||
function readStringField(value: unknown, key: string): string | null {
|
||||
const field = isRecord(value) ? value[key] : null
|
||||
return typeof field === 'string' ? field : null
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { copyCodexConversation } from '../../codex-conversation-copy'
|
||||
import { readCodexSessionAccountUsage } from './codex-session-account-usage'
|
||||
import { manageCodexSessionMcp } from './codex-session-mcp'
|
||||
import * as permission from '../turns/codex-structured-turn-permission'
|
||||
import { projectCodexSessionOptions } from '../catalog/codex-structured-service-tier'
|
||||
import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session/agent-session-background-task-wire'
|
||||
@@ -20,10 +21,7 @@ import {
|
||||
applyCodexStructuredSessionOption,
|
||||
readLiveCodexSessionOptions
|
||||
} from '../catalog/codex-structured-session-options'
|
||||
import {
|
||||
prepareCodexMessageDispatch,
|
||||
readCodexAppliedInputOptions
|
||||
} from '../turns/codex-structured-message-options'
|
||||
import * as messageOptions from '../turns/codex-structured-message-options'
|
||||
import {
|
||||
CodexAcquisitionRegistry,
|
||||
requireLiveCodexSession,
|
||||
@@ -87,6 +85,7 @@ export class CodexStructuredSessionAdapter implements SessionAdapter {
|
||||
copyConversation: NonNullable<SessionAdapter['copyConversation']> = (input) =>
|
||||
copyCodexConversation(input, this.deps.providerInvocation)
|
||||
readAccountUsage = readCodexSessionAccountUsage(this.sessions, () => this.deps.providerInvocation)
|
||||
manageMcp = manageCodexSessionMcp(this.sessions, () => this.deps.providerInvocation)
|
||||
|
||||
supportsLocation = supportsCodexStructuredLocation
|
||||
|
||||
@@ -245,7 +244,7 @@ export class CodexStructuredSessionAdapter implements SessionAdapter {
|
||||
try {
|
||||
const outcome = await dispatchCodexTurn(
|
||||
session,
|
||||
await prepareCodexMessageDispatch(
|
||||
await messageOptions.prepareCodexMessageDispatch(
|
||||
session,
|
||||
input,
|
||||
() => this.sessions.get(input.sessionId) === session
|
||||
@@ -326,7 +325,7 @@ export class CodexStructuredSessionAdapter implements SessionAdapter {
|
||||
readLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs)
|
||||
|
||||
readAppliedInputOptions: NonNullable<SessionAdapter['readAppliedInputOptions']> = (input) =>
|
||||
readCodexAppliedInputOptions(this.sessions.get(input.sessionId), input)
|
||||
messageOptions.readCodexAppliedInputOptions(this.sessions.get(input.sessionId), input)
|
||||
|
||||
projectOptions = (sessionId: string, options: Readonly<Record<string, string>>) =>
|
||||
projectCodexSessionOptions(this.sessions.get(sessionId), options)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
AgentSessionMcpOperation,
|
||||
AgentSessionMcpResult
|
||||
} from '../../../../shared/agent-session/agent-session-mcp'
|
||||
import type { PrepareStructuredRewind } from './structured-agent-session-rewind-contract'
|
||||
import type { ProviderProcessCloseOptions } from '../../../provider-process/provider-process-close'
|
||||
import type { NativeCopyCoverage } from '../../agent-session-journal/context-epochs/journal-history-origin'
|
||||
@@ -175,6 +179,11 @@ export type StructuredAgentSessionSetOptionInput = {
|
||||
|
||||
export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & {
|
||||
readAccountUsage?(record: AgentSessionRecord, signal: AbortSignal): Promise<CodexAccountUsageRead>
|
||||
/** The `/mcp` panel's read or action on the chat's MCP servers; never enters the mutation queue. */
|
||||
manageMcp?(
|
||||
record: AgentSessionRecord,
|
||||
operation: AgentSessionMcpOperation
|
||||
): Promise<AgentSessionMcpResult>
|
||||
open: StructuredAgentSessionProviderOpen
|
||||
supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean
|
||||
/** Keep runtime/location support in adapter data for remote enablement. */
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
agentSessionMcpFailureMessage,
|
||||
type AgentSessionMcpResult
|
||||
} from '../../../../../../shared/agent-session/agent-session-mcp'
|
||||
import { defineMethod } from '../../../core'
|
||||
import { requireInstalledStructuredHost } from '../creation/structured-agent-session-gate'
|
||||
import { AgentSessionMcpParams } from '../../../../../../shared/rpc-contract/structured-agent-session/structured-agent-session-mcp-params'
|
||||
|
||||
export const STRUCTURED_AGENT_SESSION_MCP_METHODS = [
|
||||
defineMethod({
|
||||
name: 'agentSession.mcp',
|
||||
permission: 'workspace',
|
||||
params: AgentSessionMcpParams,
|
||||
// MCP management talks to the running agent directly, outside the conversation's mutation queue.
|
||||
handler: async ({ sessionId, operation }, ctx): Promise<AgentSessionMcpResult> => {
|
||||
const host = await requireInstalledStructuredHost(ctx, sessionId)
|
||||
const saved = host.deps.store.getRecord(sessionId)
|
||||
const manage = saved && host.deps.agents.registration(saved.provider)?.adapter.manageMcp
|
||||
if (!saved || !manage) {
|
||||
return { status: 'unsupported' }
|
||||
}
|
||||
// An agent at rest is managed on the account its next turn launches on, as /usage reads it.
|
||||
const accountHome = await host.deps.launchAccountHome?.(saved).catch(() => null)
|
||||
const record = accountHome ? { ...saved, accountHome } : saved
|
||||
try {
|
||||
return await manage(record, operation)
|
||||
} catch (error) {
|
||||
return { status: 'failed', message: agentSessionMcpFailureMessage(error) }
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -1,3 +1,4 @@
|
||||
import { STRUCTURED_AGENT_SESSION_MCP_METHODS } from './catalog/structured-agent-session-mcp'
|
||||
import { STRUCTURED_AGENT_SESSION_ACCOUNT_USAGE_METHODS } from './catalog/structured-agent-session-account-usage'
|
||||
import { STRUCTURED_AGENT_SESSION_USAGE_LIMIT_CONTINUE_METHODS } from './catalog/structured-agent-session-usage-limit-continue'
|
||||
import { STRUCTURED_AGENT_SESSION_CLOSE_METHOD } from './conversation/structured-agent-session-close-method'
|
||||
@@ -222,5 +223,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
|
||||
...STRUCTURED_AGENT_SESSION_SEND_RECORDED_METHODS,
|
||||
...STRUCTURED_AGENT_SESSION_CURRENT_REQUESTS_METHODS,
|
||||
...STRUCTURED_AGENT_SESSION_ACCOUNT_USAGE_METHODS,
|
||||
...STRUCTURED_AGENT_SESSION_MCP_METHODS,
|
||||
...STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS
|
||||
]
|
||||
|
||||
@@ -27,6 +27,8 @@ export const MOBILE_AGENT_SESSION_RPC_METHODS = [
|
||||
// Gated on the phone by agent-session.thread-goal.phone.v1.
|
||||
'agentSession.threadGoal',
|
||||
'agentSession.commands',
|
||||
// The `/mcp` sheet; phones offer it where the host advertises agent-session.mcp.v1.
|
||||
'agentSession.mcp',
|
||||
'agentSession.history',
|
||||
'agentSession.currentRequests',
|
||||
'agentSession.subscribe',
|
||||
|
||||
@@ -65,6 +65,9 @@ const AiVaultSessionHistoryDialog = lazy(
|
||||
const NativeChatAccountUsageDialog = lazy(
|
||||
() => import('../components/native-chat/usage/NativeChatAccountUsageDialog')
|
||||
)
|
||||
const NativeChatMcpServersDialog = lazy(
|
||||
() => import('../components/native-chat/mcp/NativeChatMcpServersDialog')
|
||||
)
|
||||
const DeleteWorktreeDialog = lazy(
|
||||
() => import('../components/sidebar/workspace-actions/deletion/DeleteWorktreeDialog')
|
||||
)
|
||||
@@ -210,7 +213,7 @@ export const AppRootSurfaces = memo(function AppRootSurfaces(props: {
|
||||
<ProjectAddedDialog />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{/* Root hosting prevents chat capture handlers receiving /resume and /usage dialog events. */}
|
||||
{/* Root hosting prevents chat capture handlers receiving /resume, /usage and /mcp dialog events. */}
|
||||
{activeModal === 'agent-session-history' ? (
|
||||
<ModalBoundary boundaryId="modal.agent-session-history" resetKey>
|
||||
<AiVaultSessionHistoryDialog />
|
||||
@@ -221,6 +224,11 @@ export const AppRootSurfaces = memo(function AppRootSurfaces(props: {
|
||||
<NativeChatAccountUsageDialog />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{activeModal === 'chat-mcp-servers' ? (
|
||||
<ModalBoundary boundaryId="modal.chat-mcp-servers" resetKey>
|
||||
<NativeChatMcpServersDialog />
|
||||
</ModalBoundary>
|
||||
) : null}
|
||||
{/* Own store, not activeModal: another modal must never evict a running move. */}
|
||||
<WslMoveDialogHost />
|
||||
{/* Opened by toast, SSH host row and terminal pane. */}
|
||||
|
||||
+4
@@ -64,6 +64,10 @@ const SHARED_SLASH_COMMAND_DESCRIPTIONS: SlashCommandDescriptionCopy = {
|
||||
effort: {
|
||||
'Choose reasoning effort': () =>
|
||||
translate('components.native-chat.slashCommandDescriptions.effort', 'Choose reasoning effort')
|
||||
},
|
||||
mcp: {
|
||||
'Manage MCP servers': () =>
|
||||
translate('components.native-chat.slashCommandDescriptions.mcpManage', 'Manage MCP servers')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -6,7 +6,7 @@ import type {
|
||||
SlashCommandSuggestion
|
||||
} from '../../../../../../shared/native-chat/input/native-chat-slash-commands'
|
||||
import { nativeChatSessionSkills } from '../../../../../../shared/native-chat/input/native-chat-skill-visibility'
|
||||
import { structuredSessionSlashCommands } from '../../../../../../shared/agent-session/view/structured-agent-session-composer'
|
||||
import { structuredSessionSlashCommands } from '../../../../../../shared/agent-session/view/structured-agent-session-slash-menu'
|
||||
import { withNativeChatResumeCommand } from '@/components/native-chat/composer/pickers/use-native-chat-resume-command'
|
||||
import { withNativeChatSideChatRows } from '@/components/native-chat/composer/pickers/use-native-chat-side-chat-command'
|
||||
import type { NativeChatStructuredComposerTransport } from '@/components/native-chat/composer/submit/native-chat-structured-composer-transport'
|
||||
@@ -16,7 +16,7 @@ export type NativeChatComposerCatalog = {
|
||||
sessionSkills: readonly NativeChatSessionSkill[] | undefined
|
||||
}
|
||||
|
||||
/** Structured chats use host and session-loaded commands/skills plus Orca /resume and /btw; PTY chats retain curated catalogs. */
|
||||
/** Structured chats use host and session-loaded commands/skills plus Orca /resume, /btw and /mcp; PTY chats retain curated catalogs. */
|
||||
export function useNativeChatComposerCatalog(
|
||||
agent: AgentType,
|
||||
structuredTransport?: NativeChatStructuredComposerTransport
|
||||
@@ -28,6 +28,7 @@ export function useNativeChatComposerCatalog(
|
||||
const interactionModeSupported =
|
||||
structuredTransport?.optionSnapshot.some((entry) => entry.id === 'interactionMode') === true
|
||||
const accountUsage = structuredTransport?.accountUsage !== undefined
|
||||
const mcpPanel = structuredTransport?.mcpPanel !== undefined
|
||||
const fork = structuredTransport?.fork !== undefined
|
||||
const sideChat = structuredTransport?.sideChat !== undefined
|
||||
const sideChatNotReady = structuredTransport?.sideChat?.notReadyReason ?? null
|
||||
@@ -41,7 +42,8 @@ export function useNativeChatComposerCatalog(
|
||||
conversationCommands,
|
||||
reported,
|
||||
compactInstructions,
|
||||
interactionModeSupported
|
||||
interactionModeSupported,
|
||||
mcpPanel
|
||||
),
|
||||
accountUsage,
|
||||
fork
|
||||
@@ -57,7 +59,8 @@ export function useNativeChatComposerCatalog(
|
||||
conversationCommands,
|
||||
reported,
|
||||
structured,
|
||||
interactionModeSupported
|
||||
interactionModeSupported,
|
||||
mcpPanel
|
||||
])
|
||||
const sessionSkills = useMemo(() => nativeChatSessionSkills(agent, reported), [agent, reported])
|
||||
return { agentCommands, sessionSkills }
|
||||
|
||||
+10
-2
@@ -40,9 +40,17 @@ export function useNativeChatComposerCommandPicker(
|
||||
const hostSetsGoals = structuredTransport?.threadGoal !== undefined
|
||||
const hostSetsInteractionMode =
|
||||
structuredTransport?.optionSnapshot.some((entry) => entry.id === 'interactionMode') === true
|
||||
const opensMcpPanel = structuredTransport?.mcpPanel !== undefined
|
||||
const acceptsText = useMemo(
|
||||
() => prepareQueuedMessageEditText(agent, reported, hostSetsGoals, hostSetsInteractionMode),
|
||||
[agent, reported, hostSetsGoals, hostSetsInteractionMode]
|
||||
() =>
|
||||
prepareQueuedMessageEditText(
|
||||
agent,
|
||||
reported,
|
||||
hostSetsGoals,
|
||||
hostSetsInteractionMode,
|
||||
opensMcpPanel
|
||||
),
|
||||
[agent, reported, hostSetsGoals, hostSetsInteractionMode, opensMcpPanel]
|
||||
)
|
||||
const commandAvailable = useCallback(
|
||||
(name: string) => mode === undefined || acceptsText(`/${name}`),
|
||||
|
||||
+21
-11
@@ -19,6 +19,7 @@ import type { NativeChatStructuredComposerTransport } from '@/components/native-
|
||||
export const NATIVE_CHAT_RESUME_COMMAND = 'resume'
|
||||
|
||||
const FORK_DRAFT = /^\/fork(?:\s+([\s\S]*))?$/i
|
||||
const MCP_DRAFT = /^\/mcp\s*$/i
|
||||
|
||||
/** Replace the agent’s /resume catalog entry with Orca’s Session History command, and its /usage
|
||||
* and /fork with Orca’s where this chat runs them. */
|
||||
@@ -92,7 +93,10 @@ export type NativeChatResumeCommand = {
|
||||
* reaches the agent. */
|
||||
export function useNativeChatResumeCommand(args: {
|
||||
structuredTransport:
|
||||
| Pick<NativeChatStructuredComposerTransport, 'worktreeId' | 'accountUsage' | 'fork'>
|
||||
| Pick<
|
||||
NativeChatStructuredComposerTransport,
|
||||
'worktreeId' | 'accountUsage' | 'fork' | 'mcpPanel'
|
||||
>
|
||||
| undefined
|
||||
agent: AgentType
|
||||
terminalTabId: string
|
||||
@@ -114,12 +118,13 @@ export function useNativeChatResumeCommand(args: {
|
||||
const structuredWorktreeId = structuredTransport?.worktreeId
|
||||
const usage = structuredTransport?.accountUsage
|
||||
const fork = structuredTransport?.fork
|
||||
const openUsage = useCallback(
|
||||
(draft: string): void => {
|
||||
const mcp = structuredTransport?.mcpPanel
|
||||
const openPanel = useCallback(
|
||||
(draft: string, panel: { open: () => void } | undefined): void => {
|
||||
const rest = draft.replace(/^\/\S+\s*/, '')
|
||||
// Focus before opening so closing the modal returns to the editor even after a Send click.
|
||||
textareaRef.current?.focus()
|
||||
usage?.open()
|
||||
panel?.open()
|
||||
setDraft(rest)
|
||||
setCaret(rest.length)
|
||||
setActiveSuggestion(0)
|
||||
@@ -130,7 +135,7 @@ export function useNativeChatResumeCommand(args: {
|
||||
writeNativeChatComposerText(input, rest, rest.length)
|
||||
}
|
||||
},
|
||||
[usage, setDraft, setCaret, setActiveSuggestion, setNotice, textareaRef]
|
||||
[setDraft, setCaret, setActiveSuggestion, setNotice, textareaRef]
|
||||
)
|
||||
|
||||
const runFork = useCallback(
|
||||
@@ -212,8 +217,10 @@ export function useNativeChatResumeCommand(args: {
|
||||
!isPickedSkill(draft) &&
|
||||
(parseNativeChatResumeDraft(draft) !== null ||
|
||||
(usage !== undefined && /^\/usage\s*$/i.test(draft)) ||
|
||||
// Before goal mode too, so `/mcp` never becomes an objective.
|
||||
(mcp !== undefined && MCP_DRAFT.test(draft)) ||
|
||||
(fork !== undefined && FORK_DRAFT.test(draft.trim()))),
|
||||
[fork, isPickedSkill, structured, usage]
|
||||
[fork, isPickedSkill, mcp, structured, usage]
|
||||
)
|
||||
const consume = useCallback(
|
||||
(draft: string): boolean => {
|
||||
@@ -221,7 +228,9 @@ export function useNativeChatResumeCommand(args: {
|
||||
return false
|
||||
}
|
||||
if (/^\/usage\s*$/i.test(draft)) {
|
||||
openUsage(draft)
|
||||
openPanel(draft, usage)
|
||||
} else if (MCP_DRAFT.test(draft)) {
|
||||
openPanel(draft, mcp)
|
||||
} else if (fork && FORK_DRAFT.test(draft.trim())) {
|
||||
runFork(draft)
|
||||
} else {
|
||||
@@ -229,7 +238,7 @@ export function useNativeChatResumeCommand(args: {
|
||||
}
|
||||
return true
|
||||
},
|
||||
[fork, matchesDraft, open, openUsage, runFork]
|
||||
[fork, matchesDraft, mcp, open, openPanel, runFork, usage]
|
||||
)
|
||||
|
||||
const interceptPick = useCallback(
|
||||
@@ -241,14 +250,15 @@ export function useNativeChatResumeCommand(args: {
|
||||
if (
|
||||
item.name !== 'resume' &&
|
||||
!(item.name === 'usage' && usage) &&
|
||||
!(item.name === 'mcp' && mcp) &&
|
||||
!(item.name === 'fork' && fork)
|
||||
) {
|
||||
pick(item)
|
||||
return
|
||||
}
|
||||
clearSkillOrigin()
|
||||
if (item.name === 'usage') {
|
||||
openUsage(textareaRef.current?.value ?? '')
|
||||
if (item.name === 'usage' || item.name === 'mcp') {
|
||||
openPanel(textareaRef.current?.value ?? '', item.name === 'usage' ? usage : mcp)
|
||||
return
|
||||
}
|
||||
if (item.name === 'fork') {
|
||||
@@ -263,7 +273,7 @@ export function useNativeChatResumeCommand(args: {
|
||||
}
|
||||
pick(item)
|
||||
},
|
||||
[autocomplete, clearSkillOrigin, fork, open, openUsage, runFork, textareaRef, usage]
|
||||
[autocomplete, clearSkillOrigin, fork, mcp, open, openPanel, runFork, textareaRef, usage]
|
||||
)
|
||||
|
||||
return { consume, interceptPick, matchesDraft }
|
||||
|
||||
+8
-2
@@ -35,7 +35,12 @@ export function nativeChatComposerSendState(
|
||||
structuredTransport:
|
||||
| Pick<
|
||||
NativeChatStructuredComposerTransport,
|
||||
'sendOut' | 'sendBlockedReason' | 'threadGoal' | 'sendFollowUp' | 'optionSnapshot'
|
||||
| 'sendOut'
|
||||
| 'sendBlockedReason'
|
||||
| 'threadGoal'
|
||||
| 'sendFollowUp'
|
||||
| 'optionSnapshot'
|
||||
| 'mcpPanel'
|
||||
>
|
||||
| undefined
|
||||
editSession: { canSave: boolean } | undefined
|
||||
@@ -65,7 +70,8 @@ export function nativeChatComposerSendState(
|
||||
input.draft,
|
||||
transport.threadGoal !== undefined,
|
||||
input.agent,
|
||||
transport.optionSnapshot
|
||||
transport.optionSnapshot,
|
||||
transport.mcpPanel !== undefined
|
||||
) &&
|
||||
!structuredAgentSessionCommandChangesConversation(input.draft))
|
||||
const draftHeld = localHold || (hostHold && !runsWhileHostHolds)
|
||||
|
||||
+2
@@ -55,6 +55,8 @@ export type NativeChatStructuredComposerTransport = {
|
||||
sideChat?: NativeChatSideChat
|
||||
/** Present only where the host can read this chat's Codex account usage. */
|
||||
accountUsage?: { open: () => void }
|
||||
/** Present only where the host manages this agent's MCP servers; `/mcp` opens Orca's panel. */
|
||||
mcpPanel?: { open: () => void }
|
||||
/** Present only where this chat can fork; `run` answers with a notice when it didn't fork. */
|
||||
fork?: { run: () => string | null }
|
||||
onError: (message: string | null, detail?: NativeChatComposerErrorDetail) => void
|
||||
|
||||
+2
-1
@@ -268,7 +268,8 @@ export function useNativeChatComposerSubmit(args: {
|
||||
commandText,
|
||||
threadGoal !== undefined,
|
||||
args.agent,
|
||||
structuredTransport.optionSnapshot
|
||||
structuredTransport.optionSnapshot,
|
||||
structuredTransport.mcpPanel !== undefined
|
||||
)
|
||||
)
|
||||
return { send, goalMode, messageDelivery }
|
||||
|
||||
+2
-1
@@ -92,7 +92,8 @@ export function useNativeChatStructuredComposerSend({
|
||||
text,
|
||||
structuredTransport.threadGoal !== undefined,
|
||||
agent,
|
||||
structuredTransport.optionSnapshot
|
||||
structuredTransport.optionSnapshot,
|
||||
structuredTransport.mcpPanel !== undefined
|
||||
)
|
||||
// Blocked hosts cannot take turns/conversation commands; option commands only configure later turns.
|
||||
if (
|
||||
|
||||
+29
-4
@@ -1,8 +1,14 @@
|
||||
import { openNativeChatAccountUsageModal } from '@/components/native-chat/usage/native-chat-account-usage-modal'
|
||||
import { openNativeChatMcpServersModal } from '@/components/native-chat/mcp/native-chat-mcp-servers-modal'
|
||||
import { useStructuredAgentSessionHostCapabilityState } from '@/runtime/native-chat/host-capabilities/structured-agent-session-host-capability'
|
||||
import { AGENT_SESSION_MCP_RUNTIME_CAPABILITY } from '../../../../../../shared/agent-session/agent-session-runtime-capabilities'
|
||||
import { useMemo, type Dispatch, type SetStateAction } from 'react'
|
||||
import { dispatchStructuredAgentSessionComposerCommand } from '../../../../../../shared/agent-session/view/structured-agent-session-composer'
|
||||
import { localizeStructuredComposerOutcome } from '@/components/native-chat/composer/submit/structured-composer-command-error-text'
|
||||
import { useStructuredAgentAcceptsImages } from '@/runtime/native-chat/agent-catalogs/use-host-structured-agent'
|
||||
import {
|
||||
structuredAgentAcceptsImages,
|
||||
useHostStructuredAgent
|
||||
} from '@/runtime/native-chat/agent-catalogs/use-host-structured-agent'
|
||||
import type { NativeChatOptionPickerRequest } from '@/components/native-chat/session/options/controls/native-chat-option-picker-request'
|
||||
import type { NativeChatStructuredComposerTransport } from '@/components/native-chat/composer/submit/native-chat-structured-composer-transport'
|
||||
import type { NativeChatStructuredViewProps } from '@/components/native-chat/native-chat-view-types'
|
||||
@@ -92,14 +98,30 @@ export function useNativeChatStructuredComposerTransport(args: {
|
||||
const { revealLatest: onSubmitted, captureRevealLatest: captureAdmittedReveal } = args.reveal
|
||||
const { transportEnabled } = args
|
||||
const sendBlockedReason = args.sendBlockedReason ?? null
|
||||
const acceptsImages = useStructuredAgentAcceptsImages(
|
||||
props.target,
|
||||
const hostAgent = useHostStructuredAgent(props.target, props.agent)
|
||||
const acceptsImages = structuredAgentAcceptsImages(
|
||||
hostAgent,
|
||||
props.agent,
|
||||
controller.acceptsImages
|
||||
)
|
||||
// The local runtime is this build; a paired host must say it answers agentSession.mcp.
|
||||
const remoteManagesMcp =
|
||||
useStructuredAgentSessionHostCapabilityState(
|
||||
props.target.kind === 'local' ? null : props.target,
|
||||
AGENT_SESSION_MCP_RUNTIME_CAPABILITY
|
||||
) === 'supported'
|
||||
const managesMcp =
|
||||
hostAgent?.capabilities.mcpServers === true &&
|
||||
(props.target.kind === 'local' || remoteManagesMcp)
|
||||
const { fork, sideChat } = forks
|
||||
return useMemo((): NativeChatStructuredComposerTransport => {
|
||||
const threadGoal = controller.threadGoal
|
||||
const mcpPanel = managesMcp
|
||||
? {
|
||||
open: () =>
|
||||
openNativeChatMcpServersModal({ sessionId: props.sessionId, target: props.target })
|
||||
}
|
||||
: undefined
|
||||
// A held host takes no turn; pausing or clearing a goal starts none.
|
||||
const changeThreadGoal = threadGoal?.hostChanges
|
||||
? (change: AgentSessionThreadGoalChange) =>
|
||||
@@ -120,6 +142,7 @@ export function useNativeChatStructuredComposerTransport(args: {
|
||||
})
|
||||
}
|
||||
: undefined,
|
||||
mcpPanel,
|
||||
fork: nativeChatForkCommand(fork, controller.isWorking),
|
||||
capturePresentation: controller.capturePresentation,
|
||||
...(sideChat ? { sideChat } : {}),
|
||||
@@ -162,7 +185,8 @@ export function useNativeChatStructuredComposerTransport(args: {
|
||||
conversationCommands: controller.conversationCommands,
|
||||
compactInstructions: controller.compactInstructions,
|
||||
runConversationCommand: controller.runConversationCommand,
|
||||
...(changeThreadGoal ? { changeThreadGoal } : {})
|
||||
...(changeThreadGoal ? { changeThreadGoal } : {}),
|
||||
...(mcpPanel ? { openMcpPanel: mcpPanel.open } : {})
|
||||
},
|
||||
onSent
|
||||
).then(localizeStructuredComposerOutcome),
|
||||
@@ -205,6 +229,7 @@ export function useNativeChatStructuredComposerTransport(args: {
|
||||
}, [
|
||||
acceptsImages,
|
||||
controller,
|
||||
managesMcp,
|
||||
fork,
|
||||
sideChat,
|
||||
worktreeId,
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import type {
|
||||
AgentSessionMcpOperation,
|
||||
AgentSessionMcpServer,
|
||||
AgentSessionMcpSignIn
|
||||
} from '../../../../../shared/agent-session/agent-session-mcp'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { cn } from '@/lib/class-names'
|
||||
import { mcpServerStatusLine } from './native-chat-mcp-server-text'
|
||||
import {
|
||||
agentSessionMcpRowFailure,
|
||||
agentSessionMcpServerDisplayName
|
||||
} from '../../../../../shared/agent-session/view/agent-session-mcp-server-presentation'
|
||||
import { useDeferredPending } from './use-deferred-pending'
|
||||
|
||||
export type McpServerAction = Exclude<AgentSessionMcpOperation, { action: 'status' }>
|
||||
|
||||
const DOT_CLASS: Record<Exclude<AgentSessionMcpServer['status'], 'pending'>, string> = {
|
||||
connected: 'bg-status-success',
|
||||
'needs-auth': 'bg-status-warning',
|
||||
failed: 'bg-destructive',
|
||||
disabled: 'bg-muted-foreground/40'
|
||||
}
|
||||
|
||||
/** The label keeps its width while a spinner stands in for it. */
|
||||
function PendingLabel({ pending, children }: { pending: boolean; children: ReactNode }) {
|
||||
const spinning = useDeferredPending(pending)
|
||||
return (
|
||||
<span className="relative inline-flex items-center justify-center">
|
||||
<span className={cn(spinning && 'invisible')}>{children}</span>
|
||||
{spinning ? <LoadingSpinner className="absolute size-3" /> : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function NativeChatMcpServerRow({
|
||||
server,
|
||||
runtime,
|
||||
signIn,
|
||||
pending,
|
||||
busy,
|
||||
failure,
|
||||
run,
|
||||
dismissSignIn,
|
||||
openUrl
|
||||
}: {
|
||||
server: AgentSessionMcpServer
|
||||
/** Where the agent runs: only an agent on this computer receives the browser's sign-in callback. */
|
||||
runtime: 'local' | 'remote'
|
||||
/** This row's sign-in in progress. */
|
||||
signIn: AgentSessionMcpSignIn | null
|
||||
/** This row's action in flight. */
|
||||
pending: McpServerAction | null
|
||||
/** Any row's action is in flight; the panel runs one at a time. */
|
||||
busy: boolean
|
||||
/** The panel's last refused action; a null message has no detail. */
|
||||
failure: { server: string; message: string | null } | null
|
||||
run: (operation: McpServerAction) => void
|
||||
dismissSignIn: () => void
|
||||
openUrl: (url: string) => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const name = server.name
|
||||
const refusal = agentSessionMcpRowFailure(server, failure, signIn)
|
||||
const enabled = pending?.action === 'toggle' ? pending.enabled : server.status !== 'disabled'
|
||||
const statusLine = signIn
|
||||
? signIn.finish === 'callback' && runtime === 'local'
|
||||
? t(
|
||||
'components.native-chat.mcp.signIn.waitingBrowser',
|
||||
'Waiting for sign-in in your browser…'
|
||||
)
|
||||
: t('components.native-chat.mcp.signIn.waiting', 'Waiting for sign-in…')
|
||||
: mcpServerStatusLine(server, t)
|
||||
const action = signIn ? (
|
||||
<Button type="button" variant="ghost" size="xs" onClick={dismissSignIn}>
|
||||
{t('components.native-chat.mcp.cancel', 'Cancel')}
|
||||
</Button>
|
||||
) : server.canSignIn && server.status === 'needs-auth' ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => run({ action: 'signIn', server: name })}
|
||||
>
|
||||
<PendingLabel pending={pending?.action === 'signIn'}>
|
||||
{t('components.native-chat.mcp.signIn.action', 'Sign in')}
|
||||
</PendingLabel>
|
||||
</Button>
|
||||
) : server.canReconnect && server.status === 'failed' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => run({ action: 'reconnect', server: name })}
|
||||
>
|
||||
<PendingLabel pending={pending?.action === 'reconnect'}>
|
||||
{t('components.native-chat.mcp.reconnect', 'Reconnect')}
|
||||
</PendingLabel>
|
||||
</Button>
|
||||
) : null
|
||||
return (
|
||||
<div className="border-b border-border/60 last:border-b-0">
|
||||
<div className="flex items-center gap-2.5 px-3 py-2">
|
||||
<span aria-hidden="true" className="flex size-3 shrink-0 items-center justify-center">
|
||||
{signIn || server.status === 'pending' ? (
|
||||
<LoadingSpinner className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<span className={cn('size-2 rounded-full', DOT_CLASS[server.status])} />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{agentSessionMcpServerDisplayName(server)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'truncate text-xs',
|
||||
server.status === 'failed' && !signIn ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
title={statusLine}
|
||||
>
|
||||
{statusLine}
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
{server.canToggle && !signIn ? (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
aria-label={t('components.native-chat.mcp.toggle', 'Use {{name}}', { name })}
|
||||
onCheckedChange={(checked) => run({ action: 'toggle', server: name, enabled: checked })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{signIn ? (
|
||||
<McpSignInBox
|
||||
key={signIn.url}
|
||||
signIn={signIn}
|
||||
runtime={runtime}
|
||||
pending={pending}
|
||||
busy={busy}
|
||||
run={run}
|
||||
openUrl={openUrl}
|
||||
/>
|
||||
) : null}
|
||||
{refusal ? (
|
||||
<div className="-mt-1 pr-3 pb-2 pl-8.5 text-xs text-destructive">
|
||||
{refusal.kind === 'signInUnfinished'
|
||||
? t(
|
||||
'components.native-chat.mcp.signIn.unfinished',
|
||||
'Sign-in isn’t finished yet. Finish it on the page that opened, then press Done.'
|
||||
)
|
||||
: (refusal.message ??
|
||||
t('components.native-chat.mcp.actionFailed', 'Couldn’t do that. Try again.'))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function McpSignInBox({
|
||||
signIn,
|
||||
runtime,
|
||||
pending,
|
||||
busy,
|
||||
run,
|
||||
openUrl
|
||||
}: {
|
||||
signIn: AgentSessionMcpSignIn
|
||||
runtime: 'local' | 'remote'
|
||||
pending: McpServerAction | null
|
||||
busy: boolean
|
||||
run: (operation: McpServerAction) => void
|
||||
openUrl: (url: string) => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const [callbackUrl, setCallbackUrl] = useState('')
|
||||
const openAgain = (
|
||||
<Button type="button" variant="outline" size="xs" onClick={() => openUrl(signIn.url)}>
|
||||
{t('components.native-chat.mcp.signIn.openAgain', 'Open again')}
|
||||
</Button>
|
||||
)
|
||||
const box = 'mr-3 mb-2.5 ml-8.5 rounded-md border border-border bg-muted/40 p-2.5 text-xs'
|
||||
const hint = 'mb-2 text-muted-foreground'
|
||||
if (signIn.finish === 'external') {
|
||||
return (
|
||||
<div className={box}>
|
||||
<p className={hint}>
|
||||
{t(
|
||||
'components.native-chat.mcp.signIn.external',
|
||||
'Finish signing in on the page that opened, then press Done.'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => run({ action: 'reconnect', server: signIn.server })}
|
||||
>
|
||||
<PendingLabel pending={pending?.action === 'reconnect'}>
|
||||
{t('components.native-chat.mcp.signIn.done', 'Done')}
|
||||
</PendingLabel>
|
||||
</Button>
|
||||
{openAgain}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (runtime === 'local') {
|
||||
return (
|
||||
<div className={box}>
|
||||
<p className={hint}>
|
||||
{t(
|
||||
'components.native-chat.mcp.signIn.local',
|
||||
'Opened the sign-in page in your browser. This updates when you finish.'
|
||||
)}
|
||||
</p>
|
||||
{openAgain}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const address = callbackUrl.trim()
|
||||
return (
|
||||
<form
|
||||
className={box}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (address && !busy) {
|
||||
run({ action: 'finishSignIn', server: signIn.server, callbackUrl: address })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className={hint}>
|
||||
{t(
|
||||
'components.native-chat.mcp.signIn.remote',
|
||||
'After you sign in, the browser lands on a page that won’t load. Copy that page’s address and paste it here.'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
autoFocus
|
||||
variant="code"
|
||||
density="compact"
|
||||
className="h-6 min-w-0 flex-1"
|
||||
value={callbackUrl}
|
||||
onChange={(event) => setCallbackUrl(event.target.value)}
|
||||
placeholder={t(
|
||||
'components.native-chat.mcp.signIn.pastePlaceholder',
|
||||
'http://localhost:…'
|
||||
)}
|
||||
aria-label={t('components.native-chat.mcp.signIn.addressLabel', 'Sign-in page address')}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button type="submit" size="xs" disabled={busy || !address}>
|
||||
<PendingLabel pending={pending?.action === 'finishSignIn'}>
|
||||
{t('components.native-chat.mcp.signIn.finish', 'Finish')}
|
||||
</PendingLabel>
|
||||
</Button>
|
||||
{openAgain}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { defineFixture } from '../../../component-fixtures/component-fixture'
|
||||
import type { AgentSessionMcpServer } from '../../../../../shared/agent-session/agent-session-mcp'
|
||||
import type { AgentSessionMcpPanelState } from '../../../../../shared/agent-session/view/use-agent-session-mcp-panel'
|
||||
import { NativeChatMcpServersDialogView } from './NativeChatMcpServersDialog'
|
||||
|
||||
function server(
|
||||
name: string,
|
||||
status: AgentSessionMcpServer['status'],
|
||||
fields: Partial<AgentSessionMcpServer> = {}
|
||||
): AgentSessionMcpServer {
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
source: null,
|
||||
toolCount: null,
|
||||
error: null,
|
||||
canSignIn: false,
|
||||
canReconnect: false,
|
||||
canToggle: true,
|
||||
...fields
|
||||
}
|
||||
}
|
||||
|
||||
const CLAUDE_SERVERS: AgentSessionMcpServer[] = [
|
||||
server('Linear', 'connected', { source: 'claudeai', toolCount: 24 }),
|
||||
server('Slack', 'needs-auth', { source: 'claudeai', canSignIn: true }),
|
||||
server('postgres', 'failed', {
|
||||
source: 'project',
|
||||
error: 'connection refused',
|
||||
canReconnect: true
|
||||
}),
|
||||
server('playwright', 'disabled', { source: 'project' }),
|
||||
server('github', 'pending', { source: 'user' })
|
||||
]
|
||||
|
||||
const SLACK_ONLY = [server('Slack', 'needs-auth', { source: 'claudeai', canSignIn: true })]
|
||||
|
||||
function fixture(state: Partial<AgentSessionMcpPanelState>, runtime: 'local' | 'remote' = 'local') {
|
||||
return defineFixture({
|
||||
render: () => (
|
||||
<NativeChatMcpServersDialogView
|
||||
state={{
|
||||
result: { status: 'ok' },
|
||||
servers: [],
|
||||
pending: null,
|
||||
signIn: null,
|
||||
failure: null,
|
||||
...state
|
||||
}}
|
||||
runtime={runtime}
|
||||
run={() => {}}
|
||||
retry={() => {}}
|
||||
retrying={false}
|
||||
dismissSignIn={() => {}}
|
||||
openUrl={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const groupedList = fixture({ servers: CLAUDE_SERVERS })
|
||||
|
||||
export const codexList = fixture({
|
||||
servers: [
|
||||
server('context7', 'connected', { toolCount: 2 }),
|
||||
server('notion', 'needs-auth', { canSignIn: true }),
|
||||
server('sentry', 'disabled')
|
||||
]
|
||||
})
|
||||
|
||||
export const localCallbackSignIn = fixture({
|
||||
servers: SLACK_ONLY,
|
||||
signIn: { server: 'Slack', url: 'https://slack.com/oauth', finish: 'callback' }
|
||||
})
|
||||
|
||||
export const remoteCallbackSignIn = fixture(
|
||||
{
|
||||
servers: SLACK_ONLY,
|
||||
signIn: { server: 'Slack', url: 'https://slack.com/oauth', finish: 'callback' }
|
||||
},
|
||||
'remote'
|
||||
)
|
||||
|
||||
export const externalSignIn = fixture({
|
||||
servers: SLACK_ONLY,
|
||||
signIn: { server: 'Slack', url: 'https://claude.ai/connectors', finish: 'external' }
|
||||
})
|
||||
|
||||
export const rowFailure = fixture({
|
||||
servers: CLAUDE_SERVERS,
|
||||
failure: { server: 'postgres', message: null }
|
||||
})
|
||||
|
||||
export const notRunning = fixture({ result: { status: 'not-running' } })
|
||||
|
||||
export const readFailed = fixture({ result: { status: 'failed', message: null } })
|
||||
|
||||
export const empty = fixture({})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open'
|
||||
import { callStructuredAgentSession } from '@/runtime/native-chat/requests/structured-agent-session-call'
|
||||
import type {
|
||||
AgentSessionMcpOperation,
|
||||
AgentSessionMcpResult
|
||||
} from '../../../../../shared/agent-session/agent-session-mcp'
|
||||
import {
|
||||
createAgentSessionMcpPanelHook,
|
||||
type AgentSessionMcpPanelState
|
||||
} from '../../../../../shared/agent-session/view/use-agent-session-mcp-panel'
|
||||
import {
|
||||
readNativeChatSessionModalRequest,
|
||||
type NativeChatSessionModalRequest
|
||||
} from '../session/native-chat-session-modal-request'
|
||||
import { mcpSourceLabel } from './native-chat-mcp-server-text'
|
||||
import { groupAgentSessionMcpServers } from '../../../../../shared/agent-session/view/agent-session-mcp-server-presentation'
|
||||
import { NativeChatMcpServerRow, type McpServerAction } from './NativeChatMcpServerRow'
|
||||
import { useDeferredPending } from './use-deferred-pending'
|
||||
|
||||
const useAgentSessionMcpPanel = createAgentSessionMcpPanelHook({
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
})
|
||||
|
||||
// Sign-in belongs in the user's browser, where their accounts are.
|
||||
const openSignInPage = (url: string): void => {
|
||||
openAppHttpLink(url, { destination: 'system' })
|
||||
}
|
||||
|
||||
/** `/mcp`'s modal: each opening mounts a fresh read of the chat's servers. */
|
||||
export default function NativeChatMcpServersDialog(): React.JSX.Element | null {
|
||||
const modalData = useAppStore((s) => s.modalData)
|
||||
// The opening's chat is fixed; a fresh object per render would restart its read.
|
||||
const [request] = useState(() => readNativeChatSessionModalRequest(modalData))
|
||||
return request ? <McpServersDialog request={request} /> : null
|
||||
}
|
||||
|
||||
function McpServersDialog({
|
||||
request
|
||||
}: {
|
||||
request: NativeChatSessionModalRequest
|
||||
}): React.JSX.Element {
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
const { sessionId, target } = request
|
||||
const call = useCallback(
|
||||
(operation: AgentSessionMcpOperation) =>
|
||||
callStructuredAgentSession<AgentSessionMcpResult>(target, 'agentSession.mcp', {
|
||||
sessionId,
|
||||
operation
|
||||
}),
|
||||
[sessionId, target]
|
||||
)
|
||||
const panel = useAgentSessionMcpPanel(call, openSignInPage)
|
||||
const readAgain = panel.retry
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
const retry = useCallback(() => {
|
||||
setRetrying(true)
|
||||
void readAgain().finally(() => setRetrying(false))
|
||||
}, [readAgain])
|
||||
return (
|
||||
<NativeChatMcpServersDialogView
|
||||
state={panel}
|
||||
runtime={target.kind === 'local' ? 'local' : 'remote'}
|
||||
run={(operation) => void panel.run(operation)}
|
||||
retry={retry}
|
||||
retrying={retrying}
|
||||
dismissSignIn={panel.dismissSignIn}
|
||||
openUrl={openSignInPage}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NativeChatMcpServersDialogView({
|
||||
onClose,
|
||||
...body
|
||||
}: McpServersBodyProps & { onClose: () => void }): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
// Why: the slot has no trigger element for the dialog to hand focus back to.
|
||||
const [returnFocusTo] = useState(() =>
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
)
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
aria-describedby={undefined}
|
||||
surface="panel"
|
||||
className="top-[max(2rem,calc(50%-320px))] flex max-h-[min(640px,calc(100vh-4rem))] translate-y-0 flex-col overflow-hidden sm:max-w-[440px]"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
if (returnFocusTo?.isConnected) {
|
||||
returnFocusTo.focus({ preventScroll: true })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-sidebar-border px-3 py-2">
|
||||
<DialogTitle size="compact">
|
||||
{t('components.native-chat.mcp.title', 'MCP servers')}
|
||||
</DialogTitle>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-6"
|
||||
aria-label={t('auto.components.ui.dialog.f26c4baeda', 'Close')}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div className="scrollbar-sleek min-h-0 overflow-y-auto">
|
||||
<McpServersBody {...body} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type McpServersBodyProps = {
|
||||
state: AgentSessionMcpPanelState
|
||||
runtime: 'local' | 'remote'
|
||||
run: (operation: McpServerAction) => void
|
||||
retry: () => void
|
||||
retrying: boolean
|
||||
dismissSignIn: () => void
|
||||
openUrl: (url: string) => void
|
||||
}
|
||||
|
||||
function McpServersBody({
|
||||
state,
|
||||
runtime,
|
||||
run,
|
||||
retry,
|
||||
retrying,
|
||||
dismissSignIn,
|
||||
openUrl
|
||||
}: McpServersBodyProps): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const loading = useDeferredPending(state.result === null)
|
||||
const notice = 'px-3 py-4 text-sm text-muted-foreground'
|
||||
if (state.result === null) {
|
||||
return (
|
||||
<div className={notice} aria-busy="true">
|
||||
<span className={loading ? 'flex items-center gap-2' : 'invisible flex'}>
|
||||
<LoadingSpinner className="size-4" />
|
||||
{t('components.native-chat.mcp.loading', 'Loading MCP servers…')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (state.result.status === 'not-running') {
|
||||
return (
|
||||
<div className={notice}>
|
||||
{t(
|
||||
'components.native-chat.mcp.notRunning',
|
||||
'MCP servers connect when you send your next message.'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (state.result.status === 'unsupported') {
|
||||
return (
|
||||
<div className={notice}>
|
||||
{t(
|
||||
'components.native-chat.mcp.unsupported',
|
||||
'Update Orca on this chat’s host to manage MCP servers here.'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (state.result.status === 'failed') {
|
||||
return (
|
||||
<div className={`${notice} flex items-center gap-2`}>
|
||||
<span className="min-w-0 flex-1">
|
||||
{state.result.message ??
|
||||
t(
|
||||
'components.native-chat.mcp.readFailed',
|
||||
'MCP servers couldn’t be loaded. Try again.'
|
||||
)}
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="xs" disabled={retrying} onClick={retry}>
|
||||
{t('components.native-chat.mcp.retry', 'Retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (state.servers.length === 0) {
|
||||
return (
|
||||
<div className={notice}>
|
||||
{t('components.native-chat.mcp.empty', 'No MCP servers are set up for this chat.')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const pending = state.pending
|
||||
return (
|
||||
<div className="pb-1">
|
||||
{groupAgentSessionMcpServers(state.servers).map((group) => (
|
||||
<div key={group.source ?? ''} className="border-b border-border/60 last:border-b-0">
|
||||
{group.source !== null ? (
|
||||
<div className="px-3 pt-2 pb-0.5 text-2xs font-semibold tracking-wider text-muted-foreground uppercase">
|
||||
{mcpSourceLabel(group.source, t)}
|
||||
</div>
|
||||
) : null}
|
||||
{group.servers.map((server) => (
|
||||
<NativeChatMcpServerRow
|
||||
key={server.name}
|
||||
server={server}
|
||||
runtime={runtime}
|
||||
signIn={state.signIn?.server === server.name ? state.signIn : null}
|
||||
pending={pending?.server === server.name ? pending : null}
|
||||
busy={pending !== null}
|
||||
failure={state.failure}
|
||||
run={run}
|
||||
dismissSignIn={dismissSignIn}
|
||||
openUrl={openUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AgentSessionMcpServer } from '../../../../../shared/agent-session/agent-session-mcp'
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
|
||||
export function mcpSourceLabel(source: string, t: Translate): string {
|
||||
switch (source) {
|
||||
case 'claudeai':
|
||||
return 'claude.ai'
|
||||
case 'project':
|
||||
return t('components.native-chat.mcp.source.project', 'Project')
|
||||
case 'local':
|
||||
return t('components.native-chat.mcp.source.local', 'Local')
|
||||
case 'user':
|
||||
return t('components.native-chat.mcp.source.user', 'User')
|
||||
case 'dynamic':
|
||||
return t('components.native-chat.mcp.source.session', 'Session')
|
||||
case 'plugin':
|
||||
return t('components.native-chat.mcp.source.plugin', 'Plugin')
|
||||
default:
|
||||
return source.charAt(0).toUpperCase() + source.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
export function mcpServerStatusLine(server: AgentSessionMcpServer, t: Translate): string {
|
||||
switch (server.status) {
|
||||
case 'connected':
|
||||
return server.toolCount === null
|
||||
? t('components.native-chat.mcp.status.connected', 'Connected')
|
||||
: t('components.native-chat.mcp.status.connectedTools', 'Connected · {{count}} tools', {
|
||||
count: server.toolCount,
|
||||
defaultValue_one: 'Connected · {{count}} tool'
|
||||
})
|
||||
case 'pending':
|
||||
return t('components.native-chat.mcp.status.pending', 'Connecting…')
|
||||
case 'needs-auth':
|
||||
return server.canSignIn
|
||||
? t('components.native-chat.mcp.status.needsAuth', 'Needs sign-in')
|
||||
: t(
|
||||
'components.native-chat.mcp.status.needsAuthAfterSend',
|
||||
'Needs sign-in. Send a message to sign in here.'
|
||||
)
|
||||
case 'failed':
|
||||
return server.error
|
||||
? t('components.native-chat.mcp.status.failedReason', 'Failed: {{error}}', {
|
||||
error: server.error
|
||||
})
|
||||
: t('components.native-chat.mcp.status.failed', 'Failed')
|
||||
case 'disabled':
|
||||
return t('components.native-chat.mcp.status.disabled', 'Disabled')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { NativeChatSessionModalRequest } from '../session/native-chat-session-modal-request'
|
||||
|
||||
/** The chat `/mcp` was typed in; the modal manages the servers that chat's agent connects. */
|
||||
export function openNativeChatMcpServersModal(request: NativeChatSessionModalRequest): void {
|
||||
useAppStore.getState().openModal('chat-mcp-servers', {
|
||||
sessionId: request.sessionId,
|
||||
target: request.target
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/** Deferred so a fast local round trip never flashes a spinner; the control greys at once. */
|
||||
export function useDeferredPending(pending: boolean): boolean {
|
||||
const [shown, setShown] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!pending) {
|
||||
setShown(false)
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setShown(true), 200)
|
||||
return () => clearTimeout(timer)
|
||||
}, [pending])
|
||||
return shown
|
||||
}
|
||||
+9
-2
@@ -60,10 +60,17 @@ export function useNativeChatComposerEditSession({
|
||||
const hostSetsGoals = structuredTransport?.threadGoal !== undefined
|
||||
const hostSetsInteractionMode =
|
||||
structuredTransport?.optionSnapshot.some((entry) => entry.id === 'interactionMode') === true
|
||||
const opensMcpPanel = structuredTransport?.mcpPanel !== undefined
|
||||
const acceptsText = useMemo(
|
||||
() =>
|
||||
prepareQueuedMessageEditText(agent, sessionCommands, hostSetsGoals, hostSetsInteractionMode),
|
||||
[agent, sessionCommands, hostSetsGoals, hostSetsInteractionMode]
|
||||
prepareQueuedMessageEditText(
|
||||
agent,
|
||||
sessionCommands,
|
||||
hostSetsGoals,
|
||||
hostSetsInteractionMode,
|
||||
opensMcpPanel
|
||||
),
|
||||
[agent, sessionCommands, hostSetsGoals, hostSetsInteractionMode, opensMcpPanel]
|
||||
)
|
||||
const isEditing = editSession !== undefined
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
|
||||
|
||||
/** A root modal opened from a chat's composer; it acts on that chat's session on its host. */
|
||||
export type NativeChatSessionModalRequest = {
|
||||
sessionId: string
|
||||
target: RuntimeClientTarget
|
||||
}
|
||||
|
||||
export function readNativeChatSessionModalRequest(
|
||||
data: Record<string, unknown>
|
||||
): NativeChatSessionModalRequest | null {
|
||||
const { sessionId, target } = data
|
||||
if (typeof sessionId !== 'string' || typeof target !== 'object' || target === null) {
|
||||
return null
|
||||
}
|
||||
if ('kind' in target && target.kind === 'local') {
|
||||
return { sessionId, target: { kind: 'local' } }
|
||||
}
|
||||
if (
|
||||
'kind' in target &&
|
||||
target.kind === 'environment' &&
|
||||
'environmentId' in target &&
|
||||
typeof target.environmentId === 'string'
|
||||
) {
|
||||
return { sessionId, target: { kind: 'environment', environmentId: target.environmentId } }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -6,24 +6,20 @@ import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { NativeChatAccountUsageCard } from './NativeChatAccountUsageCard'
|
||||
import {
|
||||
readNativeChatAccountUsageModalRequest,
|
||||
type NativeChatAccountUsageModalRequest
|
||||
} from './native-chat-account-usage-modal'
|
||||
readNativeChatSessionModalRequest,
|
||||
type NativeChatSessionModalRequest
|
||||
} from '../session/native-chat-session-modal-request'
|
||||
import { useNativeChatAccountUsage } from './use-native-chat-account-usage'
|
||||
|
||||
/** `/usage`'s modal: each opening mounts a fresh read of the chat's account. */
|
||||
export default function NativeChatAccountUsageDialog(): React.JSX.Element | null {
|
||||
const modalData = useAppStore((s) => s.modalData)
|
||||
// The opening's chat is fixed; a fresh object per render would restart its read.
|
||||
const [request] = useState(() => readNativeChatAccountUsageModalRequest(modalData))
|
||||
const [request] = useState(() => readNativeChatSessionModalRequest(modalData))
|
||||
return request ? <UsageDialog request={request} /> : null
|
||||
}
|
||||
|
||||
function UsageDialog({
|
||||
request
|
||||
}: {
|
||||
request: NativeChatAccountUsageModalRequest
|
||||
}): React.JSX.Element {
|
||||
function UsageDialog({ request }: { request: NativeChatSessionModalRequest }): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
const usage = useNativeChatAccountUsage(request.sessionId, request.target)
|
||||
|
||||
@@ -1,36 +1,10 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
|
||||
import type { NativeChatSessionModalRequest } from '../session/native-chat-session-modal-request'
|
||||
|
||||
export type NativeChatAccountUsageModalRequest = {
|
||||
/** The chat `/usage` was typed in; the modal reads the account that chat runs on. */
|
||||
sessionId: string
|
||||
target: RuntimeClientTarget
|
||||
}
|
||||
|
||||
export function openNativeChatAccountUsageModal(request: NativeChatAccountUsageModalRequest): void {
|
||||
/** The chat `/usage` was typed in; the modal reads the account that chat runs on. */
|
||||
export function openNativeChatAccountUsageModal(request: NativeChatSessionModalRequest): void {
|
||||
useAppStore.getState().openModal('chat-account-usage', {
|
||||
sessionId: request.sessionId,
|
||||
target: request.target
|
||||
})
|
||||
}
|
||||
|
||||
export function readNativeChatAccountUsageModalRequest(
|
||||
data: Record<string, unknown>
|
||||
): NativeChatAccountUsageModalRequest | null {
|
||||
const { sessionId, target } = data
|
||||
if (typeof sessionId !== 'string' || typeof target !== 'object' || target === null) {
|
||||
return null
|
||||
}
|
||||
if ('kind' in target && target.kind === 'local') {
|
||||
return { sessionId, target: { kind: 'local' } }
|
||||
}
|
||||
if (
|
||||
'kind' in target &&
|
||||
target.kind === 'environment' &&
|
||||
'environmentId' in target &&
|
||||
typeof target.environmentId === 'string'
|
||||
) {
|
||||
return { sessionId, target: { kind: 'environment', environmentId: target.environmentId } }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
+6
@@ -1838,6 +1838,12 @@
|
||||
"settingsUnconfirmed": "Codex did not confirm the selected model and effort. This goal change was not sent. Check the chat and try again.",
|
||||
"stopUnconfirmed": "Codex could not confirm Stop. Check the chat before continuing."
|
||||
},
|
||||
"mcp": {
|
||||
"status": {
|
||||
"connectedTools_one": "Connected · {{count}} tool",
|
||||
"connectedTools_other": "Connected · {{count}} tools"
|
||||
}
|
||||
},
|
||||
"notices": {
|
||||
"compaction": "Context compacted",
|
||||
"contextCleared": "Context cleared",
|
||||
|
||||
@@ -21375,7 +21375,8 @@
|
||||
"export": "Export the session to HTML",
|
||||
"settings": "Open settings in Terminal",
|
||||
"extensions": "Open the extension dashboard in Terminal",
|
||||
"hotkeys": "Show keyboard shortcuts in Terminal"
|
||||
"hotkeys": "Show keyboard shortcuts in Terminal",
|
||||
"mcpManage": "Manage MCP servers"
|
||||
},
|
||||
"modelDescriptions": {
|
||||
"grok_4_7": "xAI's latest frontier model",
|
||||
@@ -21491,6 +21492,51 @@
|
||||
"alreadyRedeemed": "That reset credit was already used."
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP servers",
|
||||
"loading": "Loading MCP servers…",
|
||||
"notRunning": "MCP servers connect when you send your next message.",
|
||||
"unsupported": "Update Orca on this chat’s host to manage MCP servers here.",
|
||||
"readFailed": "MCP servers couldn’t be loaded. Try again.",
|
||||
"retry": "Retry",
|
||||
"empty": "No MCP servers are set up for this chat.",
|
||||
"cancel": "Cancel",
|
||||
"reconnect": "Reconnect",
|
||||
"toggle": "Use {{name}}",
|
||||
"actionFailed": "Couldn’t do that. Try again.",
|
||||
"source": {
|
||||
"project": "Project",
|
||||
"local": "Local",
|
||||
"user": "User",
|
||||
"plugin": "Plugin",
|
||||
"session": "Session"
|
||||
},
|
||||
"status": {
|
||||
"connected": "Connected",
|
||||
"connectedTools_one": "Connected · {{count}} tool",
|
||||
"connectedTools_other": "Connected · {{count}} tools",
|
||||
"pending": "Connecting…",
|
||||
"needsAuth": "Needs sign-in",
|
||||
"failed": "Failed",
|
||||
"failedReason": "Failed: {{error}}",
|
||||
"disabled": "Disabled",
|
||||
"needsAuthAfterSend": "Needs sign-in. Send a message to sign in here."
|
||||
},
|
||||
"signIn": {
|
||||
"action": "Sign in",
|
||||
"waiting": "Waiting for sign-in…",
|
||||
"waitingBrowser": "Waiting for sign-in in your browser…",
|
||||
"local": "Opened the sign-in page in your browser. This updates when you finish.",
|
||||
"remote": "After you sign in, the browser lands on a page that won’t load. Copy that page’s address and paste it here.",
|
||||
"external": "Finish signing in on the page that opened, then press Done.",
|
||||
"done": "Done",
|
||||
"finish": "Finish",
|
||||
"openAgain": "Open again",
|
||||
"addressLabel": "Sign-in page address",
|
||||
"unfinished": "Sign-in isn’t finished yet. Finish it on the page that opened, then press Done.",
|
||||
"pastePlaceholder": "http://localhost:…"
|
||||
}
|
||||
},
|
||||
"cardKindHidden": {
|
||||
"description": "You can change this anytime in",
|
||||
"settingsLink": "Settings > Chat UI > Turn activity",
|
||||
|
||||
@@ -48,12 +48,3 @@ export function useHostStructuredAgent(
|
||||
)
|
||||
return useAppViewExternalStore(subscribeHostStructuredAgents, read, read)
|
||||
}
|
||||
|
||||
/** `reported` is the session's options answer: absent before it lands and from an older host. */
|
||||
export function useStructuredAgentAcceptsImages(
|
||||
target: RuntimeClientTarget,
|
||||
agent: string,
|
||||
reported: boolean | undefined
|
||||
): boolean {
|
||||
return structuredAgentAcceptsImages(useHostStructuredAgent(target, agent), agent, reported)
|
||||
}
|
||||
|
||||
+2
@@ -7,6 +7,8 @@ export const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap<string, num
|
||||
// Waiting model catalogs can take Claude’s 60s listing plus 15s account switch and 5s login environment.
|
||||
['agentSession.modelCatalog', 90_000],
|
||||
['agentSession.sleepWorkspace', 120_000],
|
||||
// A cold Codex read or a Claude sign-in finish (exchange, reconnect, re-read) can run long.
|
||||
['agentSession.mcp', 90_000],
|
||||
// All bounded-concurrency provider starts and dispatch settlements precede the reply.
|
||||
['agentSession.restartContinue', 600_000]
|
||||
])
|
||||
|
||||
@@ -55,6 +55,7 @@ export type UISliceContextual = {
|
||||
| 'diagram-viewer'
|
||||
| 'agent-session-history'
|
||||
| 'chat-account-usage'
|
||||
| 'chat-mcp-servers'
|
||||
| 'node-dist-mirror'
|
||||
modalData: Record<string, unknown>
|
||||
openModal: (modal: UISliceContextual['activeModal'], data?: Record<string, unknown>) => void
|
||||
|
||||
@@ -10,6 +10,8 @@ export type AgentSessionCapabilities = {
|
||||
compactInstructions?: boolean
|
||||
/** `/compact` runs while background tasks keep running; absent, it waits for them to end. */
|
||||
compactBesideBackgroundTasks?: boolean
|
||||
/** `/mcp` opens Orca's panel for the agent's MCP servers (`agentSession.mcp`). */
|
||||
mcpServers?: boolean
|
||||
/** A goal the user can set and change on the thread. */
|
||||
threadGoal: boolean
|
||||
/** Turns report how much of the context window they used. */
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/** The chat's agent connects these MCP servers; the `/mcp` panel lists and manages them. */
|
||||
export type AgentSessionMcpServerStatus =
|
||||
| 'connected'
|
||||
| 'pending'
|
||||
| 'needs-auth'
|
||||
| 'failed'
|
||||
| 'disabled'
|
||||
|
||||
export type AgentSessionMcpServer = {
|
||||
name: string
|
||||
status: AgentSessionMcpServerStatus
|
||||
/** Where the agent found the server, e.g. claudeai, project, user, local, plugin; null when unknown. */
|
||||
source: string | null
|
||||
toolCount: number | null
|
||||
/** Short reason for a failed server, as the agent reported it. */
|
||||
error: string | null
|
||||
canSignIn: boolean
|
||||
canReconnect: boolean
|
||||
canToggle: boolean
|
||||
}
|
||||
|
||||
export type AgentSessionMcpOperation =
|
||||
| { action: 'status' }
|
||||
| { action: 'signIn'; server: string }
|
||||
/** The address the browser landed on after sign-in, for an agent the browser can't reach. */
|
||||
| { action: 'finishSignIn'; server: string; callbackUrl: string }
|
||||
| { action: 'reconnect'; server: string }
|
||||
| { action: 'toggle'; server: string; enabled: boolean }
|
||||
|
||||
export type AgentSessionMcpSignIn = {
|
||||
server: string
|
||||
url: string
|
||||
/**
|
||||
* `callback`: the agent waits for the browser to come back to it; a browser on another device
|
||||
* pastes that address instead. `external`: the provider's own site finishes it, then reconnect.
|
||||
*/
|
||||
finish: 'callback' | 'external'
|
||||
}
|
||||
|
||||
export type AgentSessionMcpResult =
|
||||
| { status: 'ok'; servers: AgentSessionMcpServer[]; signIn?: AgentSessionMcpSignIn }
|
||||
/** The chat has no running agent; its servers connect when it next starts. */
|
||||
| { status: 'not-running' }
|
||||
| { status: 'unsupported' }
|
||||
| { status: 'failed'; message: string | null }
|
||||
|
||||
/** A sign-in page the client may open: providers pass on URLs from server-controlled metadata. */
|
||||
export function agentSessionMcpWebUrl(url: unknown): string | null {
|
||||
if (typeof url !== 'string' || !URL.canParse(url)) {
|
||||
return null
|
||||
}
|
||||
const { protocol } = new URL(url)
|
||||
return protocol === 'https:' || protocol === 'http:' ? url : null
|
||||
}
|
||||
|
||||
/** Agents report free-form failures; keep one readable line. */
|
||||
export function agentSessionMcpFailureMessage(error: unknown): string | null {
|
||||
const text = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
const line = text.split('\n')[0]?.trim() ?? ''
|
||||
return line ? line.slice(0, 300) : null
|
||||
}
|
||||
@@ -30,6 +30,8 @@ const registeredAgentSchema = z.object({
|
||||
rewind: capabilityFlag,
|
||||
compact: capabilityFlag,
|
||||
threadGoal: capabilityFlag,
|
||||
// Optional, like fork: absent stays absent, and anything but true offers no MCP panel.
|
||||
mcpServers: z.literal(true).optional().catch(undefined),
|
||||
contextUsage: capabilityFlag,
|
||||
imagePrompts: capabilityFlag,
|
||||
// An unknown fork level reads as absent: the host never forks for this client.
|
||||
|
||||
@@ -111,6 +111,8 @@ export const AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY =
|
||||
// Why: hosts before this report goals in options but refuse agentSession.threadGoal from a phone.
|
||||
export const AGENT_SESSION_PHONE_THREAD_GOAL_RUNTIME_CAPABILITY =
|
||||
'agent-session.thread-goal.phone.v1' as const
|
||||
// The host answers agentSession.mcp, from desktops and phones alike.
|
||||
export const AGENT_SESSION_MCP_RUNTIME_CAPABILITY = 'agent-session.mcp.v1' as const
|
||||
// Why: hosts before this advertise usage-limit auto-continue but refuse its cancel from a phone.
|
||||
export const AGENT_SESSION_PHONE_USAGE_LIMIT_CONTINUE_CANCEL_RUNTIME_CAPABILITY =
|
||||
'agent-session.usage-limit-auto-continue.phone-cancel.v1' as const
|
||||
@@ -176,6 +178,7 @@ export const AGENT_SESSION_RUNTIME_CAPABILITIES = /* @__PURE__ */ (() =>
|
||||
AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_PHONE_THREAD_GOAL_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_MCP_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_PHONE_USAGE_LIMIT_CONTINUE_CANCEL_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_TURN_ITEM_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AgentSessionMcpServer, AgentSessionMcpSignIn } from '../agent-session-mcp'
|
||||
|
||||
export type AgentSessionMcpServerGroup = {
|
||||
/** null for an agent that reports no source; its servers show without a heading. */
|
||||
source: string | null
|
||||
servers: AgentSessionMcpServer[]
|
||||
}
|
||||
|
||||
/** Groups keep the order the agent listed their first server in. */
|
||||
export function groupAgentSessionMcpServers(
|
||||
servers: readonly AgentSessionMcpServer[]
|
||||
): AgentSessionMcpServerGroup[] {
|
||||
const groups = new Map<string | null, AgentSessionMcpServerGroup>()
|
||||
for (const server of servers) {
|
||||
const group = groups.get(server.source)
|
||||
if (group) {
|
||||
group.servers.push(server)
|
||||
} else {
|
||||
groups.set(server.source, { source: server.source, servers: [server] })
|
||||
}
|
||||
}
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
const CLAUDE_AI_PREFIX = 'claude.ai '
|
||||
|
||||
/** claude.ai connectors already sit under a claude.ai heading. */
|
||||
export function agentSessionMcpServerDisplayName(server: AgentSessionMcpServer): string {
|
||||
return server.source === 'claudeai' &&
|
||||
server.name.startsWith(CLAUDE_AI_PREFIX) &&
|
||||
server.name.length > CLAUDE_AI_PREFIX.length
|
||||
? server.name.slice(CLAUDE_AI_PREFIX.length)
|
||||
: server.name
|
||||
}
|
||||
|
||||
export type AgentSessionMcpRowFailure =
|
||||
| { kind: 'signInUnfinished' }
|
||||
| { kind: 'message'; message: string | null }
|
||||
|
||||
/** What a row shows for its last refused action, if anything. */
|
||||
export function agentSessionMcpRowFailure(
|
||||
server: AgentSessionMcpServer,
|
||||
failure: { server: string; message: string | null } | null,
|
||||
signIn: AgentSessionMcpSignIn | null
|
||||
): AgentSessionMcpRowFailure | null {
|
||||
if (failure?.server !== server.name) {
|
||||
return null
|
||||
}
|
||||
// The provider refuses a reconnect until its own site finishes the sign-in.
|
||||
if (signIn?.server === server.name && signIn.finish === 'external') {
|
||||
return { kind: 'signInUnfinished' }
|
||||
}
|
||||
// The status line already says why the server failed.
|
||||
return failure.message !== null && failure.message === server.error
|
||||
? null
|
||||
: { kind: 'message', message: failure.message }
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { agentSessionInteractionCommand } from '../options/agent-session-interaction-mode'
|
||||
import { getTextDrivenNativeChatCommands } from '../../native-chat/native-chat-agent-profiles'
|
||||
import type { AgentType } from '../../agent-status/contracts/agent-status-types'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionValue
|
||||
} from '../../native-chat/input/native-chat-session-options'
|
||||
import type { SlashCommandSuggestion } from '../../native-chat/input/native-chat-slash-commands'
|
||||
import {
|
||||
sessionSlashCommandSuggestions,
|
||||
type SlashCommandSuggestion
|
||||
} from '../../native-chat/input/native-chat-slash-commands'
|
||||
CONVERSATION_COMMANDS,
|
||||
STRUCTURED_AGENT_SESSION_SLASH_COMMANDS
|
||||
} from './structured-agent-session-slash-menu'
|
||||
import type { AgentSessionConversationCommand } from '../agent-session-conversation-command'
|
||||
import type { AgentSessionThreadGoalChange } from '../agent-session-thread-goal-wire'
|
||||
import type { AgentSessionSlashCommand } from '../agent-session-wire'
|
||||
@@ -18,19 +18,6 @@ import {
|
||||
type StructuredComposerFailureParams
|
||||
} from '../failures/structured-agent-session-composer-failure'
|
||||
|
||||
const COMPACT_INSTRUCTIONS_HINT = '[instructions]'
|
||||
|
||||
const CONVERSATION_COMMANDS: readonly SlashCommandSuggestion[] = [
|
||||
{ name: 'clear', description: 'Clear conversation context' },
|
||||
{ name: 'compact', description: 'Compact conversation context' }
|
||||
]
|
||||
|
||||
/** Session options remain available on hosts predating conversation commands. */
|
||||
export const STRUCTURED_AGENT_SESSION_SLASH_COMMANDS: readonly SlashCommandSuggestion[] = [
|
||||
{ name: 'model', description: 'Choose the model' },
|
||||
{ name: 'effort', description: 'Choose reasoning effort' }
|
||||
]
|
||||
|
||||
export type StructuredAgentSessionComposerOptions = {
|
||||
agent?: AgentType
|
||||
snapshot: readonly SessionOptionDescriptor[]
|
||||
@@ -48,6 +35,8 @@ export type StructuredAgentSessionComposerOptions = {
|
||||
) => Promise<Omit<StructuredAgentSessionCommandOutcome, 'handled'>>
|
||||
/** Without host goal support, /goal remains agent-handled text. */
|
||||
changeThreadGoal?: (change: AgentSessionThreadGoalChange) => Promise<boolean>
|
||||
/** Without it, /mcp remains agent-handled text. */
|
||||
openMcpPanel?: () => void
|
||||
}
|
||||
|
||||
/** Visible condition holding a refusal: agent/prompt/background work, an in-flight send or a Retry card. */
|
||||
@@ -86,63 +75,6 @@ function commandParts(text: string): { name: string; argument: string } | null {
|
||||
: { name, argument }
|
||||
}
|
||||
|
||||
/** Fallback menu combines host-owned and agent text-driven commands when no catalog is reported. */
|
||||
export function structuredSlashCommands(
|
||||
commands: readonly AgentSessionConversationCommand[] = [],
|
||||
agent?: AgentType | null,
|
||||
compactInstructions = false
|
||||
): readonly SlashCommandSuggestion[] {
|
||||
const hostOwned = [
|
||||
...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS,
|
||||
...CONVERSATION_COMMANDS.filter((entry) =>
|
||||
commands.includes(entry.name as AgentSessionConversationCommand)
|
||||
).map((entry) =>
|
||||
entry.name === 'compact' && compactInstructions
|
||||
? { ...entry, argumentHint: COMPACT_INSTRUCTIONS_HINT }
|
||||
: entry
|
||||
)
|
||||
]
|
||||
// No reported catalog must not hide agent-handled commands such as Codex /goal.
|
||||
return [
|
||||
...hostOwned,
|
||||
...getTextDrivenNativeChatCommands(agent).filter(
|
||||
(entry) => !hostOwned.some((offered) => offered.name === entry.name)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
/** Host commands remain first; reported catalogs add other names. */
|
||||
export function structuredSessionSlashCommands(
|
||||
agent: AgentType,
|
||||
conversationCommands: readonly AgentSessionConversationCommand[] | undefined,
|
||||
reported: readonly AgentSessionSlashCommand[] | undefined,
|
||||
compactInstructions?: boolean,
|
||||
interactionModeSupported = false
|
||||
): readonly SlashCommandSuggestion[] {
|
||||
const modeCommands: SlashCommandSuggestion[] = interactionModeSupported
|
||||
? [
|
||||
{ name: 'plan', description: 'Switch to Plan mode', orcaOwned: true },
|
||||
...(agent === 'codex'
|
||||
? [{ name: 'default', description: 'Switch to Build mode', orcaOwned: true as const }]
|
||||
: [])
|
||||
]
|
||||
: []
|
||||
const hostOwned = [
|
||||
...modeCommands,
|
||||
...structuredSlashCommands(conversationCommands, agent, compactInstructions).filter(
|
||||
({ name }) => !modeCommands.some((entry) => entry.name === name)
|
||||
)
|
||||
]
|
||||
if (reported === undefined) {
|
||||
return hostOwned
|
||||
}
|
||||
const offered = new Set(hostOwned.map(({ name }) => name))
|
||||
return [
|
||||
...hostOwned,
|
||||
...sessionSlashCommandSuggestions(agent, reported).filter(({ name }) => !offered.has(name))
|
||||
]
|
||||
}
|
||||
|
||||
function structuredRecognizedCommands(): readonly SlashCommandSuggestion[] {
|
||||
return [...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS, ...CONVERSATION_COMMANDS]
|
||||
}
|
||||
@@ -163,16 +95,18 @@ export function isStructuredAgentSessionComposerCommand(
|
||||
)
|
||||
}
|
||||
|
||||
/** Host actions send no message; /goal is an action only on hosts that set goals. */
|
||||
/** Host actions send no message; /goal and /mcp are actions only where the host offers them. */
|
||||
export function isStructuredAgentSessionHostCommand(
|
||||
text: string,
|
||||
hostSetsGoals: boolean,
|
||||
agent: AgentType | undefined,
|
||||
snapshot: readonly SessionOptionDescriptor[]
|
||||
snapshot: readonly SessionOptionDescriptor[],
|
||||
opensMcpPanel = false
|
||||
): boolean {
|
||||
return (
|
||||
isStructuredAgentSessionComposerCommand(text, agent, snapshot) ||
|
||||
(hostSetsGoals && isStructuredAgentSessionGoalCommand(text))
|
||||
(hostSetsGoals && isStructuredAgentSessionGoalCommand(text)) ||
|
||||
(opensMcpPanel && commandParts(text)?.name === 'mcp')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -258,6 +192,10 @@ export async function dispatchStructuredAgentSessionComposerCommand(
|
||||
}
|
||||
}
|
||||
const command = commandParts(text)
|
||||
if (command?.name === 'mcp' && controller.openMcpPanel) {
|
||||
controller.openMcpPanel()
|
||||
return { handled: true, accepted: true, error: null }
|
||||
}
|
||||
if (command?.name === 'goal' && controller.changeThreadGoal) {
|
||||
if (!command.argument) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getTextDrivenNativeChatCommands } from '../../native-chat/native-chat-agent-profiles'
|
||||
import type { AgentType } from '../../agent-status/contracts/agent-status-types'
|
||||
import {
|
||||
sessionSlashCommandSuggestions,
|
||||
type SlashCommandSuggestion
|
||||
} from '../../native-chat/input/native-chat-slash-commands'
|
||||
import type { AgentSessionConversationCommand } from '../agent-session-conversation-command'
|
||||
import type { AgentSessionSlashCommand } from '../agent-session-wire'
|
||||
|
||||
const COMPACT_INSTRUCTIONS_HINT = '[instructions]'
|
||||
|
||||
export const CONVERSATION_COMMANDS: readonly SlashCommandSuggestion[] = [
|
||||
{ name: 'clear', description: 'Clear conversation context' },
|
||||
{ name: 'compact', description: 'Compact conversation context' }
|
||||
]
|
||||
|
||||
/** Orca's MCP panel, offered where the host manages this agent's servers. */
|
||||
export const STRUCTURED_AGENT_SESSION_MCP_COMMAND: SlashCommandSuggestion = {
|
||||
name: 'mcp',
|
||||
description: 'Manage MCP servers',
|
||||
orcaOwned: true
|
||||
}
|
||||
|
||||
/** Session options remain available on hosts predating conversation commands. */
|
||||
export const STRUCTURED_AGENT_SESSION_SLASH_COMMANDS: readonly SlashCommandSuggestion[] = [
|
||||
{ name: 'model', description: 'Choose the model' },
|
||||
{ name: 'effort', description: 'Choose reasoning effort' }
|
||||
]
|
||||
|
||||
/** Fallback menu combines host-owned and agent text-driven commands when no catalog is reported. */
|
||||
export function structuredSlashCommands(
|
||||
commands: readonly AgentSessionConversationCommand[] = [],
|
||||
agent?: AgentType | null,
|
||||
compactInstructions = false
|
||||
): readonly SlashCommandSuggestion[] {
|
||||
const hostOwned = [
|
||||
...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS,
|
||||
...CONVERSATION_COMMANDS.filter((entry) =>
|
||||
commands.includes(entry.name as AgentSessionConversationCommand)
|
||||
).map((entry) =>
|
||||
entry.name === 'compact' && compactInstructions
|
||||
? { ...entry, argumentHint: COMPACT_INSTRUCTIONS_HINT }
|
||||
: entry
|
||||
)
|
||||
]
|
||||
// No reported catalog must not hide agent-handled commands such as Codex /goal.
|
||||
return [
|
||||
...hostOwned,
|
||||
...getTextDrivenNativeChatCommands(agent).filter(
|
||||
(entry) => !hostOwned.some((offered) => offered.name === entry.name)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
/** Host commands remain first; reported catalogs add other names. */
|
||||
export function structuredSessionSlashCommands(
|
||||
agent: AgentType,
|
||||
conversationCommands: readonly AgentSessionConversationCommand[] | undefined,
|
||||
reported: readonly AgentSessionSlashCommand[] | undefined,
|
||||
compactInstructions?: boolean,
|
||||
interactionModeSupported = false,
|
||||
mcpPanel = false
|
||||
): readonly SlashCommandSuggestion[] {
|
||||
const modeCommands: SlashCommandSuggestion[] = interactionModeSupported
|
||||
? [
|
||||
{ name: 'plan', description: 'Switch to Plan mode', orcaOwned: true },
|
||||
...(agent === 'codex'
|
||||
? [{ name: 'default', description: 'Switch to Build mode', orcaOwned: true as const }]
|
||||
: [])
|
||||
]
|
||||
: []
|
||||
const orcaOwned = [...modeCommands, ...(mcpPanel ? [STRUCTURED_AGENT_SESSION_MCP_COMMAND] : [])]
|
||||
const hostOwned = [
|
||||
...orcaOwned,
|
||||
...structuredSlashCommands(conversationCommands, agent, compactInstructions).filter(
|
||||
({ name }) => !orcaOwned.some((entry) => entry.name === name)
|
||||
)
|
||||
]
|
||||
if (reported === undefined) {
|
||||
return hostOwned
|
||||
}
|
||||
const offered = new Set(hostOwned.map(({ name }) => name))
|
||||
return [
|
||||
...hostOwned,
|
||||
...sessionSlashCommandSuggestions(agent, reported).filter(({ name }) => !offered.has(name))
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type * as React from 'react'
|
||||
import type {
|
||||
AgentSessionMcpOperation,
|
||||
AgentSessionMcpResult,
|
||||
AgentSessionMcpServer,
|
||||
AgentSessionMcpSignIn
|
||||
} from '../agent-session-mcp'
|
||||
|
||||
export type AgentSessionMcpRequest = (
|
||||
operation: AgentSessionMcpOperation
|
||||
) => Promise<AgentSessionMcpResult>
|
||||
|
||||
export type AgentSessionMcpPanelState = {
|
||||
/** null while the first read is in flight. */
|
||||
result: Exclude<AgentSessionMcpResult, { status: 'ok' }> | { status: 'ok' } | null
|
||||
servers: readonly AgentSessionMcpServer[]
|
||||
/** The action in flight, one at a time. */
|
||||
pending: Exclude<AgentSessionMcpOperation, { action: 'status' }> | null
|
||||
signIn: AgentSessionMcpSignIn | null
|
||||
/** The last action's refusal, as the agent worded it; null message means no detail. */
|
||||
failure: { server: string; message: string | null } | null
|
||||
}
|
||||
|
||||
const POLL_MS = 2_000
|
||||
const MAX_POLL_MS = 15_000
|
||||
// Agents close their sign-in callback after five minutes.
|
||||
const SIGN_IN_WINDOW_MS = 5 * 60_000
|
||||
|
||||
/** State for the `/mcp` panel; `openUrl` opens a sign-in page in the device's browser. */
|
||||
export function createAgentSessionMcpPanelHook({
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
}: Pick<typeof React, 'useCallback' | 'useEffect' | 'useRef' | 'useState'>) {
|
||||
return function useAgentSessionMcpPanel(
|
||||
request: AgentSessionMcpRequest,
|
||||
openUrl: (url: string) => void
|
||||
) {
|
||||
const [state, setState] = useState<AgentSessionMcpPanelState>({
|
||||
result: null,
|
||||
servers: [],
|
||||
pending: null,
|
||||
signIn: null,
|
||||
failure: null
|
||||
})
|
||||
const busy = useRef(false)
|
||||
const reading = useRef(false)
|
||||
// Bumped as each action starts and ends, so a read that overlapped one can't show older state.
|
||||
const actions = useRef(0)
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => {
|
||||
mounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const apply = useCallback(
|
||||
(reply: AgentSessionMcpResult, operation: AgentSessionMcpOperation) => {
|
||||
if (!mounted.current) {
|
||||
return
|
||||
}
|
||||
setState((current) => {
|
||||
// A status read lands beside an action still in flight; only the action's reply ends it.
|
||||
const pending = operation.action === 'status' ? current.pending : null
|
||||
if (reply.status !== 'ok') {
|
||||
const server = 'server' in operation ? operation.server : null
|
||||
// A failed sign-in poll keeps the list and the sign-in it watches.
|
||||
if (!server && reply.status === 'failed' && current.result?.status === 'ok') {
|
||||
return current
|
||||
}
|
||||
// A failed action keeps the list; a failed first read replaces it.
|
||||
return server && reply.status === 'failed'
|
||||
? { ...current, pending, failure: { server, message: reply.message } }
|
||||
: { ...current, pending, result: reply, servers: [], signIn: null }
|
||||
}
|
||||
const signIn =
|
||||
reply.signIn ??
|
||||
(current.signIn &&
|
||||
reply.servers.some(
|
||||
(entry) => entry.name === current.signIn?.server && entry.status !== 'connected'
|
||||
)
|
||||
? current.signIn
|
||||
: null)
|
||||
return {
|
||||
result: { status: 'ok' },
|
||||
servers: reply.servers,
|
||||
pending,
|
||||
signIn,
|
||||
failure: operation.action === 'status' ? current.failure : null
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const read = useCallback(async () => {
|
||||
if (reading.current) {
|
||||
return
|
||||
}
|
||||
reading.current = true
|
||||
const operation = { action: 'status' } as const
|
||||
const started = actions.current
|
||||
const reply = await request(operation).catch((): AgentSessionMcpResult => ({
|
||||
status: 'failed',
|
||||
message: null
|
||||
}))
|
||||
reading.current = false
|
||||
if (actions.current === started) {
|
||||
apply(reply, operation)
|
||||
}
|
||||
}, [apply, request])
|
||||
|
||||
const run = useCallback(
|
||||
async (operation: Exclude<AgentSessionMcpOperation, { action: 'status' }>) => {
|
||||
if (busy.current) {
|
||||
return
|
||||
}
|
||||
busy.current = true
|
||||
actions.current += 1
|
||||
setState((current) => ({ ...current, pending: operation, failure: null }))
|
||||
const reply = await request(operation).catch((): AgentSessionMcpResult => ({
|
||||
status: 'failed',
|
||||
message: null
|
||||
}))
|
||||
busy.current = false
|
||||
actions.current += 1
|
||||
// A panel closed while the request ran must not open a browser.
|
||||
if (reply.status === 'ok' && reply.signIn && mounted.current) {
|
||||
openUrl(reply.signIn.url)
|
||||
}
|
||||
apply(reply, operation)
|
||||
// Agents refuse an action that half-happened (a server enabled but needing sign-in); show
|
||||
// where it ended up.
|
||||
if (reply.status === 'failed') {
|
||||
void read()
|
||||
}
|
||||
},
|
||||
[apply, openUrl, read, request]
|
||||
)
|
||||
|
||||
const dismissSignIn = useCallback(() => {
|
||||
setState((current) => ({ ...current, signIn: null, failure: null }))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void read()
|
||||
}, [read])
|
||||
|
||||
// Nothing reports when an agent finishes a callback sign-in or a server settles from
|
||||
// connecting; re-read with backoff, and stop at the agents' own five-minute window.
|
||||
const watching = state.signIn?.finish === 'callback' ? state.signIn : null
|
||||
const connecting = state.servers.some((server) => server.status === 'pending')
|
||||
useEffect(() => {
|
||||
if (!watching && !connecting) {
|
||||
return
|
||||
}
|
||||
const started = Date.now()
|
||||
let delay = POLL_MS
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const tick = (): void => {
|
||||
if (Date.now() - started > SIGN_IN_WINDOW_MS) {
|
||||
setState((current) =>
|
||||
watching && current.signIn === watching ? { ...current, signIn: null } : current
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!busy.current) {
|
||||
void read()
|
||||
}
|
||||
delay = Math.min(delay * 1.5, MAX_POLL_MS)
|
||||
timer = setTimeout(tick, delay)
|
||||
}
|
||||
timer = setTimeout(tick, delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [connecting, read, watching])
|
||||
|
||||
return { ...state, run, retry: read, dismissSignIn }
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,24 @@ export function queuedMessageEditAcceptsText(
|
||||
agent: AgentType,
|
||||
reported: readonly AgentSessionSlashCommand[] | undefined,
|
||||
hostSetsGoals: boolean,
|
||||
hostSetsInteractionMode: boolean
|
||||
hostSetsInteractionMode: boolean,
|
||||
opensMcpPanel = false
|
||||
): boolean {
|
||||
return prepareQueuedMessageEditText(agent, reported, hostSetsGoals, hostSetsInteractionMode)(text)
|
||||
return prepareQueuedMessageEditText(
|
||||
agent,
|
||||
reported,
|
||||
hostSetsGoals,
|
||||
hostSetsInteractionMode,
|
||||
opensMcpPanel
|
||||
)(text)
|
||||
}
|
||||
|
||||
export function prepareQueuedMessageEditText(
|
||||
agent: AgentType,
|
||||
reported: readonly AgentSessionSlashCommand[] | undefined,
|
||||
hostSetsGoals: boolean,
|
||||
hostSetsInteractionMode: boolean
|
||||
hostSetsInteractionMode: boolean,
|
||||
opensMcpPanel = false
|
||||
): (text: string) => boolean {
|
||||
const hostCommand = prepareStructuredAgentSessionComposerCommand(agent, reported)
|
||||
return (text) =>
|
||||
@@ -29,6 +37,7 @@ export function prepareQueuedMessageEditText(
|
||||
hostCommand(text) ||
|
||||
(hostSetsInteractionMode && agentSessionInteractionCommand(agent, text) !== null) ||
|
||||
(hostSetsGoals && isStructuredAgentSessionGoalCommand(text)) ||
|
||||
/^\/resume(?:\s|$)/i.test(text)
|
||||
/^\/resume(?:\s|$)/i.test(text) ||
|
||||
(opensMcpPanel && /^\/mcp(?:\s|$)/i.test(text))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -660,6 +660,7 @@ import { CurrentRequestsParams } from './structured-agent-session/agent-session-
|
||||
import { ReadVisualParams } from './structured-agent-session/agent-session-visual-params'
|
||||
import { ContinueInterruptedParams } from './structured-agent-session/structured-agent-session-continue-params'
|
||||
import { ContinuationContextParams } from './structured-agent-session/structured-agent-session-identifiers'
|
||||
import { AgentSessionMcpParams } from './structured-agent-session/structured-agent-session-mcp-params'
|
||||
import {
|
||||
AcknowledgeAttentionParams,
|
||||
AgentsParams,
|
||||
@@ -878,6 +879,7 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'agentSession.history': HistoryParams,
|
||||
'agentSession.hold': HoldParams,
|
||||
'agentSession.item': ItemParams,
|
||||
'agentSession.mcp': AgentSessionMcpParams,
|
||||
'agentSession.modelCatalog': ModelCatalogParams,
|
||||
'agentSession.options': OptionsParams,
|
||||
'agentSession.queuedMessageDelete': QueuedMessageActionParams,
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import * as z from 'zod'
|
||||
import { SessionId } from './structured-agent-session-identifiers'
|
||||
|
||||
const ServerName = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.refine((name) => [...name].every((char) => char >= ' ' && char !== '\u007f'))
|
||||
|
||||
export const AgentSessionMcpParams = z
|
||||
.object({
|
||||
sessionId: SessionId,
|
||||
operation: z.discriminatedUnion('action', [
|
||||
z.object({ action: z.literal('status') }).strict(),
|
||||
z.object({ action: z.literal('signIn'), server: ServerName }).strict(),
|
||||
z
|
||||
.object({
|
||||
action: z.literal('finishSignIn'),
|
||||
server: ServerName,
|
||||
callbackUrl: z.string().min(1).max(8192)
|
||||
})
|
||||
.strict(),
|
||||
z.object({ action: z.literal('reconnect'), server: ServerName }).strict(),
|
||||
z.object({ action: z.literal('toggle'), server: ServerName, enabled: z.boolean() }).strict()
|
||||
])
|
||||
})
|
||||
.strict()
|
||||
Reference in New Issue
Block a user