src/vs/platform/agentHost/node/codex/codexAgent.ts

4389 LOC · 1648 covered · 2741 uncovered · 213 ranges · 25 concepts · 12 introducers · 13 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the filesrc/vs/platform/agentHost/common/codexSessionConfigKeys.ts · 114 LOCcommon/codexSessionConfi…src/vs/platform/agentHost/node/codex/codexSessionConfigKeys.ts · 197 LOCcodex/codexSessionConfig…src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts · 112 LOCcodex/codexSessionMetada…codexAgent.ts ×13 · 83 introduced LOCcodexAgent.ts ×13codexSessionConfigKeys.test|title=codexSessionConfigKeys resolveSessionConfig preserves legacy read-only permissions on restore|occurrence=1 · 0 introduced LOCcodexSessionConfigKeys.t…codexSessionConfigKeys.test|title=codexSessionConfigKeys resolveSessionConfig exposes a single permissions-preset chip|occurrence=1 · 0 introduced LOCcodexSessionConfigKeys.t…codexAgent.ts ×1 · 27 introduced LOCcodexAgent.ts ×1codexAgent.ts ×24 · 89 introduced LOCcodexAgent.ts ×24codexSessionConfigKeys.ts ×1 · 7 introduced LOCcodexSessionConfigKeys.t…codexSessionConfigKeys.ts ×1 · 2 introduced LOCcodexSessionConfigKeys.t…codexSessionConfigKeys.ts ×7 · 49 introduced LOCcodexSessionConfigKeys.t…codexSessionConfigKeys.ts ×1 · 11 introduced LOCcodexSessionConfigKeys.t…codexSessionConfigKeys.ts ×1 · 2 introduced LOCcodexSessionConfigKeys.t…codexSessionConfigKeys.ts ×2 · 4 introduced LOCcodexSessionConfigKeys.t…codexSessionConfigKeys.ts ×3 · 18 introduced LOCcodexSessionConfigKeys.t…codexSessionConfigKeys.ts ×3 · 19 introduced LOCcodexSessionConfigKeys.t…codexSessionConfigKeys.ts ×2 · 3 introduced LOCcodexSessionConfigKeys.t…codexSessionConfigKeys.ts ×2 · 2 introduced LOCcodexSessionConfigKeys.t…codexSessionConfigKeys.ts ×6 · 23 introduced LOCcodexSessionConfigKeys.t…codexAgent.ts ×1 · 2 introduced LOCcodexAgent.ts ×1codexAgent.ts ×1 · 2 introduced LOCcodexAgent.ts ×1codexAgent.ts ×3 · 7 introduced LOCcodexAgent.ts ×3codexAgent.ts ×1 · 2 introduced LOCcodexAgent.ts ×1codexAgent.ts ×1 · 2 introduced LOCcodexAgent.ts ×1codexAgent.ts ×1 · 2 introduced LOCcodexAgent.ts ×1codexAgent.ts ×1 · 10 introduced LOCcodexAgent.ts ×1codexAgent.ts ×8 · 19 introduced LOCcodexAgent.ts ×8codexAgent.ts ×158 · 1640 introduced LOCcodexAgent.ts ×158codexModelRefresh.test|title=CodexAgent model refresh keeps the last known-good models when a periodic refresh fails|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexModelRefresh.test|title=CodexAgent model refresh keeps the last known-good models when a periodic refresh fails|occurrence=1codexModelRefresh.test|t…codexPackagePaths.test|title=codex package paths codexBinaryTriple every suffix produced by codexPackageSuffix maps to a rust target triple|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexPackagePaths.test|title=codex package paths codexBinaryTriple every suffix produced by codexPackageSuffix maps to a rust target triple|occurrence=1codexPackagePaths.test|t…codexPackagePaths.test|title=codex package paths codexBinaryTriple returns undefined for unknown suffixes|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexPackagePaths.test|title=codex package paths codexBinaryTriple returns undefined for unknown suffixes|occurrence=1codexPackagePaths.test|t…codexPackagePaths.test|title=codex package paths codexPackageSuffix every supported (platform, arch) returns the npm optionalDependencies suffix|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexPackagePaths.test|title=codex package paths codexPackageSuffix every supported (platform, arch) returns the npm optionalDependencies suffix|occurrence=1codexPackagePaths.test|t…codexPackagePaths.test|title=codex package paths codexPackageSuffix never returns a -musl suffix on Linux (Codex is statically musl-linked)|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexPackagePaths.test|title=codex package paths codexPackageSuffix never returns a -musl suffix on Linux (Codex is statically musl-linked)|occurrence=1codexPackagePaths.test|t…codexPackagePaths.test|title=codex package paths codexPackageSuffix returns undefined for unsupported platforms and architectures|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexPackagePaths.test|title=codex package paths codexPackageSuffix returns undefined for unsupported platforms and architectures|occurrence=1codexPackagePaths.test|t…codex resolves|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexPackagePaths.test|title=codex package paths resolveCodexDevSdkRoot returns the directory containing node_modules when @openai/codex resolves|occurrence=1codex resolves|occurrenc…codexPackagePaths.test|title=codex package paths resolveCodexDevSdkRoot returns undefined when resolution throws (e.g. built product without the devDependency)|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexPackagePaths.test|title=codex package paths resolveCodexDevSdkRoot returns undefined when resolution throws (e.g. built product without the devDependency)|occurrence=1codexPackagePaths.test|t…codexSessionConfigKeys.test|title=codexSessionConfigKeys expands permissions presets and falls back to legacy axes|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test|title=codexSessionConfigKeys expands permissions presets and falls back to legacy axes|occurrence=1codexSessionConfigKeys.t…codexSessionConfigKeys.test|title=codexSessionConfigKeys inverts presets and migrates legacy axes without escalating|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test|title=codexSessionConfigKeys inverts presets and migrates legacy axes without escalating|occurrence=1codexSessionConfigKeys.t…codexSessionConfigKeys.test|title=codexSessionConfigKeys narrows valid values and rejects invalid values|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test|title=codexSessionConfigKeys narrows valid values and rejects invalid values|occurrence=1codexSessionConfigKeys.t…codexSessionConfigKeys.test|title=codexSessionConfigKeys resolveSessionConfig exposes a single permissions-preset chip|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test|title=codexSessionConfigKeys resolveSessionConfig exposes a single permissions-preset chip|occurrence=1codexSessionConfigKeys.t…codexSessionConfigKeys.test|title=codexSessionConfigKeys resolveSessionConfig preserves legacy read-only permissions on restore|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test|title=codexSessionConfigKeys resolveSessionConfig preserves legacy read-only permissions on restore|occurrence=1codexSessionConfigKeys.t…Focused file · src/vs/platform/agentHost/node/codex/codexAgent.ts · 4389 LOCcodex/codexAgent.ts

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- codexAgent.ts ×158
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
7 > import * as fs from 'fs';
8 > import * as os from 'os';
9 > import { CancellationError } from '../../../../base/common/errors.js';
10 > import { raceTimeout } from '../../../../base/common/async.js';
11 > import { fetchResourceMetadata } from '../../../../base/common/oauth.js';
12 > import { Emitter } from '../../../../base/common/event.js';
13 > import { Disposable } from '../../../../base/common/lifecycle.js';
14 > import { type IObservable, observableValue } from '../../../../base/common/observable.js';
15 > import { basename, dirname, isAbsolute, join, resolve, sep } from '../../../../base/common/path.js';
16 > import { StopWatch } from '../../../../base/common/stopwatch.js';
17 > import { URI } from '../../../../base/common/uri.js';
18 > import { generateUuid } from '../../../../base/common/uuid.js';
19 > import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
20 > import { localize } from '../../../../nls.js';
21 > import { ILogService } from '../../../log/common/log.js';
22 > import { IProductService } from '../../../product/common/productService.js';
23 > import { createSchema, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostMcpServersConfigKey, type ISchemaProperty, type SessionMode } from '../../common/agentHostSchema.js';
24 > import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../common/agentModelPricing.js';
25 > import { AgentHostConfigKey, agentHostCustomizationConfigSchema, type CodexUsageSource } from '../../common/agentHostCustomizationConfig.js';
26 > import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js';
27 > import { AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentSdkRootEnvVar, AgentSession, AgentSignal, CODEX_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChats, IAgentCreateChatForkSource, IAgentCreateChatResult, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IMcpNotification, type AgentProvider, type AuthenticateParams } from '../../common/agentService.js';
28 > import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
29 > import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js';
30 > import { ActionType, isChatAction, type SessionAction, type ChatAction } from '../../common/state/sessionActions.js';
31 > import type { ConfigSchema, ModelSelection, ProtectedResourceMetadata, ToolDefinition, AgentSelection } from '../../common/state/protocol/state.js';
32 > import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
33 > import { AuthRequiredReason, type AuthRequiredParams } from '../../common/state/protocol/common/notifications.js';
34 > import { buildDefaultChatUri, parseChatUri, type ClientPluginCustomization, type DirectoryCustomization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, ChatInputResponseKind, type PolicyState, type ToolCallResult, ToolResultContentType, type Turn, ResponsePartKind } from '../../common/state/sessionState.js';
35 > import type { IAgentServerToolHost } from '../../common/agentServerTools.js';
36 > import { ActiveClientToolSet } from '../activeClientState.js';
37 > import { McpCustomizationController } from '../shared/mcpCustomizationController.js';
38 > import { buildCodexMcpReadResult, codexMcpListToInventory, codexMcpServersFromConfig, codexMcpToolsChanged, codexStartupErrorNeedsAuth, injectCodexMcpAuthTokens, inventoryToSdkServers, normalizeCodexMcpResourceUrl, translateCodexMcpStartupState, type ICodexMcpServerConfigJson, type ICodexMcpServerEntry } from './codexMcpServers.js';
39 > import { codexHooksToContainers, codexSkillsToContainers } from './codexCustomizations.js';
40 > import { CodexClientCustomizationStore, codexMcpServersFromPlugins, codexSkillRootsFromPlugins, type ICodexClientPlugin } from './codexClientCustomizations.js';
41 > import { buildElicitationRequest, cancelledElicitationResponse, declinedElicitationResponse, elicitationResponseFromAnswers } from './codexElicitationMapper.js';
42 > import { McpAuthRequiredReason, McpServerStatus, type AhpMcpUiHostCapabilities, type Customization, type McpServerState } from '../../common/state/protocol/channels-session/state.js';
43 > import { IAgentConfigurationService } from '../agentConfigurationService.js';
44 > import { IFileService } from '../../../files/common/files.js';
45 > import { INativeEnvironmentService } from '../../../environment/common/environment.js';
46 > import { IAgentPluginManager, type ISyncedCustomization } from '../../common/agentPluginManager.js';
47 > import { parsePlugin } from '../../../agentPlugins/common/pluginParsers.js';
48 > import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js';
49 > import { ICopilotApiService } from '../shared/copilotApiService.js';
50 > import { extractForwardedErrorInfo } from '../shared/forwardedChatError.js';
51 > import { IAgentSdkDownloader, IAgentSdkPackage } from '../agentSdkDownloader.js';
52 > import { CancellationToken } from '../../../../base/common/cancellation.js';
53 > import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js';
54 > import { CodexAppServerClient, JsonRpcError, transportFromChildProcess, type ICodexAppServerClient, type ServerRequestHandlerResult } from './codexAppServerClient.js';
55 > import { ICodexProxyService, type ICodexProxyHandle } from './codexProxyService.js';
56 > import { createCodexSessionMapState, extractUserInputText, mapAgentMessageDelta, mapCommandExecutionOutputDelta, mapFileChangeOutputDelta, mapFileChangePatchUpdated, mapItemCompleted, mapItemStarted, mapMcpToolCallProgress, mapReasoningSummaryPartAdded, mapReasoningSummaryTextDelta, mapReasoningTextDelta, mapTokenUsageUpdated, mapTurnCompleted, mapTurnStarted, resetCodexTurnMapState, type ICodexSessionMapState } from './codexMapAppServerEvents.js';
57 > import { unwrapShellInvocation } from './codexShellCommand.js';
58 > import { planForkedTurnIdMap, resolveForkBoundary } from './codexForkPlan.js';
59 > import { resolveCodexInput } from './codexPromptResolver.js';
60 > import { buildUserInputRequest, emptyUserInputResponse, userInputResponseFromAnswers } from './codexUserInputMapper.js';
61 > import { replayThreadToTurns } from './codexReplayMapper.js';
62 > import { CodexSessionMetadataStore } from './codexSessionMetadataStore.js';
63 > import { buildCodexLaunchConfig, buildCodexResumeParams, isCodexThreadProviderCompatible } from './codexLaunchConfig.js';
64 > import { codexAccountStateForUsageSource, codexAccountStateFromResponse, codexProtectedResourcesForUsageSource, resolveCodexUsageSourceAfterAccountRead, type ICodexAccountState } from './codexAccountState.js';
65 > import { CodexSessionConfigKey, CODEX_DEFAULT_PERMISSIONS_PRESET, CODEX_PERMISSIONS_PRESETS, collaborationModeKind, migrateCodexPermissionValues, narrowAdditionalDirectories, narrowBoolean, narrowPersonality, narrowReasoningEffort, narrowReasoningSummary, narrowWebSearchMode, resolveCodexPermissions, type CodexApprovalPolicy, type CodexPermissionsPreset, type ICodexResolvedPermissions } from './codexSessionConfigKeys.js';
66 > import type { ReasoningEffort } from './protocol/generated/ReasoningEffort.js';
67 > import type { ReasoningSummary } from './protocol/generated/ReasoningSummary.js';
68 > import type { Personality } from './protocol/generated/Personality.js';
69 > import type { WebSearchMode } from './protocol/generated/WebSearchMode.js';
70 > import type { SandboxMode } from './protocol/generated/v2/SandboxMode.js';
71 > import type { SandboxPolicy } from './protocol/generated/v2/SandboxPolicy.js';
72 > import type { CommandExecutionApprovalDecision } from './protocol/generated/v2/CommandExecutionApprovalDecision.js';
73 > import type { CommandExecutionRequestApprovalParams } from './protocol/generated/v2/CommandExecutionRequestApprovalParams.js';
74 > import type { CommandExecutionRequestApprovalResponse } from './protocol/generated/v2/CommandExecutionRequestApprovalResponse.js';
75 > import type { FileChangeApprovalDecision } from './protocol/generated/v2/FileChangeApprovalDecision.js';
76 > import type { FileChangeRequestApprovalParams } from './protocol/generated/v2/FileChangeRequestApprovalParams.js';
77 > import type { FileChangeRequestApprovalResponse } from './protocol/generated/v2/FileChangeRequestApprovalResponse.js';
78 > import type { PermissionsRequestApprovalParams } from './protocol/generated/v2/PermissionsRequestApprovalParams.js';
79 > import type { PermissionsRequestApprovalResponse } from './protocol/generated/v2/PermissionsRequestApprovalResponse.js';
80 > import type { DynamicToolSpec } from './protocol/generated/v2/DynamicToolSpec.js';
81 > import type { DynamicToolCallParams } from './protocol/generated/v2/DynamicToolCallParams.js';
82 > import type { DynamicToolCallResponse } from './protocol/generated/v2/DynamicToolCallResponse.js';
83 > import type { DynamicToolCallOutputContentItem } from './protocol/generated/v2/DynamicToolCallOutputContentItem.js';
84 > import type { ToolRequestUserInputParams } from './protocol/generated/v2/ToolRequestUserInputParams.js';
85 > import type { ToolRequestUserInputQuestion } from './protocol/generated/v2/ToolRequestUserInputQuestion.js';
86 > import type { ToolRequestUserInputResponse } from './protocol/generated/v2/ToolRequestUserInputResponse.js';
87 > import type { JsonValue } from './protocol/generated/serde_json/JsonValue.js';
88 > import type { GetAccountResponse } from './protocol/generated/v2/GetAccountResponse.js';
89 > import type { ModelListResponse } from './protocol/generated/v2/ModelListResponse.js';
90 > import type { Thread } from './protocol/generated/v2/Thread.js';
91 > import type { ThreadListResponse } from './protocol/generated/v2/ThreadListResponse.js';
92 > import type { ThreadReadResponse } from './protocol/generated/v2/ThreadReadResponse.js';
93 > import type { ThreadForkResponse } from './protocol/generated/v2/ThreadForkResponse.js';
94 > import type { TurnCompletedNotification } from './protocol/generated/v2/TurnCompletedNotification.js';
95 > import type { TurnStartedNotification } from './protocol/generated/v2/TurnStartedNotification.js';
96 > import type { ItemStartedNotification } from './protocol/generated/v2/ItemStartedNotification.js';
97 > import type { ItemCompletedNotification } from './protocol/generated/v2/ItemCompletedNotification.js';
98 > import type { TurnStartParams } from './protocol/generated/v2/TurnStartParams.js';
99 > import type { UserInput } from './protocol/generated/v2/UserInput.js';
100 > import type { ListMcpServerStatusResponse } from './protocol/generated/v2/ListMcpServerStatusResponse.js';
101 > import type { McpServerToolCallResponse } from './protocol/generated/v2/McpServerToolCallResponse.js';
102 > import type { McpResourceReadResponse } from './protocol/generated/v2/McpResourceReadResponse.js';
103 > import type { McpServerStartupState } from './protocol/generated/v2/McpServerStartupState.js';
104 > import type { McpServerElicitationRequestParams } from './protocol/generated/v2/McpServerElicitationRequestParams.js';
105 > import type { McpServerElicitationRequestResponse } from './protocol/generated/v2/McpServerElicitationRequestResponse.js';
106 > import type { SkillsListResponse } from './protocol/generated/v2/SkillsListResponse.js';
107 > import type { HooksListResponse } from './protocol/generated/v2/HooksListResponse.js';
108 > import type { ItemGuardianApprovalReviewCompletedNotification } from './protocol/generated/v2/ItemGuardianApprovalReviewCompletedNotification.js';
109 > import type { GuardianWarningNotification } from './protocol/generated/v2/GuardianWarningNotification.js';
110 > import type { ThreadApproveGuardianDeniedActionResponse } from './protocol/generated/v2/ThreadApproveGuardianDeniedActionResponse.js';
111 > import type { ConfigReadResponse } from './protocol/generated/v2/ConfigReadResponse.js';
112 > import type { ConfigWriteResponse } from './protocol/generated/v2/ConfigWriteResponse.js';
113 > import { formatGuardianDenialNotification, summarizeGuardianReviewAction, toGuardianAssessmentEventJson } from './codexGuardianReview.js';
114 >
115 > const CLIENT_INFO = {
116 > name: 'vscode_agent_host',
117 > title: 'VS Code Agent Host',
118 > // The codex `clientInfo.version` is informational. Hardcoded to a
119 > // non-empty placeholder; bumping it isn't required when our code
120 > // changes.
121 > version: '0.1.0',
122 > };
123 >
124 > const CODEX_THINKING_LEVEL_KEY = 'thinkingLevel';
125 >
126 > /**
127 > * User-agent prefix applied to the Codex agent's outbound CAPI calls (e.g. the
128 > * model-list fetch) so the traffic is identifiable server-side. Mirrors
129 > * `claudeAgent.ts` and the `vscode_codex` prefix used by `codexProxyService.ts`
130 > * and `oaiLanguageModelServer.ts`.
131 > */
132 > const USER_AGENT_PREFIX = 'vscode_codex';
133 >
134 > const CODEX_REASONING_EFFORTS: readonly ReasoningEffort[] = ['minimal', 'low', 'medium', 'high'];
135 >
136 > /**
137 > * MCP App capabilities advertised on every codex MCP server. Mirrors
138 > * {@link DEFAULT_MCP_APP_CAPABILITIES} but omits `sampling`: codex owns
139 > * the model connection (through the `vscode-proxy` provider) and exposes
140 > * no app-server RPC for App-initiated `sampling/createMessage`, so the
141 > * host cannot serve that capability for codex.
142 > */
143 > const CODEX_MCP_APP_CAPABILITIES: AhpMcpUiHostCapabilities = {
144 > serverTools: { listChanged: true },
145 > serverResources: {},
146 > };
147 >
148 > /**
149 > * Codex surfaces an MCP tool-call approval as a `request_user_input`
150 > * question whose id is `mcp_tool_call_approval_<callId>` (the `<callId>`
151 > * matches the `mcpToolCall` item id). The host intercepts these and renders
152 > * them on the normal tool-approval card instead of a chat-input question;
153 > * see {@link CodexAgent._handleMcpToolApprovalViaCard}.
154 > *
155 > * Codex decodes the answer string back into a decision: `Allow` accepts the
156 > * call, the synthetic `__codex_mcp_decline__` rejects it (anything else is
157 > * treated as a cancel). These mirror the constants in codex
158 > * `core/src/mcp_tool_call.rs`.
159 > */
160 > const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX = 'mcp_tool_call_approval_';
161 > const MCP_TOOL_APPROVAL_ANSWER_ALLOW = 'Allow';
162 > const MCP_TOOL_APPROVAL_ANSWER_DECLINE = '__codex_mcp_decline__';
163 >
164 > /**
165 > * `supported_endpoints` value (on a Copilot CAPI {@link CCAModel}) that marks
166 > * a model as reachable through CAPI's OpenAI-shaped Responses endpoint. Codex
167 > * only drives models via this endpoint (the `vscode-proxy` provider uses
168 > * `wire_api="responses"`), so the model picker is filtered to models that
169 > * advertise it. Confirmed against the live CAPI catalog: gpt-5.x / gpt-5*-codex
170 > * / mai-code carry `/responses`; Anthropic models carry `/v1/messages` and
171 > * chat-only models carry `/chat/completions` (neither is usable by codex).
172 > */
173 > const CODEX_RESPONSES_ENDPOINT = '/responses';
174 >
175 > /**
176 > * Codex's Agent Mode schema, derived from the platform-generic Mode schema but
177 > * with "Autopilot" removed. Codex has only two native collaboration modes —
178 > * `plan` and `default` (see {@link ModeKind}) — so "Autopilot" would map to
179 > * `default`, identical to "Interactive", and offering it in the picker would be
180 > * a no-op duplicate. Labels and descriptions are sliced by index so they stay
181 > * in sync with the platform schema.
182 > */
183 > function createCodexModeSchema(): ISchemaProperty<SessionMode> {
184 > const base = platformSessionSchema.definition[SessionConfigKey.Mode].protocol;
185 > const kept = (base.enum ?? []).flatMap((value, index) => value === 'autopilot' ? [] : [index]);
186 > return schemaProperty<SessionMode>({
187 > ...base,
188 > enum: kept.map(index => base.enum![index]),
189 > enumLabels: base.enumLabels && kept.map(index => base.enumLabels![index]),
190 > enumDescriptions: base.enumDescriptions && kept.map(index => base.enumDescriptions![index]),
191 > });
192 > }
193 >
194 > const codexSessionConfigSchema = createSchema({
195 > [CodexSessionConfigKey.PermissionsPreset]: schemaProperty<CodexPermissionsPreset>({
196 > type: 'string',
197 > title: localize('codex.sessionConfig.permissionsPreset', "Approvals"),
198 > description: localize('codex.sessionConfig.permissionsPresetDescription', "How much Codex can do on its own before asking for approval."),
199 > enum: [...CODEX_PERMISSIONS_PRESETS],
200 > enumLabels: [
201 > localize('codex.sessionConfig.permissionsPreset.default', "Default Permissions"),
202 > localize('codex.sessionConfig.permissionsPreset.autoReview', "Auto-Review"),
203 > localize('codex.sessionConfig.permissionsPreset.fullAccess', "Full Access"),
204 > ],
205 > enumDescriptions: [
206 > localize('codex.sessionConfig.permissionsPreset.defaultDescription', "Codex can read and edit files in the workspace and run routine local commands. It asks before using the internet or going beyond the workspace."),
207 > localize('codex.sessionConfig.permissionsPreset.autoReviewDescription', "Same workspace access as Default, but approval requests are routed through the auto-reviewer instead of prompting you."),
208 > localize('codex.sessionConfig.permissionsPreset.fullAccessDescription', "Codex can edit files outside the workspace and use the internet without asking. Use only when you want full machine access."),
209 > ],
210 > default: CODEX_DEFAULT_PERMISSIONS_PRESET,
211 > sessionMutable: true,
212 > }),
213 > [CodexSessionConfigKey.ApprovalPolicy]: schemaProperty<CodexApprovalPolicy>({
214 > type: 'string',
215 > title: localize('codex.sessionConfig.approvalPolicy', "Approvals"),
216 > description: localize('codex.sessionConfig.approvalPolicyDescription', "How Codex requests approval for tool calls."),
217 > enum: ['never', 'on-request', 'on-failure', 'untrusted'],
218 > enumLabels: [
219 > localize('codex.sessionConfig.approvalPolicy.never', "No Escalations"),
220 > localize('codex.sessionConfig.approvalPolicy.onRequest', "Ask When Needed"),
221 > localize('codex.sessionConfig.approvalPolicy.onFailure', "Ask on Failure"),
222 > localize('codex.sessionConfig.approvalPolicy.untrusted', "Ask More Often"),
223 > ],
224 > enumDescriptions: [
225 > localize('codex.sessionConfig.approvalPolicy.neverDescription', "Never ask for elevated permission; commands that cannot run in the sandbox are rejected."),
226 > localize('codex.sessionConfig.approvalPolicy.onRequestDescription', "Ask only when Codex determines a command needs elevated permission."),
227 > localize('codex.sessionConfig.approvalPolicy.onFailureDescription', "Try commands in the sandbox first, then ask to retry with elevated permission if the sandbox blocks them."),
228 > localize('codex.sessionConfig.approvalPolicy.untrustedDescription', "Ask before more command categories so you can review actions more closely."),
229 > ],
230 > default: 'on-request',
231 > sessionMutable: true,
232 > }),
233 > [CodexSessionConfigKey.SandboxMode]: schemaProperty<SandboxMode>({
234 > type: 'string',
235 > title: localize('codex.sessionConfig.sandboxMode', "Sandbox"),
236 > description: localize('codex.sessionConfig.sandboxModeDescription', "Filesystem and network restrictions applied to tool calls."),
237 > enum: ['read-only', 'workspace-write', 'danger-full-access'],
238 > enumLabels: [
239 > localize('codex.sessionConfig.sandboxMode.readOnly', "Read-Only"),
240 > localize('codex.sessionConfig.sandboxMode.workspaceWrite', "Workspace Write"),
241 > localize('codex.sessionConfig.sandboxMode.dangerFullAccess', "Full Access (Dangerous)"),
242 > ],
243 > enumDescriptions: [
244 > localize('codex.sessionConfig.sandboxMode.readOnlyDescription', "Tool calls can read the workspace but cannot modify files."),
245 > localize('codex.sessionConfig.sandboxMode.workspaceWriteDescription', "Tool calls can read and write within the workspace; network is controlled separately."),
246 > localize('codex.sessionConfig.sandboxMode.dangerFullAccessDescription', "Tool calls have unrestricted disk and network access."),
247 > ],
248 > default: 'workspace-write',
249 > sessionMutable: true,
250 > }),
251 > [CodexSessionConfigKey.WebSearchMode]: schemaProperty<WebSearchMode>({
252 > type: 'string',
253 > title: localize('codex.sessionConfig.webSearchMode', "Web Search"),
254 > description: localize('codex.sessionConfig.webSearchModeDescription', "Web-search tool availability for the model."),
255 > enum: ['disabled', 'cached', 'live'],
256 > enumLabels: [
257 > localize('codex.sessionConfig.webSearchMode.disabled', "Disabled"),
258 > localize('codex.sessionConfig.webSearchMode.cached', "Cached Only"),
259 > localize('codex.sessionConfig.webSearchMode.live', "Live"),
260 > ],
261 > default: 'disabled',
262 > sessionMutable: false,
263 > }),
264 > [CodexSessionConfigKey.ModelReasoningEffort]: schemaProperty<ReasoningEffort>({
265 > type: 'string',
266 > title: localize('codex.sessionConfig.modelReasoningEffort', "Reasoning Effort"),
267 > description: localize('codex.sessionConfig.modelReasoningEffortDescription', "Controls how much reasoning effort Codex uses."),
268 > enum: [...CODEX_REASONING_EFFORTS],
269 > enumLabels: CODEX_REASONING_EFFORTS.map(getReasoningEffortLabel),
270 > enumDescriptions: CODEX_REASONING_EFFORTS.map(effort => getReasoningEffortDescription(effort) ?? ''),
271 > default: 'medium',
272 > sessionMutable: true,
273 > }),
274 > [SessionConfigKey.Mode]: createCodexModeSchema(),
275 > [CodexSessionConfigKey.Personality]: schemaProperty<Personality>({
276 > type: 'string',
277 > title: localize('codex.sessionConfig.personality', "Personality"),
278 > description: localize('codex.sessionConfig.personalityDescription', "Tone Codex uses when communicating."),
279 > enum: ['none', 'friendly', 'pragmatic'],
280 > enumLabels: [
281 > localize('codex.sessionConfig.personality.none', "Default"),
282 > localize('codex.sessionConfig.personality.friendly', "Friendly"),
283 > localize('codex.sessionConfig.personality.pragmatic', "Pragmatic"),
284 > ],
285 > enumDescriptions: [
286 > localize('codex.sessionConfig.personality.noneDescription', "Use Codex's built-in default tone."),
287 > localize('codex.sessionConfig.personality.friendlyDescription', "Warmer, more conversational tone."),
288 > localize('codex.sessionConfig.personality.pragmaticDescription', "Terse, no-nonsense tone focused on actions."),
289 > ],
290 > default: 'none',
291 > sessionMutable: true,
292 > }),
293 > [CodexSessionConfigKey.ReasoningSummary]: schemaProperty<ReasoningSummary>({
294 > type: 'string',
295 > title: localize('codex.sessionConfig.reasoningSummary', "Reasoning Summary"),
296 > description: localize('codex.sessionConfig.reasoningSummaryDescription', "How Codex summarizes its reasoning in the response stream."),
297 > enum: ['auto', 'concise', 'detailed', 'none'],
298 > enumLabels: [
299 > localize('codex.sessionConfig.reasoningSummary.auto', "Auto"),
300 > localize('codex.sessionConfig.reasoningSummary.concise', "Concise"),
301 > localize('codex.sessionConfig.reasoningSummary.detailed', "Detailed"),
302 > localize('codex.sessionConfig.reasoningSummary.none', "None"),
303 > ],
304 > default: 'auto',
305 > sessionMutable: true,
306 > }),
307 > [CodexSessionConfigKey.AdditionalDirectories]: schemaProperty<string[]>({
308 > type: 'array',
309 > title: localize('codex.sessionConfig.additionalDirectories', "Additional Writable Directories"),
310 > description: localize('codex.sessionConfig.additionalDirectoriesDescription', "Absolute paths the sandbox is allowed to write to, in addition to the workspace. Only applies when Sandbox is Workspace Write."),
311 > items: { type: 'string', title: localize('codex.sessionConfig.additionalDirectories.item', "Directory") },
312 > enumDynamic: true,
313 > default: [],
314 > sessionMutable: true,
315 > }),
316 > [CodexSessionConfigKey.NetworkAccessEnabled]: schemaProperty<boolean>({
317 > type: 'boolean',
318 > title: localize('codex.sessionConfig.networkAccessEnabled', "Network"),
319 > description: localize('codex.sessionConfig.networkAccessEnabledDescription', "Allow sandboxed tool calls to make outbound network requests. Only applies when Sandbox is Workspace Write."),
320 > default: false,
321 > sessionMutable: true,
322 > }),
323 > [SessionConfigKey.Permissions]: platformSessionSchema.definition[SessionConfigKey.Permissions],
324 > });
325 >
326 > const codexVisibleSessionConfigSchema = createSchema({
327 > [SessionConfigKey.Mode]: codexSessionConfigSchema.definition[SessionConfigKey.Mode],
328 > [CodexSessionConfigKey.PermissionsPreset]: codexSessionConfigSchema.definition[CodexSessionConfigKey.PermissionsPreset],
329 > [SessionConfigKey.Permissions]: platformSessionSchema.definition[SessionConfigKey.Permissions],
330 > });
331 >
332 > interface ICodexSessionConfigDefaults {
333 > readonly [CodexSessionConfigKey.PermissionsPreset]: CodexPermissionsPreset;
334 > readonly [CodexSessionConfigKey.ApprovalPolicy]: CodexApprovalPolicy;
335 > readonly [CodexSessionConfigKey.SandboxMode]: SandboxMode;
336 > readonly [CodexSessionConfigKey.WebSearchMode]: WebSearchMode;
337 > readonly [CodexSessionConfigKey.ModelReasoningEffort]: ReasoningEffort;
338 > readonly [CodexSessionConfigKey.AdditionalDirectories]: string[];
339 > readonly [CodexSessionConfigKey.NetworkAccessEnabled]: boolean;
340 > readonly [SessionConfigKey.Mode]: SessionMode;
341 > readonly [CodexSessionConfigKey.Personality]: Personality;
342 > readonly [CodexSessionConfigKey.ReasoningSummary]: ReasoningSummary;
343 > }
344 >
345 > const codexSessionConfigDefaults: ICodexSessionConfigDefaults = {
346 > [CodexSessionConfigKey.PermissionsPreset]: CODEX_DEFAULT_PERMISSIONS_PRESET,
347 > [CodexSessionConfigKey.ApprovalPolicy]: 'on-request',
348 > [CodexSessionConfigKey.SandboxMode]: 'workspace-write',
349 > [CodexSessionConfigKey.WebSearchMode]: 'disabled',
350 > [CodexSessionConfigKey.ModelReasoningEffort]: 'medium',
351 > [CodexSessionConfigKey.AdditionalDirectories]: [],
352 > [CodexSessionConfigKey.NetworkAccessEnabled]: false,
353 > [SessionConfigKey.Mode]: 'interactive',
354 > [CodexSessionConfigKey.Personality]: 'none',
355 > [CodexSessionConfigKey.ReasoningSummary]: 'auto',
356 > };
357 >
358 > const CodexPrewarmTtlMs = 60_000;
359 >
360 > /**
361 > * Per-session bookkeeping. The codex thread is owned by the shared
362 > * connection in {@link CodexAgent}; this struct only tracks what the
363 > * `IAgent` surface needs.
364 > */
365 > /** Resolved user-input answer captured from the client's `chat/inputCompleted`. */
366 > interface ICodexUserInputResult {
367 > readonly response: ChatInputResponseKind;
368 > readonly answers?: Record<string, ChatInputAnswer>;
369 > }
370 >
371 > interface ICodexSession {
372 > /** Caller-facing session id used in the `codex:/<id>` URI; may differ from the codex thread id. */
373 > readonly sessionId: string;
374 > /**
375 > * Codex app-server thread id used in JSON-RPC `thread/*` and `turn/*` calls.
376 > * Undefined until the session has been materialized (first `sendMessage`
377 > * triggers `thread/start`). Decoupling materialization from
378 > * `createSession` mirrors the Claude harness's provisional/materialize
379 > * split and avoids spawning an orphan codex thread when the workbench
380 > * rebinds a provisional URI after a chip-selection.
381 > */
382 > threadId: string | undefined;
383 > readonly sessionUri: URI;
384 > /**
385 > * Effective working directory. Starts as the folder the client passed to
386 > * {@link CodexAgent.createSession}; at first materialization it is replaced
387 > * with the host-resolved working directory (the isolated worktree for
388 > * worktree-isolation sessions) before `thread/start` locks the codex
389 > * subprocess `cwd`. When the client supplies none (e.g. an editor window
390 > * with no workspace folder open), a managed temp folder is lazily created
391 > * as a fallback at materialize time (tracked by
392 > * {@link managedWorkingDirectory} for cleanup). Mutable so both the
393 > * worktree swap and the lazy assignment can happen after the provisional
394 > * `createSession`.
395 > */
396 > workingDirectory: URI | undefined;
397 > /**
398 > * Set to the temp folder created for this session when no working
399 > * directory was supplied, so {@link CodexAgent.disposeSession} can remove
400 > * it. `undefined` when the client supplied a working directory.
401 > */
402 > managedWorkingDirectory: URI | undefined;
403 > readonly mapState: ICodexSessionMapState;
404 > /**
405 > * Phase 4: parked deferreds for `item/commandExecution/requestApproval`,
406 > * keyed by the host-side toolCallId. Resolved by
407 > * {@link CodexAgent.respondToPermissionRequest}.
408 > */
409 > readonly pendingCommandApprovals: PendingRequestRegistry<CommandExecutionApprovalDecision>;
410 > /**
411 > * Per-session set of "accept for session" decisions. When the user
412 > * picks Accept-for-Session in a previous approval, subsequent
413 > * approval requests on the same session resolve automatically.
414 > */
415 > readonly acceptedForSession: Set<string>;
416 > /**
417 > * Guardian (auto-review) `reviewId`s that have already been surfaced to
418 > * the user as a denied-action approval card. Guards against acting twice
419 > * on the same review if the completed notification is redelivered.
420 > */
421 > readonly handledGuardianReviews: Set<string>;
422 > /**
423 > * Host-side toolCallIds of the synthetic "Approve anyway" cards created for
424 > * guardian (auto-review) denials that are still awaiting a user decision.
425 > * Unlike codex's blocking command approvals, these cards live inside the
426 > * active turn but codex does *not* wait on them — so when the turn ends
427 > * (often via the auto-review circuit-breaker interrupt) the reducer cancels
428 > * the card. We use this set to unwind the parked deferred on turn end so the
429 > * suspended {@link CodexAgent._handleGuardianReviewCompleted} frame doesn't
430 > * leak.
431 > */
432 > readonly pendingGuardianReviewCards: Set<string>;
433 > /**
434 > * Steering messages handed to codex via `turn/steer` that are awaiting
435 > * the matching `userMessage` item echo, which promotes them into their
436 > * own visible turn. Keyed by {@link PendingMessage.id}. Drained (with a
437 > * `steering_consumed` signal) on turn completion, abort, dispose, or a
438 > * `turn/steer` rejection so the chat UI's pending bubble never sticks.
439 > */
440 > readonly pendingSteeringFlips: Map<string, PendingMessage>;
441 > /**
442 > * Client-provided tool definitions for this session, keyed by the
443 > * contributing workbench client. The merged set is registered with codex
444 > * as `dynamicTools` at `thread/start`. Empty until the first active client
445 > * sets its tools.
446 > */
447 > readonly clientToolSet: ActiveClientToolSet;
448 > /**
449 > * Parked deferreds for in-flight client-tool calls (codex
450 > * `item/tool/call`), keyed by the host-side toolCallId. Resolved by
451 > * {@link CodexAgent.onClientToolCallComplete}.
452 > */
453 > readonly pendingClientToolCalls: PendingRequestRegistry<ToolCallResult>;
454 > /**
455 > * Parked deferreds for in-flight user-input requests (codex
456 > * `item/tool/requestUserInput`, i.e. the model's `ask_user`), keyed by a
457 > * host-generated requestId. Resolved by
458 > * {@link CodexAgent.respondToUserInputRequest}.
459 > */
460 > readonly pendingUserInputs: PendingRequestRegistry<ICodexUserInputResult>;
461 > /**
462 > * Signature of the {@link clientTools} the codex thread was started
463 > * with. Codex only accepts `dynamicTools` at `thread/start`, so if the
464 > * tools change before the first turn (e.g. the prewarmed thread started
465 > * before {@link setClientTools} arrived) the thread is restarted to pick
466 > * them up. `undefined` until materialized.
467 > */
468 > materializedToolsSig: string | undefined;
469 > /**
470 > * Signature of the `mcp_servers` (root config + client plugins) the codex
471 > * thread was started with. Codex only accepts `config.mcp_servers` at
472 > * `thread/start`, so if the set changes before the first turn the thread is
473 > * restarted to pick them up. `undefined` until materialized.
474 > */
475 > materializedMcpSig: string | undefined;
476 > /** True once a turn has been started on the (materialized) thread. */
477 > firstTurnSent: boolean;
478 > model: ModelSelection | undefined;
479 > /** Workbench-facing turn id for the active turn. */
480 > currentTurnId: string | undefined;
481 > /** Local monotonic timer for the active workbench-facing turn. */
482 > turnStopWatch: StopWatch | undefined;
483 > /** Codex app-server turn id for the active turn. */
484 > currentAppTurnId: string | undefined;
485 > /** Codex app-server turn id -> workbench-facing turn id. */
486 > readonly hostTurnIdByAppTurnId: Map<string, string>;
487 > /**
488 > * Workbench-facing turn id -> codex app-server turn id, retained across
489 > * turn completion so {@link CodexAgent.truncateSession} can translate a
490 > * live host turn id to a `thread/rollback` target.
491 > */
492 > readonly codexTurnIdByHostTurnId: Map<string, string>;
493 > /** Set when this session was restored (Phase 3) and needs `thread/resume` before the first `turn/start`. */
494 > needsResume: boolean;
495 > /** Most recent user prompt sent on this session — used as fallback userMessage text in `turn/started`. */
496 > lastPromptText: string;
497 > /** True once the workbench has disposed this session. Guards background prewarm continuations. */
498 > disposed: boolean;
499 > /** In-flight background or foreground materialization, shared across callers. */
500 > materializePromise: Promise<void> | undefined;
501 > /** Whether the workbench-facing materialize event has been emitted. */
502 > materializedEventFired: boolean;
503 > /** TTL timer for a materialized-but-unused prewarmed thread. */
504 > prewarmTimer: ReturnType<typeof setTimeout> | undefined;
505 > /** True once the prewarmed session has been claimed by a user turn. */
506 > prewarmClaimed: boolean;
507 > /** True once the agent host's server tools have been advertised on this session. */
508 > serverToolsAdvertised: boolean;
509 > /**
510 > * Per-session MCP customization surface. Created lazily the first time
511 > * the session needs to surface codex's MCP servers (either via
512 > * {@link CodexAgent.getSessionCustomizations} or when the connection's
513 > * MCP inventory is applied). Disposed when the session is removed.
514 > */
515 > mcpController: McpCustomizationController | undefined;
516 > /**
517 > * Store of client-pushed ("Open Plugin") customizations synced to this
518 > * session. Their MCP servers are attached per-thread at `thread/start`
519 > * and their skills feed codex's process-global `skills/extraRoots/set`.
520 > */
521 > readonly clientCustomizations: CodexClientCustomizationStore;
522 > }
523 >
524 > /**
525 > * A live Codex collab-agent (subagent) child thread. Codex runs each spawned
526 > * subagent as its OWN app-server thread that emits a full item/turn event
527 > * stream (`turn/started`, `item/*`, `turn/completed`) under the child thread
528 > * id — it is NOT flattened onto the parent thread. We render that stream in a
529 > * read-only peer chat (the "agent team" pattern, mirroring Copilot/Claude) by
530 > * routing the child thread's notifications through the shared mappers with an
531 > * isolated {@link ICodexSession} and firing each resulting action tagged with
532 > * the parent `spawnAgent` tool call as its `parentToolCallId`, so the shared
533 > * orchestrator ({@link AgentSideEffects}) lands them in the subagent chat.
534 > */
535 > interface ICodexSubagent {
536 > /** Caller-facing sessionId of the parent session that spawned this subagent. */
537 > readonly parentSessionId: string;
538 > /** Host-side toolCallId of the parent `spawnAgent` collab tool call (routing key). */
539 > readonly toolCallId: string;
540 > /**
541 > * Isolated session used to run the shared event mappers for the child
542 > * thread. Shares the parent's `sessionUri` and `acceptedForSession` memo so
543 > * side effects target the parent's working tree and the accept-for-session
544 > * decision spans parent + subagents, but keeps its own map/turn state.
545 > */
546 > readonly session: ICodexSession;
547 > }
548 >
549 > /**
550 > * Connection state machine. The codex process is spawned lazily on first
551 > * need (Decision 6) and stays alive for the agent's lifetime.
552 > */
553 > type ConnectionState =
554 > | { readonly kind: 'idle' }
555 > | { readonly kind: 'starting'; readonly promise: Promise<IConnectionReady> }
556 > | ({ readonly kind: 'ready' } & IConnectionReady);
557 >
558 > interface IConnectionReady {
559 > readonly client: ICodexAppServerClient;
560 > readonly usageSource: CodexUsageSource;
561 > readonly proxyHandle?: ICodexProxyHandle;
562 > readonly child: ChildProcessWithoutNullStreams;
563 > }
564 >
565 > /**
566 > * `IAgent` implementation backed by `codex app-server`.
567 > *
568 > * Phase 2 surface: createSession (blocks on `thread/start`), sendMessage
569 > * (one `turn/start`, streams `agentMessage` deltas), setPendingMessages
570 > * (steering via `turn/steer`), abortSession (`turn/interrupt`),
571 > * disposeSession (`thread/unsubscribe`, no process kill).
572 > *
573 > * Decisions 3 (shared process), 6 (lazy spawn), 7 (session id == threadId),
574 > * 10 (no cwd → reject), 15 (cancel, keep streamed content), 16 (steering),
575 > * 17 (attachments), 18 (apikey auth).
576 > */
577 >
578 > /**
579 > * `@openai/codex` distribution descriptor. Lives in this file because it
580 > * encodes Codex-specific knowledge — the env-var name and the fact that
581 > * Codex's Linux binaries are statically musl-linked and ship as a single
582 > * `linux-*` SKU regardless of host libc.
583 > */
584 > export const CodexSdkPackage: IAgentSdkPackage = {
585 > id: 'codex',
586 > displayName: 'Codex',
587 > devOverrideEnvVar: AgentHostCodexAgentSdkRootEnvVar,
588 > hasSeparateMuslLinuxPackage: false,
589 > };
590 >
591 > /**
592 > * Convert a workbench {@link ToolCallResult} into the codex
593 > * {@link DynamicToolCallResponse} returned for an `item/tool/call` request.
594 > * Text content maps to `inputText`; when there is no text content the
595 > * tool's past-tense summary is used so codex never receives an empty body.
596 > */
597 function dynamicToolResponseFromResult(result: ToolCallResult): DynamicToolCallResponse {
598 const contentItems: DynamicToolCallOutputContentItem[] = [];
599 for (const c of result.content ?? []) {
600 if (c.type === ToolResultContentType.Text) {
601 contentItems.push({ type: 'inputText', text: c.text });
602 }
603 }
604 if (contentItems.length === 0) {
605 // Codex rejects an empty tool body, so always send a non-empty
606 // `inputText`: prefer the tool's past-tense summary, otherwise a
607 // generic completion marker keyed off success.
608 const summary = typeof result.pastTenseMessage === 'string' && result.pastTenseMessage.length > 0
609 ? result.pastTenseMessage
610 : (result.success ? 'Tool completed with no output.' : 'Tool failed with no output.');
611 contentItems.push({ type: 'inputText', text: summary });
612 }
613 return { contentItems, success: result.success };
614 }
616 function toolsSignature(tools: readonly ToolDefinition[] | undefined): string {
617 if (!tools || tools.length === 0) {
618 return '';
619 }
620 return tools
621 .map(t => `${t.name}\u0000${t.description ?? ''}\u0000${JSON.stringify(t.inputSchema ?? null)}`)
622 .sort()
623 .join('\u0001');
624 }
626 > /**
627 > * Stable signature of the `mcp_servers` object a thread was started with, used
628 > * to detect when the merged (root config + client plugin) MCP set changed so
629 > * the thread can be restarted before its first turn to pick up the new servers.
630 > */
631 function mcpServersSignature(servers: Record<string, ICodexMcpServerConfigJson>): string {
632 const names = Object.keys(servers).sort();
633 return names.map(name => `${name}\u0000${JSON.stringify(servers[name])}`).join('\u0001');
634 }
636 > /**
637 > * Codex active-client handle. Writes flow into the owning session's
638 > * {@link ActiveClientToolSet} (tools) and its {@link CodexClientCustomizationStore}
639 > * (customizations); the session is resolved lazily so writes that arrive before
640 > * (or after) the session exists are gracefully dropped, matching the prior
641 > * `setClientTools` early-return behavior. Assigning `customizations` caches the
642 > * inputs (so the getter echoes them) and kicks off the agent's async sync.
643 > */
644 > class CodexActiveClientHandle implements IActiveClient {
645 > private _customizations: readonly ClientPluginCustomization[] = [];
646 >
647 > constructor(
648 private readonly _getSession: () => ICodexSession | undefined,
649 readonly clientId: string,
650 readonly displayName: string | undefined,
651 private readonly _onToolsSet: (tools: readonly ToolDefinition[]) => void,
652 private readonly _syncCustomizations: (customizations: readonly ClientPluginCustomization[]) => void,
653 ) { }
655 > get tools(): readonly ToolDefinition[] {
656 return this._getSession()?.clientToolSet.get(this.clientId) ?? [];
657 }
658 > set tools(tools: readonly ToolDefinition[]) { codexAgent.ts ×158
659 this._getSession()?.clientToolSet.set(this.clientId, tools);
660 this._onToolsSet(tools);
661 }
663 > get customizations(): readonly ClientPluginCustomization[] {
664 return this._customizations;
665 }
666 > set customizations(customizations: readonly ClientPluginCustomization[]) { codexAgent.ts ×158
667 this._customizations = customizations;
668 this._syncCustomizations(customizations);
669 }
671 >
672 > /**
673 > * Map a resolved approval decision to the {@link FileChangeApprovalDecision}
674 > * subset. The host's boolean response only yields `accept`/`decline`; the
675 > * command-only amendment variants are treated as a decline for file changes.
676 > */
677 function narrowFileChangeDecision(decision: CommandExecutionApprovalDecision): FileChangeApprovalDecision {
678 switch (decision) {
679 case 'accept':
680 case 'acceptForSession':
681 case 'decline':
682 case 'cancel':
683 return decision;
684 default:
685 return 'decline';
686 }
687 }
689 > export class CodexAgent extends Disposable implements IAgent {
690 >
691 > readonly id: AgentProvider = CODEX_AGENT_PROVIDER_ID;
692 >
693 > private readonly _onDidSessionProgress = this._register(new Emitter<AgentSignal>());
694 > readonly onDidSessionProgress = this._onDidSessionProgress.event;
695 >
696 > private readonly _onDidMaterializeSession = this._register(new Emitter<IAgentMaterializeSessionEvent>());
697 > readonly onDidMaterializeSession = this._onDidMaterializeSession.event;
698 >
699 > private readonly _onDidRequireAuth = this._register(new Emitter<Omit<AuthRequiredParams, 'channel'>>());
700 > readonly onDidRequireAuth = this._onDidRequireAuth.event;
701 >
702 > private readonly _onMcpNotification = this._register(new Emitter<IMcpNotification>());
703 > readonly onMcpNotification = this._onMcpNotification.event;
704 >
705 > private readonly _models = observableValue<readonly IAgentModelInfo[]>(this, []);
706 > readonly models: IObservable<readonly IAgentModelInfo[]> = this._models;
707 > private _openAIAccountState: ICodexAccountState = { usageSource: 'openai', status: 'signedOut' };
708 > private _providerConfigurationValues: Record<string, unknown> = {};
709 > private _providerConfigurationWrite = Promise.resolve();
710 > private _providerConfigurationReady = false;
711 > private _providerConfigurationRefresh: Promise<void> | undefined;
712 >
713 > /** Keyed by caller-facing sessionId (the URI host). */
714 > private readonly _sessions = new Map<string, ICodexSession>();
715 > /** Inverse map: codex threadId → caller-facing sessionId, for routing codex notifications back to sessions. */
716 > private readonly _sessionIdByThreadId = new Map<string, string>();
717 > /**
718 > * Live subagent (collab-agent) child threads, keyed by the child codex
719 > * thread id. Populated when a parent session's `spawnAgent` collab tool
720 > * call completes (carrying the child `receiverThreadIds`); the child's
721 > * subsequent `turn/*` and `item/*` notifications route here instead of
722 > * {@link _sessionIdByThreadId}. Removed on the child's `turn/completed`.
723 > */
724 > private readonly _subagentsByThreadId = new Map<string, ICodexSubagent>();
725 > /**
726 > * Connection-global MCP server inventory reported by the codex
727 > * app-server (`mcpServerStatus/list` + `mcpServer/startupStatus/updated`).
728 > * Codex owns MCP servers at the process level — shared across every
729 > * thread — so the inventory lives on the agent and is mirrored onto each
730 > * session's {@link ICodexSession.mcpController}. Keyed by server name.
731 > */
732 > private readonly _mcpInventory = new Map<string, ICodexMcpServerEntry>();
733 > /**
734 > * OAuth bearer tokens acquired for auth-gated http MCP servers, keyed by
735 > * the server's {@link normalizeCodexMcpResourceUrl | normalized URL}.
736 > * Populated by {@link handleAuthenticationToken} after the workbench
737 > * completes the sign-in, then injected into the per-thread `http_headers`
738 > * by {@link _buildSessionMcpServers}. Process-global: a token for a given
739 > * server URL applies to every session/thread that uses it (codex runs one
740 > * shared app-server).
741 > */
742 > private readonly _mcpAuthTokens = new Map<string, string>();
743 > /**
744 > * Association from a normalized OAuth `resource` (what the workbench
745 > * authenticates) to the normalized MCP server URL(s) it unlocks. RFC 9728
746 > * discovery can return a `resource` that differs from the configured server
747 > * URL (e.g. root `https://host/` for a `https://host/mcp` endpoint), so the
748 > * token the workbench pushes back is keyed by the resource, not the server
749 > * URL. Recorded in {@link _surfaceMcpAuthRequired} at discovery time and
750 > * read by {@link handleAuthenticationToken} to route the token to the right
751 > * server(s).
752 > */
753 > private readonly _mcpAuthServerUrlsByResource = new Map<string, Set<string>>();
754 > private _githubToken: string | undefined;
755 > private _usageSource: CodexUsageSource;
756 > private _pendingUsageSource: CodexUsageSource | undefined;
757 > private _connection: ConnectionState = { kind: 'idle' };
758 > private _connectionGeneration = 0;
759 > private _modelsRefreshPromise: Promise<void> | undefined;
760 > private _usageSourceValidation = Promise.resolve();
761 > private readonly _metadataStore: CodexSessionMetadataStore;
762 >
763 > /**
764 > * The agent host's server-tool host (feedback "comments" today, more in the
765 > * future). Server tools execute in-process against the session's own state
766 > * — unlike client tools, which round-trip to the workbench. `undefined`
767 > * until {@link setServerToolHost} is called during registration; remains
768 > * `undefined` in test / standalone construction.
769 > */
770 > private _serverToolHost: IAgentServerToolHost | undefined;
771 >
772 > constructor(
773 > @ILogService private readonly _logService: ILogService, codexAgent.ts ×24
774 > @ICopilotApiService private readonly _copilotApiService: ICopilotApiService,
775 > @ICodexProxyService private readonly _codexProxyService: ICodexProxyService,
776 > @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
777 > @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService,
778 > @IAgentSdkDownloader private readonly _agentSdkDownloader: IAgentSdkDownloader,
779 > @IProductService private readonly _productService: IProductService,
780 > @IAgentPluginManager private readonly _pluginManager: IAgentPluginManager,
781 > @IFileService private readonly _fileService: IFileService,
782 > @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
783 > @IInstantiationService private readonly _instantiationService: IInstantiationService,
784 > ) {
785 > super();
786 > this._metadataStore = this._instantiationService.createInstance(CodexSessionMetadataStore);
787 > this._usageSource = this._resolveUsageSource();
788 > this._register(this._configurationService.onDidRootConfigChange(() => {
789 const next = this._resolveUsageSource();
790 if (next !== this._usageSource) {
791 this._requestUsageSourceChange(next);
792 } else {
793 this._pendingUsageSource = undefined;
794 }
795 this._queueProviderConfigurationWrite();
796 > })); codexAgent.ts ×24
797 > void this._refreshProviderConfiguration();
798 > if (this._usageSource === 'openai') {
799 this._usageSourceValidation = this._validateOpenAIUsageSource();
800 }
803 > private async _validateOpenAIUsageSource(): Promise<void> {
804 let account: ICodexAccountState;
805 try {
806 const connection = await this._ensureConnection(true);
807 account = await this._refreshAccount(connection.client, false);
808 } catch (error) {
809 if (this._usageSource !== 'openai') {
810 return;
811 }
812 const message = error instanceof Error ? error.message : String(error);
813 this._setOpenAIAccountState({ usageSource: 'openai', status: 'error', error: message }, false);
814 return;
815 }
816 if (this._usageSource !== 'openai') {
817 return;
818 }
819 const source = resolveCodexUsageSourceAfterAccountRead(this._usageSource, account);
820 if (source === 'copilot') {
821 this._logService.info('[Codex] OpenAI is signed out; falling back to GitHub Copilot');
822 this._configurationService.updateRootConfig({ [AgentHostConfigKey.CodexUsageSource]: source });
823 return;
824 }
825 if (account.status === 'signedIn') {
826 this._queueModelRefresh();
827 }
828 }
830 > private _setOpenAIAccountState(state: ICodexAccountState, _publish = true): void {
831 this._openAIAccountState = state;
832 }
834 > private _resolveUsageSource(): CodexUsageSource {
835 > return this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.CodexUsageSource) ?? 'copilot'; codexAgent.ts ×24
836 > }
838 > private _requestUsageSourceChange(source: CodexUsageSource): void {
839 if (this._hasActiveTurns()) {
840 this._pendingUsageSource = source;
841 this._logService.info(`[Codex] Deferring usage source change to '${source}' until active turns finish`);
842 return;
843 }
844 if (source === 'openai') {
845 this._applyUsageSourceChange(source, false, false);
846 this._usageSourceValidation = this._validateOpenAIUsageSource();
847 return;
848 }
849 this._applyUsageSourceChange(source);
850 }
852 > private _applyUsageSourceChange(source: CodexUsageSource, _publishAccount = true, refreshModels = true): void {
853 const previousSource = this._usageSource;
854 this._pendingUsageSource = undefined;
855 this._usageSource = source;
856 this._disposeConnection();
857 for (const session of this._sessions.values()) {
858 this._resetSessionForUsageSourceChange(session, source, previousSource);
859 }
860 this._subagentsByThreadId.clear();
861 this._models.set([], undefined);
862 if (!refreshModels) {
863 return;
864 }
865 if (source === 'openai') {
866 this._queueModelRefresh();
867 } else if (this._githubToken) {
868 this._queueModelRefresh();
869 } else {
870 this._onDidRequireAuth.fire({ resource: this._gitHubEndpointService.getCopilotResource().resource, reason: AuthRequiredReason.Required });
871 }
872 }
874 > private _resetSessionForUsageSourceChange(session: ICodexSession, source: CodexUsageSource, previousSource?: CodexUsageSource): void {
875 if (session.threadId === undefined) {
876 return;
877 }
878 this._logService.info(`[Codex:${session.sessionId}] replacing ${previousSource ?? 'incompatible-provider'} thread ${session.threadId} with a fresh ${source} thread`);
879 this._sessionIdByThreadId.delete(session.threadId);
880 session.threadId = undefined;
881 session.materializePromise = undefined;
882 session.materializedToolsSig = undefined;
883 session.materializedMcpSig = undefined;
884 session.needsResume = false;
885 session.hostTurnIdByAppTurnId.clear();
886 session.codexTurnIdByHostTurnId.clear();
887 }
889 > private _hasActiveTurns(): boolean {
890 return [...this._sessions.values()].some(session => session.currentTurnId !== undefined)
891 || [...this._subagentsByThreadId.values()].some(subagent => subagent.session.currentTurnId !== undefined);
892 }
894 > private _applyPendingUsageSourceIfIdle(): void {
895 const pendingUsageSource = this._pendingUsageSource;
896 if (pendingUsageSource && !this._hasActiveTurns()) {
897 this._applyUsageSourceChange(pendingUsageSource);
898 }
899 }
901 > // #region Auth
902 >
903 > getProtectedResources(): ProtectedResourceMetadata[] {
904 > return codexProtectedResourcesForUsageSource( codexAgent.ts ×24
905 > this._usageSource,
906 > this._gitHubEndpointService.getCopilotResource(),
907 > this._gitHubEndpointService.getRepoResource(),
908 > );
909 > }
911 > async authenticate(resource: string, token: string): Promise<boolean> {
912 > if (resource === this._gitHubEndpointService.getRepoResource().resource) { codexAgent.ts ×13
913 return true;
914 }
915 > if (resource !== this._gitHubEndpointService.getCopilotResource().resource) { codexAgent.ts ×13
916 return false;
917 }
918 > const changed = this._githubToken !== token; codexAgent.ts ×13
919 > this._githubToken = token;
920 > if (this._usageSource === 'openai') {
921 void this._refreshProviderConfiguration();
922 return true;
923 }
924 > if (changed && this._connection.kind === 'ready' && this._connection.proxyHandle) { codexAgent.ts ×13
925 // Codex stays running — proxy reads the new token from its
926 // own cell on the next request (Decision 4).
927 this._connection.proxyHandle.setToken(token);
928 this._queueModelRefresh();
929 > } else if (changed) { codexAgent.ts ×13
930 > // Defer model refresh until the connection comes up.
931 > this._queueModelRefresh();
932 > }
933 > this._logService.info('[Codex] Auth token updated');
934 > void this._refreshProviderConfiguration();
935 > return true;
936 > }
938 > /**
939 > * Receives a bearer token the workbench acquired for a protected resource
940 > * (the `authenticate` command is fanned out to every agent). If the
941 > * resource maps to one or more configured auth-gated http MCP servers
942 > * (via the association recorded at discovery time, or a direct URL match),
943 > * store the token per server URL (so {@link _buildSessionMcpServers} injects
944 > * it) and reconnect the affected threads so codex picks it up. This is the
945 > * codex end of the *same* OAuth mechanism the Copilot agent uses: the
946 > * workbench does the sign-in, the agent injects the resulting bearer.
947 > * Returns whether the token was consumed by an MCP server (the GitHub agent
948 > * token flows through {@link authenticate} instead).
949 > */
950 > async handleAuthenticationToken(params: AuthenticateParams): Promise<boolean> {
951 const normalizedResource = normalizeCodexMcpResourceUrl(params.resource);
952 if (normalizedResource === undefined) {
953 return false;
954 }
955 // The workbench authenticates the OAuth `resource`, which RFC 9728
956 // discovery may report as different from the configured server URL.
957 // Resolve the server URL(s) this resource unlocks: the association
958 // recorded at discovery time, plus a direct match when the resource IS
959 // a configured server URL (discovery returned the URL unchanged, or was
960 // skipped).
961 const serverUrls = new Set(this._mcpAuthServerUrlsByResource.get(normalizedResource) ?? []);
962 if (this._isConfiguredHttpServerUrl(normalizedResource)) {
963 serverUrls.add(normalizedResource);
964 }
965 if (serverUrls.size === 0) {
966 return false;
967 }
968 let changed = false;
969 for (const serverUrl of serverUrls) {
970 if (this._mcpAuthTokens.get(serverUrl) !== params.token) {
971 this._mcpAuthTokens.set(serverUrl, params.token);
972 changed = true;
973 }
974 }
975 if (!changed) {
976 return true;
977 }
978 this._logService.info(`[Codex] stored MCP auth token for ${params.resource}; reconnecting affected sessions`);
979 await this._reconnectSessionsForMcpAuth(serverUrls);
980 return true;
981 }
983 > /** Whether `normalizedUrl` is a currently-configured http MCP server (root config or any session's client plugins). */
984 > private _isConfiguredHttpServerUrl(normalizedUrl: string): boolean {
985 if (Object.values(codexMcpServersFromConfig(this._configurationService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey)))
986 .some(server => server.url !== undefined && normalizeCodexMcpResourceUrl(server.url) === normalizedUrl)) {
987 return true;
988 }
989 return [...this._sessions.values()].some(session =>
990 [...this._httpMcpServerUrls(session).values()].includes(normalizedUrl),
991 );
992 }
994 > /**
995 > * Reconnects every materialized session whose merged MCP servers include one
996 > * of `normalizedUrls` so codex re-reads `config.mcp_servers` with the
997 > * injected `Authorization` header. A thread that has not yet committed a
998 > * turn is restarted (`thread/start`, lossless); one with history is resumed
999 > * (`thread/resume` carries the same `config` field, loading history from the
1000 > * rollout) on its next turn via {@link ICodexSession.needsResume}.
1001 > */
1002 > private async _reconnectSessionsForMcpAuth(normalizedUrls: ReadonlySet<string>): Promise<void> {
1003 for (const session of this._sessions.values()) {
1004 if (session.disposed || session.threadId === undefined) {
1005 continue;
1006 }
1007 if (![...this._httpMcpServerUrls(session).values()].some(url => normalizedUrls.has(url))) {
1008 continue;
1009 }
1010 if (!session.firstTurnSent) {
1011 try {
1012 await this._restartThreadWithCurrentTools(session);
1013 } catch (err) {
1014 this._logService.warn(`[Codex:${session.sessionId}] reconnect after MCP auth failed: ${err instanceof Error ? err.message : String(err)}`);
1015 }
1016 } else {
1017 // A thread with history is resumed (with the current config) on
1018 // its next turn rather than restarted, so nothing is lost.
1019 session.needsResume = true;
1020 }
1021 }
1022 }
1024 > /**
1025 > * {@link IAgent.refreshModels}. Coalesces onto an in-flight refresh — from
1026 > * an account/usage-source change or an earlier tick — rather than issuing a
1027 > * second enumeration, and never rejects: {@link _refreshModels} logs and
1028 > * applies its own stale-write guards on failure.
1029 > */
1030 > refreshModels(): Promise<void> {
1031 > return this._modelsRefreshPromise ?? this._queueModelRefresh(); codexAgent.ts ×13
1032 > }
1034 > private _queueModelRefresh(): Promise<void> {
1035 > const refreshPromise = this._refreshModels().finally(() => { codexAgent.ts ×13
1036 > if (this._modelsRefreshPromise === refreshPromise) {
1037 > this._modelsRefreshPromise = undefined;
1038 > }
1039 > });
1040 > this._modelsRefreshPromise = refreshPromise;
1041 > return refreshPromise;
1042 > }
1044 > private _ensureAuthenticated(): string | undefined {
1045 > if (this._usageSource === 'openai') { codexAgent.ts ×24
1046 return undefined;
1047 }
1048 > const token = this._githubToken; codexAgent.ts ×24
1049 > if (!token) {
1050 > throw new ProtocolError(
1051 > AHP_AUTH_REQUIRED,
1052 > 'Authentication is required to use Codex',
1053 > this.getProtectedResources(),
1054 > );
1055 > }
1056 return token;
1059 > private _defaultModel(): ModelSelection | undefined {
1060 const models = this._models.get();
1061 const chosen = models[0];
1062 return chosen ? { id: chosen.id } : undefined;
1063 }
1065 > private _supportedModelOrUndefined(model: ModelSelection | undefined): ModelSelection | undefined {
1066 if (model && this._models.get().some(m => m.id === model.id)) {
1067 return model;
1068 }
1069 if (model) {
1070 this._logService.warn(`[Codex] Ignoring unknown model '${model.id}'`);
1071 }
1072 return this._defaultModel();
1073 }
1075 > private async _resolveModel(session: ICodexSession): Promise<ModelSelection> {
1076 // Ensure the catalog is populated before validating the selection so a
1077 // model picked before models finished loading isn't dropped.
1078 if (this._models.get().length === 0 && this._modelsRefreshPromise) {
1079 await this._modelsRefreshPromise;
1080 }
1081 const selected = this._supportedModelOrUndefined(session.model);
1082 if (selected) {
1083 session.model = selected;
1084 return selected;
1085 }
1086 throw new Error('Codex has no available models.');
1087 }
1089 > private _createReasoningEffortConfigSchema(): ConfigSchema {
1090 > return { codexAgent.ts ×13
1091 > type: 'object',
1092 > properties: {
1093 > [CODEX_THINKING_LEVEL_KEY]: {
1094 > type: 'string',
1095 > title: localize('codex.modelThinkingLevel.title', "Thinking Level"),
1096 > description: localize('codex.modelThinkingLevel.description', "Controls how much reasoning effort Codex uses."),
1097 > default: 'medium',
1098 > enum: [...CODEX_REASONING_EFFORTS],
1099 > enumLabels: CODEX_REASONING_EFFORTS.map(getReasoningEffortLabel),
1100 > enumDescriptions: CODEX_REASONING_EFFORTS.map(effort => getReasoningEffortDescription(effort) ?? ''),
1101 > },
1102 > },
1103 > };
1104 > }
1106 > private _getReasoningEffort(session: ICodexSession): ReasoningEffort | undefined {
1107 const modelConfigEffort = narrowReasoningEffort(session.model?.config?.[CODEX_THINKING_LEVEL_KEY]);
1108 if (modelConfigEffort) {
1109 return modelConfigEffort;
1110 }
1111 const config = this._configurationService.getSessionConfigValues(session.sessionUri.toString());
1112 return narrowReasoningEffort(config?.[CodexSessionConfigKey.ModelReasoningEffort]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.ModelReasoningEffort];
1113 }
1115 > private _readSessionConfig(session: ICodexSession): ReturnType<typeof codexSessionConfigSchema.validateOrDefault> {
1116 return codexSessionConfigSchema.validateOrDefault(
1117 this._configurationService.getSessionConfigValues(session.sessionUri.toString()),
1118 codexSessionConfigDefaults,
1119 );
1120 }
1122 > /**
1123 > * Resolve the Codex security axes (approval policy, sandbox, reviewer) for a
1124 > * live or restored session from its RAW persisted config values.
1125 > *
1126 > * The raw values are normalized through {@link migrateCodexPermissionValues}
1127 > * (the same migration the restore path applies) before resolving, so the
1128 > * axes we send to the app-server always match the preset the "Approvals" chip
1129 > * displays. This matters for two legacy shapes:
1130 > * - a session that persisted only `sandboxMode = 'read-only'` is preserved
1131 > * verbatim, so it is NOT silently escalated back to `workspace-write` on
1132 > * resume (the chip over-promises, but the session stays more locked down);
1133 > * - a session that persisted `approvalPolicy = 'never'` + `workspace-write`
1134 > * (which the chip renders as "Default Permissions") is snapped onto the
1135 > * `default` preset's `on-request` policy so it actually prompts, instead of
1136 > * running commands unprompted while the chip claims it would ask.
1137 > */
1138 > private _resolveSessionPermissions(session: ICodexSession): ICodexResolvedPermissions {
1139 const rawValues = this._configurationService.getSessionConfigValues(session.sessionUri.toString());
1140 const defaults = {
1141 approvalPolicy: codexSessionConfigDefaults[CodexSessionConfigKey.ApprovalPolicy],
1142 sandboxMode: codexSessionConfigDefaults[CodexSessionConfigKey.SandboxMode],
1143 };
1144 return resolveCodexPermissions(migrateCodexPermissionValues(rawValues, defaults), defaults);
1145 }
1147 > private _sandboxPolicy(session: ICodexSession, config: ReturnType<typeof codexSessionConfigSchema.validateOrDefault>, mode: SandboxMode): SandboxPolicy {
1148 if (mode === 'danger-full-access') {
1149 return { type: 'dangerFullAccess' };
1150 }
1151 const networkAccess = narrowBoolean(config[CodexSessionConfigKey.NetworkAccessEnabled]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.NetworkAccessEnabled];
1152 if (mode === 'read-only') {
1153 return { type: 'readOnly', networkAccess: false };
1154 }
1155 const writableRoots = [
1156 ...(session.workingDirectory ? [session.workingDirectory.fsPath] : []),
1157 ...(narrowAdditionalDirectories(config[CodexSessionConfigKey.AdditionalDirectories]) ?? []),
1158 ];
1159 return {
1160 type: 'workspaceWrite',
1161 writableRoots,
1162 networkAccess,
1163 excludeTmpdirEnvVar: false,
1164 excludeSlashTmp: false,
1165 };
1166 }
1168 > private _turnStartOptions(session: ICodexSession, modelId: string): Pick<TurnStartParams, 'approvalPolicy' | 'sandboxPolicy' | 'approvalsReviewer' | 'effort' | 'runtimeWorkspaceRoots' | 'personality' | 'summary' | 'collaborationMode'> {
1169 const config = this._readSessionConfig(session);
1170 const { approvalPolicy, sandboxMode, approvalsReviewer } = this._resolveSessionPermissions(session);
1171 const sandboxPolicy = this._sandboxPolicy(session, config, sandboxMode);
1172 const runtimeWorkspaceRoots = sandboxPolicy.type === 'workspaceWrite' ? sandboxPolicy.writableRoots : undefined;
1173 const effort = this._getReasoningEffort(session);
1174 const personality = narrowPersonality(config[CodexSessionConfigKey.Personality]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.Personality];
1175 const summary = narrowReasoningSummary(config[CodexSessionConfigKey.ReasoningSummary]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.ReasoningSummary];
1176 // Map the platform-generic Agent Mode to codex's native collaboration
1177 // mode. Always send it (even for `default`) so switching Plan → Interactive
1178 // resets the sticky thread mode. `collaborationMode.settings` carries the
1179 // model + effort because codex treats it as authoritative over the
1180 // top-level fields when a collaboration mode is set.
1181 const mode = collaborationModeKind(config[SessionConfigKey.Mode]);
1182 const collaborationMode: TurnStartParams['collaborationMode'] = {
1183 mode,
1184 settings: { model: modelId, reasoning_effort: effort ?? null, developer_instructions: null },
1185 };
1186 return {
1187 approvalPolicy,
1188 sandboxPolicy,
1189 approvalsReviewer,
1190 effort,
1191 personality,
1192 summary,
1193 collaborationMode,
1194 ...(runtimeWorkspaceRoots ? { runtimeWorkspaceRoots } : {}),
1195 };
1196 }
1198 > private async _refreshModels(): Promise<void> {
1199 > const usageSource = this._usageSource; codexAgent.ts ×13
1200 > if (usageSource === 'openai') {
1201 await this._refreshOpenAIModels();
1202 return;
1203 }
1204 > const token = this._githubToken; codexAgent.ts ×13
1205 > if (!token) {
1206 this._models.set([], undefined);
1207 return;
1208 }
1209 > try { codexAgent.ts ×13
1210 > const userAgent = `${USER_AGENT_PREFIX}/${this._productService.version}`;
1211 > const all = await this._copilotApiService.models(token, { headers: { 'User-Agent': userAgent }, suppressIntegrationId: true });
1212 > if (this._usageSource !== usageSource || this._githubToken !== token) {
1213 return;
1214 }
1215 > const configSchema = this._createReasoningEffortConfigSchema(); codexAgent.ts ×13
1216 > // Codex talks to every model through the `vscode-proxy` custom model
1217 > // provider with `wire_api="responses"` (see CodexProxyService), so it
1218 > // can only drive models that expose Copilot CAPI's OpenAI-shaped
1219 > // Responses endpoint. Filter the catalog to those advertising
1220 > // `/responses` in `supported_endpoints` (this drops Anthropic
1221 > // `/v1/messages` and chat-completions-only models, which codex cannot
1222 > // use). The chosen id is forwarded straight through; CAPI remains the
1223 > // authority on what the token may actually use.
1224 > const models = all
1225 > .filter(m => m.supported_endpoints?.includes(CODEX_RESPONSES_ENDPOINT))
1226 > .sort((a, b) => Number(b.is_chat_default) - Number(a.is_chat_default))
1227 > .map((m): IAgentModelInfo => ({
1228 > provider: this.id,
1229 > id: m.id,
1230 > name: m.name ?? m.id,
1231 > maxContextWindow: m.capabilities?.limits?.max_context_window_tokens,
1232 > maxOutputTokens: m.capabilities?.limits?.max_output_tokens,
1233 > maxPromptTokens: m.capabilities?.limits?.max_prompt_tokens,
1234 > supportsVision: !!m.capabilities?.supports?.vision,
1235 > configSchema,
1236 > policyState: m.policy?.state as PolicyState | undefined,
1237 > _meta: createPricingMetaFromBilling(
1238 > normalizeCAPIBilling(m.billing),
1239 > typeof m.model_picker_price_category === 'string'
1240 ? m.model_picker_price_category
1241 > : undefined, codexAgent.ts ×13
1242 > ),
1243 > }));
1244 > this._models.set(models, undefined);
1245 > } catch (err) {
1246 > this._logService.warn(`[Codex] Failed to refresh models: ${err instanceof Error ? err.message : String(err)}`);
1247 > // Keep the last known-good catalog. Usage-source changes clear the
1248 > // list in `_applyUsageSourceChange`; a transient periodic failure
1249 > // must not make every model disappear.
1250 > }
1251 > }
1253 > private async _refreshOpenAIModels(): Promise<void> {
1254 try {
1255 const connection = await this._ensureConnection();
1256 if (connection.usageSource !== 'openai') {
1257 return;
1258 }
1259 const data = [] as ModelListResponse['data'];
1260 let cursor: string | null = null;
1261 do {
1262 const response: ModelListResponse = await connection.client.request<'model/list', ModelListResponse>('model/list', { cursor, limit: 100, includeHidden: false });
1263 data.push(...response.data);
1264 cursor = response.nextCursor;
1265 } while (cursor !== null);
1266 const configSchema = this._createReasoningEffortConfigSchema();
1267 const models = data
1268 .sort((left, right) => Number(right.isDefault) - Number(left.isDefault))
1269 .map((model): IAgentModelInfo => ({
1270 provider: this.id,
1271 id: model.model,
1272 name: model.displayName,
1273 supportsVision: model.inputModalities.includes('image'),
1274 configSchema,
1275 }));
1276 if (this._usageSource === 'openai') {
1277 this._models.set(models, undefined);
1278 }
1279 } catch (err) {
1280 this._logService.warn(`[Codex] Failed to refresh OpenAI models: ${err instanceof Error ? err.message : String(err)}`);
1281 // Keep the last known-good catalog. Usage-source changes clear the
1282 // list in `_applyUsageSourceChange`; a transient periodic failure
1283 // must not make every model disappear.
1284 }
1285 }
1287 > // #endregion
1288 >
1289 > // #region Connection lifecycle
1290 >
1291 > /**
1292 > * Lazily spawn the codex app-server, initialize the connection,
1293 > * authenticate via apiKey, and return the ready connection. Idempotent
1294 > * — concurrent callers share the same promise.
1295 > */
1296 > private async _ensureConnection(skipUsageSourceValidation = false): Promise<IConnectionReady> {
1297 > if (this._connection.kind === 'ready') { codexAgent.ts ×24
1298 return Promise.resolve(this._connection);
1299 }
1300 > if (this._connection.kind === 'starting') { codexAgent.ts ×24
1301 return this._connection.promise;
1302 }
1303 > if (!skipUsageSourceValidation && this._usageSource === 'openai') { codexAgent.ts ×24
1304 let validation = this._usageSourceValidation;
1305 await validation;
1306 while (validation !== this._usageSourceValidation) {
1307 validation = this._usageSourceValidation;
1308 await validation;
1309 }
1310 }
1311 > const token = this._ensureAuthenticated(); codexAgent.ts ×24
1312 > const usageSource = this._usageSource;
1313 > const generation = this._connectionGeneration;
1314 > const startPromise = this._startConnection(usageSource, token);
1315 > const promise = startPromise.then(ready => {
1316 if (generation !== this._connectionGeneration || usageSource !== this._usageSource) {
1317 ready.client.dispose();
1318 ready.proxyHandle?.dispose();
1319 try { ready.child.kill('SIGKILL'); } catch { /* already dead */ }
1320 throw new Error('Codex usage source changed while app-server was starting');
1321 }
1322 this._connection = { kind: 'ready', ...ready };
1323 return ready;
1324 > }).catch(err => { codexAgent.ts ×24
1325 if (generation === this._connectionGeneration) {
1326 this._connection = { kind: 'idle' };
1327 }
1328 throw err;
1329 > }); codexAgent.ts ×24
1330 > this._connection = { kind: 'starting', promise };
1331 > return promise;
1332 > }
1334 > /**
1335 > * Resolve the Codex SDK root — the directory whose
1336 > * `node_modules/@openai/codex-<target>/…` holds the native binary.
1337 > *
1338 > * Mirrors the three-tier resolution in `ClaudeAgentSdkService._loadSdk`:
1339 > * 1. dev override / product download, via the downloader, when the SDK
1340 > * `isAvailable` (env override || `product.agentSdks.codex`);
1341 > * 2. dev fallback to this repo's `node_modules`, where `@openai/codex`
1342 > * and its per-host binary package are devDependencies — this is what
1343 > * lets running-from-source (and dev smoke tests) spawn Codex without
1344 > * an env-var override.
1345 > *
1346 > * `isAvailable` is already false in dev, so it discriminates the two
1347 > * without injecting `INativeEnvironmentService`. When neither path
1348 > * resolves we defer to the downloader so callers get its actionable
1349 > * "not configured" diagnostic.
1350 > */
1351 > private async _resolveSdkRoot(): Promise<string> {
1352 if (this._agentSdkDownloader.isAvailable(CodexSdkPackage)) {
1353 return this._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, CancellationToken.None);
1354 }
1355 const devRoot = await resolveCodexDevSdkRoot();
1356 if (devRoot) {
1357 this._logService.info(`[Codex] resolving SDK from repo node_modules (dev fallback): ${devRoot}`);
1358 return devRoot;
1359 }
1360 return this._agentSdkDownloader.loadSdkRoot(CodexSdkPackage, CancellationToken.None);
1361 }
1363 > private async _startConnection(usageSource: CodexUsageSource, token: string | undefined): Promise<IConnectionReady> {
1364 // Resolve the Codex SDK root: dev override / product download via the
1365 // downloader, or this repo's `node_modules` in a source checkout (see
1366 // `_resolveSdkRoot`). We spawn the native codex binary inside the
1367 // platform package directly (the same shape the JS shim at
1368 // `node_modules/@openai/codex/bin/codex.js` would resolve to) — going
1369 // through the shim adds a launcher hop and forces an
1370 // `ELECTRON_RUN_AS_NODE` round-trip when the agent host runs as an
1371 // Electron utility process.
1372 const root = await this._resolveSdkRoot();
1373 const codexTarget = codexPackageSuffix(process.platform, process.arch);
1374 if (!codexTarget) {
1375 throw new Error(`Codex: unsupported platform ${process.platform}-${process.arch}`);
1376 }
1377 const triple = codexBinaryTriple(codexTarget);
1378 if (!triple) {
1379 throw new Error(`Codex: no binary triple known for sdkTarget '${codexTarget}'`);
1380 }
1381 const binaryName = process.platform === 'win32' ? 'codex.exe' : 'codex';
1382 const binaryPath = join(root, 'node_modules', `@openai/codex-${codexTarget}`, 'vendor', triple, 'bin', binaryName);
1383 try {
1384 fs.accessSync(binaryPath, fs.constants.X_OK);
1385 } catch (err) {
1386 throw new Error(`Codex binary not executable: ${binaryPath} (${err instanceof Error ? err.message : String(err)})`);
1387 }
1388
1389 let proxyHandle: ICodexProxyHandle | undefined;
1390 if (usageSource === 'copilot') {
1391 if (!token) {
1392 throw new Error('Codex Copilot launch requires a GitHub token');
1393 }
1394 proxyHandle = await this._codexProxyService.start(token);
1395 }
1396
1397 const extraArgs = parseBinaryArgs(process.env[AgentHostCodexAgentBinaryArgsEnvVar]);
1398 const launchConfig = buildCodexLaunchConfig(usageSource, process.env, proxyHandle, extraArgs);
1399 const env = launchConfig.env;
1400 const userCodexHome = process.env[AgentHostCodexAgentCodexHomeEnvVar];
1401 if (userCodexHome) {
1402 env.CODEX_HOME = userCodexHome;
1403 }
1404
1405 const args = [...launchConfig.args];
1406
1407 this._logService.info(`[Codex] spawning usageSource=${usageSource} proxy=${proxyHandle ? 'enabled' : 'disabled'} ${binaryPath} ${args.join(' ')}`);
1408 const child = spawn(binaryPath, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
1409
1410 // Surface stderr to the log channel — codex writes useful startup
1411 // diagnostics there. Mirror Claude's pattern.
1412 child.stderr.setEncoding('utf8');
1413 child.stderr.on('data', chunk => this._logService.info(`[Codex stderr] ${String(chunk).trimEnd()}`));
1414
1415 const transport = transportFromChildProcess(child);
1416 const client = new CodexAppServerClient(transport, (level, msg) => {
1417 this._logService.info(`[CodexClient ${level}] ${msg}`);
1418 });
1419
1420 // Tear everything down if the child dies on its own.
1421 client.onExit(e => {
1422 this._logService.warn(`[Codex] app-server exited code=${e.code} signal=${e.signal}`);
1423 this._handleConnectionLost();
1424 });
1425 client.onTransportError(err => {
1426 this._logService.error(`[Codex] transport error: ${err.message}`);
1427 this._handleConnectionLost();
1428 });
1429
1430 // Initialize handshake. Failure here is fatal for the connection.
1431 try {
1432 await client.request<'initialize'>('initialize', {
1433 clientInfo: CLIENT_INFO,
1434 capabilities: { experimentalApi: true, requestAttestation: false, optOutNotificationMethods: null },
1435 });
1436 client.notify<'initialized'>('initialized', undefined as never);
1437 // With `requires_openai_auth = false` on the proxy provider,
1438 // codex does not require a separate login step — the proxy
1439 // nonce is read from OPENAI_API_KEY by the provider's env_key.
1440 if (userCodexHome && proxyHandle) {
1441 // User-provided CODEX_HOME may target a provider that
1442 // still requires auth; preserve the apiKey login path.
1443 await client.request<'account/login/start'>('account/login/start', {
1444 type: 'apiKey',
1445 apiKey: proxyHandle.nonce,
1446 });
1447 }
1448 if (usageSource === 'openai') {
1449 void this._refreshAccount(client);
1450 }
1451 } catch (err) {
1452 client.dispose();
1453 proxyHandle?.dispose();
1454 try { child.kill('SIGKILL'); } catch { /* already dead */ }
1455 throw err;
1456 }
1457
1458 // Wire global notification → SessionAction dispatch.
1459 this._registerIgnoredNotifications(client);
1460 this._register(client.onNotification('account/login/completed', () => { /* sign-in is managed outside VS Code */ }));
1461 this._register(client.onNotification('account/updated', () => {
1462 if (this._usageSource === 'openai' && this._connection.kind === 'ready' && this._connection.client === client) {
1463 void this._refreshAccount(client);
1464 this._queueModelRefresh();
1465 }
1466 }));
1467 this._register(client.onNotification('turn/started', params => this._dispatchByThread(params.threadId, s => this._handleTurnStartedNotification(s, params))));
1468 this._register(client.onNotification('item/started', params => this._dispatchByThread(params.threadId, s => this._handleItemStarted(s, params))));
1469 this._register(client.onNotification('item/agentMessage/delta', params => this._dispatchByThread(params.threadId, s => mapAgentMessageDelta(s.mapState, this._withHostTurnId(s, params)))));
1470 this._register(client.onNotification('item/commandExecution/outputDelta', params => this._dispatchByThread(params.threadId, s => mapCommandExecutionOutputDelta(s.mapState, this._withHostTurnId(s, params)))));
1471 this._register(client.onNotification('item/fileChange/patchUpdated', params => this._dispatchByThread(params.threadId, s => mapFileChangePatchUpdated(s.mapState, this._withHostTurnId(s, params)))));
1472 this._register(client.onNotification('item/fileChange/outputDelta', params => this._dispatchByThread(params.threadId, s => mapFileChangeOutputDelta(s.mapState, this._withHostTurnId(s, params)))));
1473 this._register(client.onNotification('item/mcpToolCall/progress', params => this._dispatchByThread(params.threadId, s => mapMcpToolCallProgress(s.mapState, this._withHostTurnId(s, params)))));
1474 this._register(client.onNotification('item/reasoning/summaryPartAdded', params => this._dispatchByThread(params.threadId, s => mapReasoningSummaryPartAdded(s.mapState, this._withHostTurnId(s, params)))));
1475 this._register(client.onNotification('item/reasoning/summaryTextDelta', params => this._dispatchByThread(params.threadId, s => mapReasoningSummaryTextDelta(s.mapState, this._withHostTurnId(s, params)))));
1476 this._register(client.onNotification('item/reasoning/textDelta', params => this._dispatchByThread(params.threadId, s => mapReasoningTextDelta(s.mapState, this._withHostTurnId(s, params)))));
1477 this._register(client.onNotification('thread/tokenUsage/updated', params => this._dispatchByThread(params.threadId, s => mapTokenUsageUpdated(this._withHostTurnId(s, params)))));
1478 this._register(client.onNotification('item/completed', params => this._dispatchItemCompleted(params)));
1479 this._register(client.onNotification('turn/completed', params => this._dispatchTurnCompleted(params)));
1480 // Auto-review (guardian) surfacing. The guardian warning is shown as a
1481 // system notification; a completed *denied* review is turned into a
1482 // retroactive "Approve anyway" tool-call card. The review lifecycle is
1483 // non-blocking (codex does not wait on us), so the completed handler is
1484 // async and resolves its session directly rather than via _dispatchByThread.
1485 this._register(client.onNotification('guardianWarning', params => this._dispatchByThread(params.threadId, s => this._handleGuardianWarning(s, params))));
1486 this._register(client.onNotification('item/autoApprovalReview/completed', params => { void this._handleGuardianReviewCompleted(client, params); }));
1487
1488 // MCP server lifecycle. Codex owns MCP servers at the process level
1489 // (shared across threads); surface them to AHP clients as per-session
1490 // customizations + an `mcp://` side channel. The startup notification
1491 // drives state transitions; `ready` triggers a full inventory refresh
1492 // so the freshly-loaded tools become available.
1493 this._register(client.onNotification('mcpServer/startupStatus/updated', params => this._handleMcpStartupStatus(client, params.name, params.status, params.error)));
1494
1495 // Phase 4: command-execution approval requests. Park on a
1496 // per-session deferred, emit `ChatToolCallReady` in the
1497 // PendingConfirmation state, and answer codex when the user
1498 // (or accept-for-session memoization) decides.
1499 this._register(client.onRequest<'item/commandExecution/requestApproval'>(
1500 'item/commandExecution/requestApproval',
1501 params => this._handleCommandApprovalRequestRpc(params),
1502 ));
1503
1504 // File-change and permission-escalation approval requests (raised in
1505 // non-`danger-full-access` sandboxes / on the on-request approval
1506 // policy). Surface them through the same pending-confirmation flow.
1507 this._register(client.onRequest<'item/fileChange/requestApproval'>(
1508 'item/fileChange/requestApproval',
1509 params => this._handleFileChangeApprovalRequestRpc(params),
1510 ));
1511 this._register(client.onRequest<'item/permissions/requestApproval'>(
1512 'item/permissions/requestApproval',
1513 params => this._handlePermissionsApprovalRequestRpc(params),
1514 ));
1515
1516 // Client-provided (dynamic) tool execution requests. Codex asks the
1517 // host to run a tool registered via `thread/start.dynamicTools`; we
1518 // route the call to the owning workbench client and answer with its
1519 // result.
1520 this._register(client.onRequest<'item/tool/call'>(
1521 'item/tool/call',
1522 params => this._handleDynamicToolCallRpc(params),
1523 ));
1524
1525 // User-input requests (the model's `ask_user`). Surface the questions
1526 // as a chat input request and answer codex with the user's response.
1527 this._register(client.onRequest<'item/tool/requestUserInput'>(
1528 'item/tool/requestUserInput',
1529 params => this._handleUserInputRequestRpc(params),
1530 ));
1531
1532 // MCP elicitation requests. An MCP server (relayed by codex) asks the
1533 // user for structured input mid-tool-call. Surface it through the same
1534 // chat-input flow as `ask_user` and answer codex with accept/decline/cancel.
1535 this._register(client.onRequest<'mcpServer/elicitation/request'>(
1536 'mcpServer/elicitation/request',
1537 params => this._handleElicitationRequestRpc(params),
1538 ));
1539
1540 // Seed the MCP server inventory from the freshly-connected app-server.
1541 // Best-effort and fire-and-forget: failures leave the inventory empty
1542 // until the next `mcpServer/startupStatus/updated` notification.
1543 void this._refreshMcpInventory(client);
1544
1545 return { client, usageSource, proxyHandle, child };
1546 }
1548 > /**
1549 > * Builds the `mcp_servers` object for a session's `thread/start.config`:
1550 > * the workbench's root `mcpServers` config merged with the session's
1551 > * enabled client-plugin MCP servers. Passing them per-thread (rather than
1552 > * as process-global `-c` spawn overrides) means each new session picks up
1553 > * the current root config without restarting the shared app-server, and it
1554 > * merges with (leaves intact) the user's global `~/.codex/config.toml`.
1555 > * Client-plugin servers win a name collision with the root config. Any
1556 > * OAuth bearer token acquired for an auth-gated http server (see
1557 > * {@link handleAuthenticationToken}) is injected as an `Authorization`
1558 > * header so codex connects authenticated.
1559 > */
1560 > private _buildSessionMcpServers(session: ICodexSession): Record<string, ICodexMcpServerConfigJson> {
1561 const root = codexMcpServersFromConfig(this._configurationService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey));
1562 const clientPlugins = codexMcpServersFromPlugins(session.clientCustomizations.enabledPlugins());
1563 return injectCodexMcpAuthTokens({ ...root, ...clientPlugins }, this._mcpAuthTokens);
1564 }
1566 > /**
1567 > * The normalized URLs of every configured http MCP server (root config +
1568 > * the session's client plugins), keyed by server name. Used to (a) surface
1569 > * an auth-required server's resource for the workbench sign-in and (b)
1570 > * match a workbench-acquired token back to the server(s) it unlocks.
1571 > * Computed from a token-free build so the URLs are the bare server URLs.
1572 > */
1573 > private _httpMcpServerUrls(session: ICodexSession): Map<string, string> {
1574 const root = codexMcpServersFromConfig(this._configurationService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey));
1575 const clientPlugins = codexMcpServersFromPlugins(session.clientCustomizations.enabledPlugins());
1576 const urls = new Map<string, string>();
1577 for (const [name, server] of Object.entries({ ...root, ...clientPlugins })) {
1578 const normalized = server.url !== undefined ? normalizeCodexMcpResourceUrl(server.url) : undefined;
1579 if (normalized !== undefined) {
1580 urls.set(name, normalized);
1581 }
1582 }
1583 return urls;
1584 }
1586 > /** The bare (un-normalized) URL of a configured http MCP server by name, across all sessions. */
1587 > private _mcpServerUrlForName(name: string): string | undefined {
1588 const root = codexMcpServersFromConfig(this._configurationService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey));
1589 if (root[name]?.url !== undefined) {
1590 return root[name].url;
1591 }
1592 for (const session of this._sessions.values()) {
1593 const fromPlugins = codexMcpServersFromPlugins(session.clientCustomizations.enabledPlugins());
1594 if (fromPlugins[name]?.url !== undefined) {
1595 return fromPlugins[name].url;
1596 }
1597 }
1598 return undefined;
1599 }
1601 > /**
1602 > * Map the session's tools into codex `dynamicTools` specs: the agent host's
1603 > * server tools (executed in-process) plus the workbench client's tools
1604 > * (round-tripped to the client). Both are registered with codex the same
1605 > * way — at `thread/start` — and dispatched apart in
1606 > * {@link _handleDynamicToolCallRpc} by name.
1607 > */
1608 > private _buildDynamicTools(session: ICodexSession): DynamicToolSpec[] | undefined {
1609 const serverTools = this._serverToolHost?.definitions ?? [];
1610 const clientTools = session.clientToolSet.merged();
1611 // Server tools first; a server tool name shadows a colliding client tool
1612 // (the agent host owns those names) and matches the routing order below.
1613 const seen = new Set<string>();
1614 const all: ToolDefinition[] = [];
1615 for (const t of [...serverTools, ...clientTools]) {
1616 if (seen.has(t.name)) {
1617 continue;
1618 }
1619 seen.add(t.name);
1620 all.push(t);
1621 }
1622 if (all.length === 0) {
1623 return undefined;
1624 }
1625 return all.map(t => ({
1626 type: 'function' as const,
1627 name: t.name,
1628 description: t.description ?? '',
1629 inputSchema: (t.inputSchema ?? { type: 'object' }) as JsonValue,
1630 }));
1631 }
1633 > private async _handleDynamicToolCallRpc(params: DynamicToolCallParams): Promise<ServerRequestHandlerResult<DynamicToolCallResponse>> {
1634 const sessionId = this._sessionIdByThreadId.get(params.threadId);
1635 const session = sessionId ? this._sessions.get(sessionId) : undefined;
1636 if (!session) {
1637 return { result: this._toolFailure(`Codex tool call for unknown thread ${params.threadId}`) };
1638 }
1639 // Server tools are executed in-process against the session's own state
1640 // (no workbench round-trip). We register them under their bare name, so
1641 // codex calls back with `namespace === null`. Dispatch them here before
1642 // the client-tool path below.
1643 const host = this._serverToolHost;
1644 if (host && params.namespace === null && host.toolNames.includes(params.tool)) {
1645 try {
1646 const text = host.executeTool(session.sessionUri.toString(), params.tool, params.arguments);
1647 return { result: { contentItems: [{ type: 'inputText', text: await text }], success: true } };
1648 } catch (err) {
1649 return { result: this._toolFailure(`Server tool ${params.tool} failed: ${err instanceof Error ? err.message : String(err)}`) };
1650 }
1651 }
1652 // `item/started` for the `dynamicToolCall` (id === callId) is delivered
1653 // before this request and seeds the host toolCallId + ChatToolCallReady
1654 // the owning client reacts to. Look it up so the client's completion
1655 // (keyed by that toolCallId) resolves this request.
1656 const toolCallId = session.mapState.itemToToolCall.get(params.callId)?.toolCallId;
1657 if (toolCallId === undefined) {
1658 return { result: this._toolFailure(`No pending client tool call for ${params.tool} (callId ${params.callId})`) };
1659 }
1660 if (session.clientToolSet.size === 0) {
1661 return { result: this._toolFailure(`No client available to run ${params.tool}`) };
1662 }
1663 try {
1664 // `register` consumes any result the client already delivered (the
1665 // display path emits ChatToolCallReady before this request, so the
1666 // completion can race ahead — PendingRequestRegistry buffers it).
1667 const result = await session.pendingClientToolCalls.register(toolCallId);
1668 return { result: dynamicToolResponseFromResult(result) };
1669 } catch (err) {
1670 if (err instanceof CancellationError) {
1671 return { result: this._toolFailure(`Client tool ${params.tool} was cancelled`) };
1672 }
1673 return { result: this._toolFailure(`Client tool ${params.tool} failed: ${err instanceof Error ? err.message : String(err)}`) };
1674 }
1675 }
1677 > private _toolFailure(message: string): DynamicToolCallResponse {
1678 this._logService.warn(`[Codex] dynamic tool call failed: ${message}`);
1679 return { contentItems: [{ type: 'inputText', text: message }], success: false };
1680 }
1682 > private async _handleUserInputRequestRpc(params: ToolRequestUserInputParams): Promise<ServerRequestHandlerResult<ToolRequestUserInputResponse>> {
1683 const sessionId = this._sessionIdByThreadId.get(params.threadId);
1684 const session = sessionId ? this._sessions.get(sessionId) : undefined;
1685 if (!session) {
1686 return { result: emptyUserInputResponse(params.questions) };
1687 }
1688 if (!session.currentTurnId) {
1689 this._logService.warn(`[Codex] user input request without an active turn for threadId=${params.threadId}; returning empty answers`);
1690 return { result: emptyUserInputResponse(params.questions) };
1691 }
1692 // MCP tool-call approvals arrive as a single `request_user_input`
1693 // question id'd `mcp_tool_call_approval_<callId>`. Render them on the
1694 // normal tool-approval card (mirroring shell/file approvals) instead of
1695 // a chat-input question, when the originating `mcpToolCall` item's host
1696 // tool call is known. Falls through to the chat-input path otherwise.
1697 const approvalQuestion = params.questions.length === 1 && params.questions[0].id.startsWith(MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX)
1698 ? params.questions[0]
1699 : undefined;
1700 if (approvalQuestion) {
1701 const callId = approvalQuestion.id.slice(MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX.length);
1702 const entry = session.mapState.itemToToolCall.get(callId);
1703 if (entry) {
1704 return this._handleMcpToolApprovalViaCard(session, approvalQuestion, entry);
1705 }
1706 }
1707 const requestId = generateUuid();
1708 const request = buildUserInputRequest(requestId, params.questions);
1709 try {
1710 const result = await session.pendingUserInputs.registerAndFire(requestId, () => {
1711 this._fire(session.sessionUri, { type: ActionType.ChatInputRequested, request });
1712 });
1713 return { result: userInputResponseFromAnswers(params.questions, result.response, result.answers) };
1714 } catch (err) {
1715 // Session disposed / connection lost while awaiting; answer codex
1716 // with empty answers so the turn unwinds instead of hanging.
1717 return { result: emptyUserInputResponse(params.questions) };
1718 }
1719 }
1721 > /**
1722 > * Renders an MCP tool-call approval on the normal tool-approval card
1723 > * (a pending-confirmation `ChatToolCallReady` on the originating
1724 > * `mcpToolCall` host tool call) rather than as a chat-input question.
1725 > * The user's Allow/Deny decision is mapped back to the answer string
1726 > * codex expects (`Allow` / `__codex_mcp_decline__`). Mirrors the shell
1727 > * command approval flow ({@link CodexAgent._handleCommandApprovalRequest}).
1728 > */
1729 > private async _handleMcpToolApprovalViaCard(
1730 session: ICodexSession,
1731 question: ToolRequestUserInputQuestion,
1732 entry: { readonly toolCallId: string; readonly turnId: string },
1733 ): Promise<{ readonly result: ToolRequestUserInputResponse }> {
1734 const confirmationTitle = question.question || question.header || 'Run MCP tool';
1735 let decision: CommandExecutionApprovalDecision;
1736 try {
1737 decision = await session.pendingCommandApprovals.registerAndFire(entry.toolCallId, () => {
1738 this._fire(session.sessionUri, {
1739 type: ActionType.ChatToolCallReady,
1740 turnId: entry.turnId,
1741 toolCallId: entry.toolCallId,
1742 invocationMessage: confirmationTitle,
1743 toolInput: confirmationTitle,
1744 confirmationTitle,
1745 });
1746 });
1747 } catch (err) {
1748 // Session disposed / connection lost while awaiting; decline so the
1749 // codex-side MCP tool call unwinds instead of hanging.
1750 decision = 'decline';
1751 }
1752 const allow = decision === 'accept' || decision === 'acceptForSession';
1753 const answer = allow ? MCP_TOOL_APPROVAL_ANSWER_ALLOW : MCP_TOOL_APPROVAL_ANSWER_DECLINE;
1754 return { result: { answers: { [question.id]: { answers: [answer] } } } };
1755 }
1757 > private async _handleElicitationRequestRpc(params: McpServerElicitationRequestParams): Promise<ServerRequestHandlerResult<McpServerElicitationRequestResponse>> {
1758 const sessionId = this._sessionIdByThreadId.get(params.threadId);
1759 const session = sessionId ? this._sessions.get(sessionId) : undefined;
1760 this._logService.info(`[Codex] elicitation request threadId=${params.threadId} mode=${params.mode} server=${params.serverName} session=${session ? session.sessionId : 'NONE'}`);
1761 if (!session) {
1762 this._logService.warn(`[Codex] elicitation request for unknown threadId=${params.threadId}; declining`);
1763 return { result: declinedElicitationResponse() };
1764 }
1765 if (!session.currentTurnId) {
1766 this._logService.warn(`[Codex] elicitation request without an active turn for threadId=${params.threadId}; declining`);
1767 return { result: declinedElicitationResponse() };
1768 }
1769 const requestId = generateUuid();
1770 const request = buildElicitationRequest(requestId, params);
1771 try {
1772 const result = await session.pendingUserInputs.registerAndFire(requestId, () => {
1773 this._fire(session.sessionUri, { type: ActionType.ChatInputRequested, request });
1774 });
1775 this._logService.info(`[Codex] elicitation resolved requestId=${requestId} response=${result.response}`);
1776 return { result: elicitationResponseFromAnswers(params, result.response, result.answers) };
1777 } catch (err) {
1778 // Session disposed / connection lost while awaiting; cancel the
1779 // elicitation so the MCP server's request unwinds.
1780 this._logService.info(`[Codex] elicitation cancelled requestId=${requestId}: ${err instanceof Error ? err.message : String(err)}`);
1781 return { result: cancelledElicitationResponse() };
1782 }
1783 }
1785 > private _hostTurnId(session: ICodexSession, appTurnId: string): string {
1786 return session.hostTurnIdByAppTurnId.get(appTurnId) ?? appTurnId;
1787 }
1789 > private _withHostTurnId<T extends { readonly turnId: string }>(session: ICodexSession, params: T): T {
1790 const turnId = this._hostTurnId(session, params.turnId);
1791 return turnId === params.turnId ? params : { ...params, turnId };
1792 }
1794 > private _withHostTurn<T extends { readonly turn: { readonly id: string } }>(session: ICodexSession, params: T): T {
1795 const appTurnId = params.turn.id;
1796 const hostTurnId = session.currentTurnId ?? this._hostTurnId(session, appTurnId);
1797 session.hostTurnIdByAppTurnId.set(appTurnId, hostTurnId);
1798 session.currentAppTurnId = appTurnId;
1799 return hostTurnId === appTurnId ? params : { ...params, turn: { ...params.turn, id: hostTurnId } };
1800 }
1802 > private _handleTurnStartedNotification(session: ICodexSession, params: TurnStartedNotification): (SessionAction | ChatAction)[] {
1803 // The workbench already dispatched the canonical turn start before sendMessage.
1804 // Codex's event only establishes app-server turn id correlation for later items.
1805 mapTurnStarted(session.mapState, this._withHostTurn(session, params), session.lastPromptText);
1806 return [];
1807 }
1809 > private _handleTurnCompletedNotification(session: ICodexSession, params: TurnCompletedNotification): (SessionAction | ChatAction)[] {
1810 const appTurnId = params.turn.id;
1811 const hostTurnId = this._hostTurnId(session, appTurnId);
1812 const out = mapTurnCompleted(session.mapState, this._withHostTurn(session, params), this._clearTurnStopWatch(session));
1813 // Remember which codex (app-server) turn each workbench turn maps to so
1814 // truncateSession can translate a host turn id to a thread rollback even
1815 // after the live correlation below is cleared.
1816 session.codexTurnIdByHostTurnId.set(hostTurnId, appTurnId);
1817 // Codex reports app-server turn ids, while the workbench owns host turn ids.
1818 // Clear the correlation after completion so later turns cannot reuse stale ids.
1819 if (session.currentAppTurnId === appTurnId || session.currentTurnId === hostTurnId) {
1820 session.currentTurnId = undefined;
1821 session.currentAppTurnId = undefined;
1822 }
1823 session.hostTurnIdByAppTurnId.delete(appTurnId);
1824 // Any steering still buffered was never echoed as a `userMessage`
1825 // item; clear the pending bubble now that the turn is over.
1826 this._drainPendingSteering(session);
1827 // Unwind any still-pending "Approve anyway" guardian cards. codex does not
1828 // block on them, so the reducer cancels the card when the turn ends; here
1829 // we resolve the parked deferred (`cancel`) so the suspended
1830 // {@link _handleGuardianReviewCompleted} frame unwinds instead of leaking
1831 // until session dispose. The durable denial notification already emitted
1832 // remains in the transcript.
1833 if (session.pendingGuardianReviewCards.size > 0) {
1834 for (const guardianToolCallId of [...session.pendingGuardianReviewCards]) {
1835 session.pendingCommandApprovals.respond(guardianToolCallId, 'cancel');
1836 }
1837 }
1838 return out;
1839 }
1841 > /**
1842 > * Dispatch a codex `item/started` notification. `userMessage` items are
1843 > * intercepted here (rather than in the pure mapper) because steering
1844 > * promotion needs the agent's per-session turn-correlation state; all
1845 > * other item kinds defer to {@link mapItemStarted}.
1846 > */
1847 > private _handleItemStarted(session: ICodexSession, params: ItemStartedNotification): (SessionAction | ChatAction)[] {
1848 if (params.item.type === 'userMessage') {
1849 return this._handleSteeredUserMessage(session, params.item.content);
1850 }
1851 return mapItemStarted(session.mapState, this._withHostTurnId(session, params));
1852 }
1854 > /**
1855 > * Codex echoes every user message — the turn opener (already shown by
1856 > * the workbench before `sendMessage`) and any steered input — as a
1857 > * `userMessage` item. Only steered input is buffered in
1858 > * {@link ICodexSession.pendingSteeringFlips}; a buffered match is
1859 > * promoted into its own visible turn and everything else is dropped.
1860 > */
1861 > private _handleSteeredUserMessage(session: ICodexSession, content: readonly UserInput[]): (SessionAction | ChatAction)[] {
1862 const text = extractUserInputText(content);
1863 const steering = this._takeMatchingPendingSteering(session, text);
1864 if (!steering) {
1865 return [];
1866 }
1867 return this._beginSteeringTurn(session, steering);
1868 }
1870 > /**
1871 > * Pop the buffered steering message whose text matches the echoed
1872 > * `userMessage` content. Matching by content (not FIFO) keeps the
1873 > * mapping correct when several steering messages with different texts
1874 > * are in flight.
1875 > */
1876 > private _takeMatchingPendingSteering(session: ICodexSession, text: string): PendingMessage | undefined {
1877 for (const [id, msg] of session.pendingSteeringFlips) {
1878 if (msg.message.text === text) {
1879 session.pendingSteeringFlips.delete(id);
1880 return msg;
1881 }
1882 }
1883 return undefined;
1884 }
1886 > /**
1887 > * Promote a steered message into its own protocol turn: complete the
1888 > * in-flight turn (so its response parts settle into history) and open a
1889 > * fresh turn whose user message is the steering content. The
1890 > * `queuedMessageId` clears the corresponding pending steering bubble.
1891 > * Subsequent codex items for the same app-server turn are re-mapped to
1892 > * the new host turn id so the steering response lands there.
1893 > */
1894 > private _beginSteeringTurn(session: ICodexSession, steering: PendingMessage): (SessionAction | ChatAction)[] {
1895 const actions: (SessionAction | ChatAction)[] = [];
1896 const appTurnId = session.currentAppTurnId;
1897 const previousHostTurnId = session.currentTurnId ?? (appTurnId ? this._hostTurnId(session, appTurnId) : undefined);
1898 if (previousHostTurnId) {
1899 actions.push({ type: ActionType.ChatTurnComplete, turnId: previousHostTurnId, duration: this._clearTurnStopWatch(session) });
1900 }
1901 const newHostTurnId = generateUuid();
1902 if (appTurnId) {
1903 session.hostTurnIdByAppTurnId.set(appTurnId, newHostTurnId);
1904 }
1905 session.currentTurnId = newHostTurnId;
1906 resetCodexTurnMapState(session.mapState);
1907 actions.push({
1908 type: ActionType.ChatTurnStarted,
1909 turnId: newHostTurnId,
1910 startedAt: new Date().toISOString(),
1911 message: steering.message,
1912 queuedMessageId: steering.id,
1913 });
1914 this._startTurnStopWatch(session);
1915 return actions;
1916 }
1918 > /**
1919 > * Clear any steering messages still buffered (never echoed by codex)
1920 > * and fire `steering_consumed` for each so the chat UI removes the
1921 > * lingering pending bubble. Called on turn completion, abort, dispose,
1922 > * and connection loss.
1923 > */
1924 > private _drainPendingSteering(session: ICodexSession): void {
1925 if (session.pendingSteeringFlips.size === 0) {
1926 return;
1927 }
1928 const ids = [...session.pendingSteeringFlips.keys()];
1929 session.pendingSteeringFlips.clear();
1930 for (const id of ids) {
1931 this._fireSteeringConsumed(session, id);
1932 }
1933 }
1935 > private _fireSteeringConsumed(session: ICodexSession, id: string): void {
1936 this._onDidSessionProgress.fire({ kind: 'steering_consumed', chat: URI.parse(buildDefaultChatUri(session.sessionUri)), id });
1937 }
1939 > private _registerIgnoredNotifications(client: ICodexAppServerClient): void {
1940 const ignored = [
1941 'thread/started', // thread/start response is authoritative for session materialization.
1942 'thread/status/changed', // Codex thread status is not surfaced in Agent Host state yet.
1943 'thread/settings/updated', // VS Code owns session config; Codex settings echoes are not consumed yet.
1944 'thread/goal/updated', // Goals are not surfaced in the Agent Host UI yet.
1945 'thread/goal/cleared', // Goals are not surfaced in the Agent Host UI yet.
1946 'account/rateLimits/updated', // Rate-limit UI/state is not implemented yet.
1947 'remoteControl/status/changed', // Remote-control state is not part of the VS Code integration.
1948 'serverRequest/resolved', // We resolve requests through JSON-RPC responses, so this echo is informational.
1949 'item/autoApprovalReview/started', // Informational; the completed notification drives the denied-action card.
1950 ] as const;
1951 for (const method of ignored) {
1952 this._register(client.onNotification(method, () => { /* intentionally ignored */ }));
1953 }
1954 }
1956 > private async _refreshAccount(client: ICodexAppServerClient, publish = true): Promise<ICodexAccountState> {
1957 try {
1958 const response = await client.request<'account/read', GetAccountResponse>('account/read', { refreshToken: false });
1959 const state = codexAccountStateFromResponse(response);
1960 this._setOpenAIAccountState(state, publish);
1961 this._logService.info(`[Codex] account/read accountType=${response.account?.type ?? 'none'} requiresOpenaiAuth=${response.requiresOpenaiAuth}${state.planType ? ` planType=${state.planType}` : ''}`);
1962 return state;
1963 } catch (err) {
1964 const message = err instanceof Error ? err.message : String(err);
1965 this._logService.warn(`[Codex] account/read failed: ${message}`);
1966 const state: ICodexAccountState = { usageSource: 'openai', status: 'error', error: message };
1967 this._setOpenAIAccountState(state, publish);
1968 return state;
1969 }
1970 }
1972 > private async _readProviderConfiguration(): Promise<Record<string, unknown>> {
1973 > const connection = await this._ensureConnection(); codexAgent.ts ×24
1974 const response = await connection.client.request<'config/read', ConfigReadResponse>('config/read', { includeLayers: true });
1975 > const userLayer = response.layers?.find(layer => layer.name.type === 'user' && layer.name.profile === null) ?? response.layers?.find(layer => layer.name.type === 'user'); codexAgent.ts ×24
1976 > const config = userLayer?.config && typeof userLayer.config === 'object' && !Array.isArray(userLayer.config) ? userLayer.config as Record<string, unknown> : {};
1977 > return {
1978 > 'codex.personality': this._readConfigurationValue(config, 'personality') ?? 'default',
1979 > 'codex.autoReviewPolicy': this._readConfigurationValue(config, 'auto_review.policy') ?? '',
1980 > };
1981 > }
1983 > private async _writeProviderConfiguration(key: string, value: unknown): Promise<void> {
1984 const connection = await this._ensureConnection();
1985 await connection.client.request<'config/batchWrite', ConfigWriteResponse>('config/batchWrite', {
1986 edits: key === 'codex.autoReviewPolicy' && value === ''
1987 ? [{ keyPath: 'auto_review', value: null, mergeStrategy: 'replace' }]
1988 : key === 'codex.personality' && value === 'default'
1989 ? [{ keyPath: 'personality', value: null, mergeStrategy: 'replace' }]
1990 : [{ keyPath: key === 'codex.personality' ? 'personality' : 'auto_review.policy', value: value as string, mergeStrategy: 'replace' }],
1991 expectedVersion: null,
1992 reloadUserConfig: true,
1993 });
1994 }
1996 > private _refreshProviderConfiguration(): Promise<void> {
1997 > return this._providerConfigurationRefresh ??= (async () => { codexAgent.ts ×24
1998 > try {
1999 > this._providerConfigurationValues = await this._readProviderConfiguration();
2000 this._providerConfigurationReady = true;
2001 this._configurationService.updateRootConfig(this._providerConfigurationValues);
2002 > } catch (error) { codexAgent.ts ×24
2003 > this._logService.warn(`[Codex] Failed to read config.toml: ${error instanceof Error ? error.message : String(error)}`);
2004 > } finally {
2005 > this._providerConfigurationRefresh = undefined;
2006 > }
2007 > })();
2008 > }
2010 > private _queueProviderConfigurationWrite(): void {
2011 if (!this._providerConfigurationReady) {
2012 return;
2013 }
2014 const values = this._configurationService.getRootConfigValues?.() ?? {};
2015 for (const key of ['codex.personality', 'codex.autoReviewPolicy']) {
2016 if (values[key] === this._providerConfigurationValues[key]) { continue; }
2017 const value = values[key];
2018 if (value === undefined) { continue; }
2019 this._providerConfigurationWrite = this._providerConfigurationWrite.then(async () => {
2020 if (this._providerConfigurationValues[key] === value) {
2021 return;
2022 }
2023 await this._writeProviderConfiguration(key, value);
2024 this._providerConfigurationValues[key] = value;
2025 }).catch(error => this._logService.error(`[Codex] Failed to update config.toml: ${error instanceof Error ? error.message : String(error)}`));
2026 }
2027 }
2029 > private _readConfigurationValue(config: Record<string, unknown>, keyPath: string): unknown {
2030 let value: unknown = config;
2031 for (const segment of keyPath.split('.')) {
2032 if (!value || Array.isArray(value) || typeof value !== 'object') {
2033 return undefined;
2034 }
2035 value = (value as Record<string, unknown>)[segment];
2036 }
2037 return value;
2038 }
2040 > private _dispatchByThread(threadId: string, mapFn: (s: ICodexSession) => ReturnType<typeof mapTurnStarted>): void {
2041 // Collab-agent (subagent) child threads emit their own full event
2042 // stream; route them to the isolated subagent session and fire each
2043 // action tagged with the parent `spawnAgent` tool call so the shared
2044 // orchestrator lands them in the read-only peer chat.
2045 const subagent = this._subagentsByThreadId.get(threadId);
2046 if (subagent) {
2047 const actions = mapFn(subagent.session);
2048 for (const action of actions) {
2049 this._fireSubagent(subagent, action);
2050 }
2051 return;
2052 }
2053 const sessionId = this._sessionIdByThreadId.get(threadId);
2054 const session = sessionId ? this._sessions.get(sessionId) : undefined;
2055 if (!session) {
2056 // Usually an unclaimed prewarm; ignore.
2057 this._logService.trace(`[Codex] Ignoring notification for untracked threadId=${threadId}; likely unclaimed prewarm`);
2058 return;
2059 }
2060 const actions = mapFn(session);
2061 for (const action of actions) {
2062 this._fire(session.sessionUri, action);
2063 }
2064 }
2066 > /**
2067 > * `item/completed` dispatch. In addition to the normal per-thread mapping,
2068 > * a parent session's completed `spawnAgent` collab tool call now carries
2069 > * the child `receiverThreadIds`, so we register each spawned subagent and
2070 > * emit a `subagent_started` signal (before mapping the completion, so the
2071 > * shared orchestrator has attached the subagent-chat block to the parent
2072 > * tool call by the time it completes).
2073 > */
2074 > private _dispatchItemCompleted(params: ItemCompletedNotification): void {
2075 const subagent = this._subagentsByThreadId.get(params.threadId);
2076 if (subagent) {
2077 const actions = mapItemCompleted(subagent.session.mapState, this._withHostTurnId(subagent.session, params));
2078 for (const action of actions) {
2079 this._fireSubagent(subagent, action);
2080 }
2081 return;
2082 }
2083 const sessionId = this._sessionIdByThreadId.get(params.threadId);
2084 const session = sessionId ? this._sessions.get(sessionId) : undefined;
2085 if (!session) {
2086 this._logService.trace(`[Codex] Ignoring item/completed for untracked threadId=${params.threadId}; likely unclaimed prewarm`);
2087 return;
2088 }
2089 // Detect subagent spawns BEFORE mapping the completion: the host
2090 // toolCallId lives in the parent's itemToToolCall map (which the mapper
2091 // may clear), and firing `subagent_started` first lets the orchestrator
2092 // attach the read-only-chat block to the still-open parent tool call.
2093 this._maybeRegisterSubagents(session, params);
2094 const actions = mapItemCompleted(session.mapState, this._withHostTurnId(session, params));
2095 for (const action of actions) {
2096 this._fire(session.sessionUri, action);
2097 }
2098 }
2100 > /**
2101 > * `turn/completed` dispatch. For a subagent child thread, route the turn's
2102 > * flush/orphan actions to the peer chat but suppress its `ChatTurnComplete`
2103 > * — the child chat's turn is closed cleanly (without the parent's
2104 > * checkpoint/changeset/title side effects) by the `subagent_completed`
2105 > * signal, which also tears down the child-thread tracking.
2106 > */
2107 > private _dispatchTurnCompleted(params: TurnCompletedNotification): void {
2108 const subagent = this._subagentsByThreadId.get(params.threadId);
2109 if (subagent) {
2110 const actions = this._handleTurnCompletedNotification(subagent.session, params);
2111 for (const action of actions) {
2112 if (action.type === ActionType.ChatTurnComplete) {
2113 continue;
2114 }
2115 this._fireSubagent(subagent, action);
2116 }
2117 this._subagentsByThreadId.delete(params.threadId);
2118 subagent.session.pendingCommandApprovals.denyAll('decline');
2119 this._onDidSessionProgress.fire({
2120 kind: 'subagent_completed',
2121 chat: URI.parse(buildDefaultChatUri(subagent.session.sessionUri)),
2122 toolCallId: subagent.toolCallId,
2123 });
2124 this._applyPendingUsageSourceIfIdle();
2125 return;
2126 }
2127 this._dispatchByThread(params.threadId, s => this._handleTurnCompletedNotification(s, params));
2128 this._applyPendingUsageSourceIfIdle();
2129 }
2131 > /**
2132 > * When a parent session's `spawnAgent` collab tool call completes it
2133 > * carries the child thread id(s) in `receiverThreadIds`. Register an
2134 > * isolated subagent session for each new child thread and emit a
2135 > * `subagent_started` signal so the shared orchestrator opens the read-only
2136 > * peer chat and attaches its discovery block to the parent tool call.
2137 > */
2138 > private _maybeRegisterSubagents(session: ICodexSession, params: ItemCompletedNotification): void {
2139 const item = params.item;
2140 if (item.type !== 'collabAgentToolCall' || item.tool !== 'spawnAgent') {
2141 return;
2142 }
2143 const entry = session.mapState.itemToToolCall.get(item.id);
2144 if (!entry) {
2145 return;
2146 }
2147 const parentChat = URI.parse(buildDefaultChatUri(session.sessionUri));
2148 const model = item.model || undefined;
2149 const taskDescription = item.prompt || undefined;
2150 for (const childThreadId of item.receiverThreadIds) {
2151 if (this._subagentsByThreadId.has(childThreadId)) {
2152 continue;
2153 }
2154 const subSession = this._createSubagentSession(session, childThreadId);
2155 this._subagentsByThreadId.set(childThreadId, {
2156 parentSessionId: session.sessionId,
2157 toolCallId: entry.toolCallId,
2158 session: subSession,
2159 });
2160 this._onDidSessionProgress.fire({
2161 kind: 'subagent_started',
2162 chat: parentChat,
2163 toolCallId: entry.toolCallId,
2164 agentName: model ?? 'codex',
2165 agentDisplayName: model ?? 'Subagent',
2166 taskDescription,
2167 // Codex surfaces the full delegated instruction as `item.prompt`.
2168 taskPrompt: typeof item.prompt === 'string' && item.prompt.length > 0 ? item.prompt : undefined,
2169 });
2170 this._logService.trace(`[Codex:${session.sessionId}] subagent spawned thread=${childThreadId} toolCall=${entry.toolCallId} model=${model ?? '(default)'}`);
2171 }
2172 }
2174 > /**
2175 > * Build an isolated {@link ICodexSession} used to run the shared event
2176 > * mappers for a subagent child thread. It shares the parent's `sessionUri`
2177 > * (so side effects target the parent's working tree and the fired actions
2178 > * resolve to the parent chat channel) and `acceptedForSession` memo (so the
2179 > * accept-for-session decision spans parent + subagents), but has its own
2180 > * fresh map/turn state and approval registry so the child's events don't
2181 > * collide with the parent's.
2182 > */
2183 > private _createSubagentSession(parent: ICodexSession, childThreadId: string): ICodexSession {
2184 const clientToolSet = new ActiveClientToolSet();
2185 return {
2186 sessionId: parent.sessionId,
2187 threadId: childThreadId,
2188 sessionUri: parent.sessionUri,
2189 workingDirectory: parent.workingDirectory,
2190 managedWorkingDirectory: undefined,
2191 mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? []), clientToolSet),
2192 pendingCommandApprovals: new PendingRequestRegistry<CommandExecutionApprovalDecision>(),
2193 acceptedForSession: parent.acceptedForSession,
2194 handledGuardianReviews: new Set<string>(),
2195 pendingGuardianReviewCards: new Set<string>(),
2196 pendingSteeringFlips: new Map<string, PendingMessage>(),
2197 clientToolSet,
2198 pendingClientToolCalls: new PendingRequestRegistry<ToolCallResult>(),
2199 pendingUserInputs: new PendingRequestRegistry<ICodexUserInputResult>(),
2200 materializedToolsSig: undefined,
2201 materializedMcpSig: undefined,
2202 firstTurnSent: true,
2203 model: parent.model,
2204 currentTurnId: undefined,
2205 turnStopWatch: undefined,
2206 currentAppTurnId: undefined,
2207 hostTurnIdByAppTurnId: new Map<string, string>(),
2208 codexTurnIdByHostTurnId: new Map<string, string>(),
2209 needsResume: false,
2210 lastPromptText: '',
2211 disposed: false,
2212 materializePromise: undefined,
2213 materializedEventFired: true,
2214 prewarmTimer: undefined,
2215 prewarmClaimed: true,
2216 serverToolsAdvertised: true,
2217 mcpController: undefined,
2218 clientCustomizations: new CodexClientCustomizationStore(),
2219 };
2220 }
2222 > /**
2223 > * Fire a subagent action tagged with the parent `spawnAgent` tool call.
2224 > * The `resource` is the PARENT chat channel (the key the subagent chat is
2225 > * registered under in the orchestrator); `parentToolCallId` routes the
2226 > * action into the child's read-only peer chat.
2227 > */
2228 > private _fireSubagent(subagent: ICodexSubagent, action: SessionAction | ChatAction): void {
2229 this._onDidSessionProgress.fire({
2230 kind: 'action',
2231 resource: URI.parse(buildDefaultChatUri(subagent.session.sessionUri)),
2232 action,
2233 parentToolCallId: subagent.toolCallId,
2234 });
2235 }
2237 > /**
2238 > * Phase 4: handle `item/commandExecution/requestApproval` from
2239 > * codex. Look up the host-side tool call for the item, emit a
2240 > * `ChatToolCallReady` in PendingConfirmation, park on a deferred
2241 > * keyed by toolCallId, and resolve when the user (or the
2242 > * accept-for-session memo) decides. Unknown sessions / items
2243 > * decline silently so codex stops blocking.
2244 > */
2245 > private async _handleCommandApprovalRequestRpc(params: CommandExecutionRequestApprovalParams): Promise<{ readonly result: CommandExecutionRequestApprovalResponse }> {
2246 // The request handler must return Codex's JSON-RPC result wrapper; keep
2247 // the approval method below focused on the host-side permission decision.
2248 const decision = await this._handleCommandApprovalRequest(params);
2249 return { result: { decision } };
2250 }
2252 > private async _handleCommandApprovalRequest(params: {
2253 readonly threadId: string;
2254 readonly turnId: string;
2255 readonly itemId: string;
2256 readonly command?: string | null;
2257 readonly reason?: string | null;
2258 }): Promise<CommandExecutionApprovalDecision> {
2259 const target = this._resolveApprovalTarget(params.threadId);
2260 if (!target) {
2261 this._logService.warn(`[Codex] commandExecution/requestApproval for unknown threadId=${params.threadId}; declining`);
2262 return 'decline';
2263 }
2264 const session = target.session;
2265 const entry = session.mapState.itemToToolCall.get(params.itemId);
2266 if (!entry) {
2267 this._logService.warn(`[Codex:${session.sessionId}] commandExecution/requestApproval for unknown itemId=${params.itemId}; declining`);
2268 return 'decline';
2269 }
2270 const command = params.command ?? '';
2271 // Peel the OS shell wrapper (`/bin/zsh -lc '…'`) off for display so the
2272 // approval card matches the terminal pill, but keep the raw command as
2273 // the accept-for-session memo key so it stays byte-identical to what
2274 // Codex re-sends on the next request for the same command.
2275 const displayCommand = unwrapShellInvocation(command);
2276 // Accept-for-session memo: if the user previously accepted this
2277 // exact command for the session, auto-accept without prompting.
2278 if (command && session.acceptedForSession.has(command)) {
2279 return 'acceptForSession';
2280 }
2281 const confirmationTitle = params.reason ?? 'Run shell command';
2282 // Atomically register the deferred and fire the
2283 // PendingConfirmation signal so a synchronous responder can't
2284 // miss the registration.
2285 const decision = await session.pendingCommandApprovals.registerAndFire(entry.toolCallId, () => {
2286 this._fireApproval(target, {
2287 type: ActionType.ChatToolCallReady,
2288 turnId: entry.turnId,
2289 toolCallId: entry.toolCallId,
2290 invocationMessage: displayCommand,
2291 toolInput: displayCommand,
2292 confirmationTitle,
2293 });
2294 });
2295 // Track accept-for-session decisions for the next request.
2296 if (decision === 'acceptForSession' && command) {
2297 session.acceptedForSession.add(command);
2298 }
2299 return decision;
2300 }
2302 > private async _handleFileChangeApprovalRequestRpc(params: FileChangeRequestApprovalParams): Promise<{ readonly result: FileChangeRequestApprovalResponse }> {
2303 const decision = await this._requestItemApproval(params.threadId, params.itemId, params.reason ?? 'Apply file changes');
2304 return { result: { decision: narrowFileChangeDecision(decision) } };
2305 }
2307 > private async _handlePermissionsApprovalRequestRpc(params: PermissionsRequestApprovalParams): Promise<{ readonly result: PermissionsRequestApprovalResponse }> {
2308 const decision = await this._requestItemApproval(params.threadId, params.itemId, params.reason ?? 'Grant elevated permissions');
2309 const granted = decision === 'accept' || decision === 'acceptForSession';
2310 return {
2311 result: {
2312 // Grant exactly what was requested on accept; nothing on decline.
2313 permissions: granted
2314 ? { network: params.permissions.network ?? undefined, fileSystem: params.permissions.fileSystem ?? undefined }
2315 : {},
2316 scope: decision === 'acceptForSession' ? 'session' : 'turn',
2317 },
2318 };
2319 }
2321 > /**
2322 > * Shared approval flow for item-scoped `requestApproval` requests that
2323 > * don't carry their own command string: look up the host tool call for
2324 > * the item, fire a pending-confirmation `ChatToolCallReady`, and resolve
2325 > * when the user (via {@link respondToPermissionRequest}) decides. Declines
2326 > * if the session or item is unknown.
2327 > */
2328 > private async _requestItemApproval(threadId: string, itemId: string, confirmationTitle: string): Promise<CommandExecutionApprovalDecision> {
2329 const target = this._resolveApprovalTarget(threadId);
2330 if (!target) {
2331 this._logService.warn(`[Codex] approval request for unknown threadId=${threadId}; declining`);
2332 return 'decline';
2333 }
2334 const session = target.session;
2335 const entry = session.mapState.itemToToolCall.get(itemId);
2336 if (!entry) {
2337 this._logService.warn(`[Codex:${session.sessionId}] approval request for unknown itemId=${itemId}; declining`);
2338 return 'decline';
2339 }
2340 return session.pendingCommandApprovals.registerAndFire(entry.toolCallId, () => {
2341 this._fireApproval(target, {
2342 type: ActionType.ChatToolCallReady,
2343 turnId: entry.turnId,
2344 toolCallId: entry.toolCallId,
2345 invocationMessage: confirmationTitle,
2346 toolInput: confirmationTitle,
2347 confirmationTitle,
2348 });
2349 });
2350 }
2352 > /**
2353 > * Resolve the {@link ICodexSession} that owns a codex thread for an
2354 > * approval request, plus the subagent wrapper when the thread is a
2355 > * collab-agent child. A subagent tool call's pending-confirmation
2356 > * `ChatToolCallReady` must be fired with the parent `spawnAgent` tool call
2357 > * as its `parentToolCallId` (via {@link _fireApproval}) so it lands in the
2358 > * child's read-only peer chat — where the matching `ChatToolCallStart`
2359 > * lives — instead of on the parent session.
2360 > */
2361 > private _resolveApprovalTarget(threadId: string): { readonly session: ICodexSession; readonly subagent?: ICodexSubagent } | undefined {
2362 const subagent = this._subagentsByThreadId.get(threadId);
2363 if (subagent) {
2364 return { session: subagent.session, subagent };
2365 }
2366 const sessionId = this._sessionIdByThreadId.get(threadId);
2367 const session = sessionId ? this._sessions.get(sessionId) : undefined;
2368 return session ? { session } : undefined;
2369 }
2371 > /** Fire an approval action to the parent session or the subagent peer chat. */
2372 > private _fireApproval(target: { readonly session: ICodexSession; readonly subagent?: ICodexSubagent }, action: SessionAction | ChatAction): void {
2373 if (target.subagent) {
2374 this._fireSubagent(target.subagent, action);
2375 } else {
2376 this._fire(target.session.sessionUri, action);
2377 }
2378 }
2380 > private _handleGuardianWarning(session: ICodexSession, params: GuardianWarningNotification): ChatAction[] {
2381 const turnId = session.currentTurnId;
2382 if (turnId === undefined) {
2383 this._logService.trace(`[Codex:${session.sessionId}] guardianWarning without active turn; ignoring`);
2384 return [];
2385 }
2386 return [{
2387 type: ActionType.ChatResponsePart,
2388 turnId,
2389 part: {
2390 kind: ResponsePartKind.SystemNotification,
2391 content: params.message,
2392 },
2393 }];
2394 }
2396 > private async _handleGuardianReviewCompleted(client: ICodexAppServerClient, params: ItemGuardianApprovalReviewCompletedNotification): Promise<void> {
2397 const sessionId = this._sessionIdByThreadId.get(params.threadId);
2398 const session = sessionId ? this._sessions.get(sessionId) : undefined;
2399 if (!session) {
2400 this._logService.trace(`[Codex] autoApprovalReview/completed for unknown threadId=${params.threadId}; ignoring`);
2401 return;
2402 }
2403 if (params.review.status !== 'denied') {
2404 return;
2405 }
2406 if (session.handledGuardianReviews.has(params.reviewId)) {
2407 return;
2408 }
2409 // Bind the denial surfacing to the review's OWN turn (mapped app→host),
2410 // not whatever turn happens to be current. An `autoApprovalReview/completed`
2411 // that arrives out of order — after its turn ended, or once a later turn is
2412 // active — must not mis-attribute the notice/card to a different turn, nor
2413 // apply this review's stale action against it. When the review's turn is no
2414 // longer the active turn there is nothing left to approve within it, so ignore.
2415 const turnId = this._hostTurnId(session, params.turnId);
2416 if (session.currentTurnId !== turnId) {
2417 this._logService.trace(`[Codex:${sessionId}] autoApprovalReview/completed for non-current turn ${turnId} (current=${session.currentTurnId ?? '(none)'}); ignoring reviewId=${params.reviewId}`);
2418 return;
2419 }
2420
2421 session.handledGuardianReviews.add(params.reviewId);
2422
2423 const summary = summarizeGuardianReviewAction(params.action);
2424
2425 // Durable record: a Markdown response part survives turn completion AND is
2426 // rendered by the live streaming path (unlike a system-notification part,
2427 // which the workbench maps to a transient progress message and never emits
2428 // mid-turn). The auto-review circuit-breaker interrupts the turn after
2429 // repeated denials — cancelling the tool-call card below — so without this
2430 // the user could be left with no feedback at all. Surfacing the reviewer
2431 // rationale here mirrors the manual-approval feedback the Default
2432 // permissions preset provides.
2433 this._fire(session.sessionUri, {
2434 type: ActionType.ChatResponsePart,
2435 turnId,
2436 part: {
2437 kind: ResponsePartKind.Markdown,
2438 id: generateUuid(),
2439 content: formatGuardianDenialNotification(summary, params.review.rationale),
2440 },
2441 });
2442
2443 // Best-effort in-turn override: while the turn is still running (before the
2444 // circuit-breaker interrupt) the model keeps trying safer paths, so
2445 // approving here lets codex retry the exact denied action. codex does not
2446 // block on this card, so if the turn ends first the reducer cancels it and
2447 // {@link _handleTurnCompletedNotification} unwinds the parked deferred.
2448 const toolCallId = generateUuid();
2449 const invocationMessage = summary.detail || summary.title;
2450 const confirmationTitle = 'Approve anyway';
2451 // Deliberately render this as a PLAIN confirmation card, NOT a terminal
2452 // pill: the denied action already appears as its real commandExecution
2453 // terminal box (streamed by the app-server) and again in the denial
2454 // blockquote above. Tagging the card with a terminal `toolKind` + a
2455 // `toolInput` would make the adapter draw a *second* terminal box for the
2456 // same command (see stateToProgressAdapter `shouldRenderAsTerminal`),
2457 // which is the duplicate the user reported. Omitting both keeps the card
2458 // to just its title/message + "Approve anyway" button. The button still
2459 // works because the reducer keys PendingConfirmation off confirmationTitle
2460 // (with `confirmed` unset), independent of toolInput/meta.
2461 session.pendingGuardianReviewCards.add(toolCallId);
2462 let decision: CommandExecutionApprovalDecision;
2463 try {
2464 decision = await session.pendingCommandApprovals.registerAndFire(toolCallId, () => {
2465 this._fire(session.sessionUri, {
2466 type: ActionType.ChatToolCallStart,
2467 turnId,
2468 toolCallId,
2469 toolName: 'auto_review_denied',
2470 displayName: summary.title,
2471 intention: invocationMessage,
2472 });
2473 this._fire(session.sessionUri, {
2474 type: ActionType.ChatToolCallReady,
2475 turnId,
2476 toolCallId,
2477 invocationMessage,
2478 confirmationTitle,
2479 });
2480 });
2481 } catch (err) {
2482 // The parked approval was rejected (session dispose / cancellation);
2483 // there is no card lifecycle left to finalize.
2484 this._logService.trace(`[Codex:${sessionId}] guardian approval cancelled for reviewId=${params.reviewId}: ${err instanceof Error ? err.message : String(err)}`);
2485 return;
2486 } finally {
2487 session.pendingGuardianReviewCards.delete(toolCallId);
2488 }
2489
2490 if (decision !== 'accept' && decision !== 'acceptForSession') {
2491 // Declined, cancelled, or unwound by turn completion: the action stays
2492 // blocked by codex. When the user declined, the UI already transitioned
2493 // the card off the ChatToolCallConfirmed it dispatched; when the turn
2494 // ended, the reducer cancelled it. Either way there is nothing to send.
2495 return;
2496 }
2497
2498 // If the turn ended between the user's approval and here, the card was
2499 // already cancelled by the reducer and codex is no longer waiting on this
2500 // action within the turn — skip the round-trip.
2501 if (session.currentTurnId !== turnId) {
2502 this._logService.trace(`[Codex:${sessionId}] turn ended before guardian approval could be applied for reviewId=${params.reviewId}`);
2503 return;
2504 }
2505
2506 try {
2507 await client.request<'thread/approveGuardianDeniedAction', ThreadApproveGuardianDeniedActionResponse>('thread/approveGuardianDeniedAction', {
2508 threadId: params.threadId,
2509 event: toGuardianAssessmentEventJson(params),
2510 });
2511 this._fire(session.sessionUri, {
2512 type: ActionType.ChatToolCallComplete,
2513 turnId,
2514 toolCallId,
2515 result: {
2516 success: true,
2517 pastTenseMessage: 'Approved anyway',
2518 },
2519 });
2520 } catch (err) {
2521 // The user approved but the app-server rejected the round-trip; finalize
2522 // the card as failed so it does not hang in the running state forever.
2523 const message = err instanceof Error ? err.message : String(err);
2524 this._logService.warn(`[Codex:${sessionId}] approveGuardianDeniedAction failed for reviewId=${params.reviewId}: ${message}`);
2525 this._fire(session.sessionUri, {
2526 type: ActionType.ChatToolCallComplete,
2527 turnId,
2528 toolCallId,
2529 result: {
2530 success: false,
2531 pastTenseMessage: 'Approval failed',
2532 error: { message },
2533 },
2534 });
2535 }
2536 }
2538 > private _handleConnectionLost(): void {
2539 const conn = this._connection;
2540 if (conn.kind !== 'ready') {
2541 return;
2542 }
2543 this._connection = { kind: 'idle' };
2544 // Notify every known session with a single ChatError + complete
2545 // pair so the UI surfaces "agent disconnected" cleanly.
2546 for (const session of this._sessions.values()) {
2547 // Unpark any pending approvals so awaiters unwind.
2548 session.pendingCommandApprovals.denyAll('decline');
2549 // Reject in-flight client tool calls so their handlers unwind.
2550 session.pendingClientToolCalls.rejectAll(new CancellationError());
2551 session.pendingUserInputs.rejectAll(new CancellationError());
2552 // Clear any buffered steering so its pending bubble doesn't leak.
2553 this._drainPendingSteering(session);
2554 const turnId = session.currentTurnId;
2555 const appTurnId = session.currentAppTurnId;
2556 session.currentTurnId = undefined;
2557 session.currentAppTurnId = undefined;
2558 if (appTurnId) {
2559 session.hostTurnIdByAppTurnId.delete(appTurnId);
2560 }
2561 if (turnId) {
2562 const duration = this._clearTurnStopWatch(session);
2563 this._fire(session.sessionUri, {
2564 type: ActionType.ChatError,
2565 turnId,
2566 duration,
2567 error: { errorType: 'CodexDisconnected', message: 'Codex app-server disconnected; session must restart.' },
2568 });
2569 this._fire(session.sessionUri, { type: ActionType.ChatTurnComplete, turnId, duration });
2570 }
2571 }
2572 for (const subagent of this._subagentsByThreadId.values()) {
2573 subagent.session.pendingCommandApprovals.denyAll('decline');
2574 subagent.session.pendingClientToolCalls.rejectAll(new CancellationError());
2575 subagent.session.pendingUserInputs.rejectAll(new CancellationError());
2576 subagent.session.currentTurnId = undefined;
2577 subagent.session.currentAppTurnId = undefined;
2578 }
2579 this._subagentsByThreadId.clear();
2580 this._applyPendingUsageSourceIfIdle();
2581 // Release resources. The proxy handle is refcounted and drops
2582 // the underlying server once everyone releases.
2583 try {
2584 conn.client.dispose();
2585 } catch (err) {
2586 this._logService.error(`[Codex] Failed to dispose app-server client after connection lost: ${err instanceof Error ? err.message : String(err)}`);
2587 }
2588 try {
2589 conn.proxyHandle?.dispose();
2590 } catch (err) {
2591 this._logService.error(`[Codex] Failed to dispose proxy handle after connection lost: ${err instanceof Error ? err.message : String(err)}`);
2592 }
2593 }
2595 > private _disposeConnection(): void {
2596 > const connection = this._connection; codexAgent.ts ×24
2597 > this._connectionGeneration++;
2598 > this._connection = { kind: 'idle' };
2599 > if (connection.kind !== 'ready') {
2600 > return;
2601 > }
2602 try { connection.client.dispose(); } catch { /* ignore */ }
2603 > try { connection.proxyHandle?.dispose(); } catch { /* ignore */ } codexAgent.ts ×24
2604 try { connection.child.kill('SIGKILL'); } catch { /* already dead */ }
2607 > // #endregion
2608 >
2609 > // #region IAgent methods
2610 >
2611 > getDescriptor(): IAgentDescriptor {
2612 return {
2613 provider: this.id,
2614 displayName: localize('codexAgent.displayName', "Codex"),
2615 description: this._usageSource === 'openai'
2616 ? localize('codexAgent.description.openai', "Codex agent using your OpenAI account")
2617 : localize('codexAgent.description.copilot', "Codex agent using GitHub Copilot"),
2618 };
2619 }
2621 > private _sessionUriFromChat(chat: URI): URI {
2622 const parsed = parseChatUri(chat);
2623 return parsed ? URI.parse(parsed.session) : chat;
2624 }
2626 > // ---- Chat surface ------------------------------------------------------
2627 > //
2628 > // Chat-addressed adoption of the {@link IAgent} surface introduced
2629 > // in gate G-C1. Codex is a SINGLE-CHAT harness: a session owns exactly one
2630 > // (default) chat addressed by its default chat channel URI, so the
2631 > // chat methods simply route to the existing session-addressed
2632 > // implementations. The legacy `(session, chat?)` methods below are kept as a
2633 > // compat shim (removed centrally in gate G-C2) and both surfaces coexist.
2634 >
2635 > /**
2636 > * The chat-addressed operation surface for the chats within a session.
2637 > * Codex is single-chat: peer-chat operations
2638 > * ({@link IAgentChats.createChat}/{@link IAgentChats.fork})
2639 > * are unsupported and throw, mirroring today's behavior where Codex omits
2640 > * `createChat` (the orchestrator rejected multi-chat for Codex). The
2641 > * remaining methods address the session's single default chat, whose
2642 > * URI is the deterministic default chat channel URI.
2643 > */
2644 > readonly chats: IAgentChats = {
2645 > createChat: (_chat: URI, _options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> => {
2646 > throw new Error('Codex agent does not support multiple chats'); codexAgent.ts ×8
2647 > },
2648 > fork: (_chat: URI, _source: IAgentCreateChatForkSource, _options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> => { codexAgent.ts ×158
2649 > throw new Error('Codex agent does not support chat forking'); codexAgent.ts ×8
2650 > },
2651 > disposeChat: (_chat: URI): Promise<void> => { codexAgent.ts ×158
2652 > // Codex has no additional (peer) chats to dispose; the codexAgent.ts ×8
2653 > // default chat lives and dies with its session.
2654 > return Promise.resolve();
2655 > },
2656 > sendMessage: (chat: URI, prompt: string, workingDirectory: URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise<void> => { codexAgent.ts ×158
2657 > return this._sendMessage(chat, prompt, attachments, turnId, workingDirectory); codexAgent.ts ×8
2658 > },
2659 > abort: (chat: URI): Promise<void> => { codexAgent.ts ×158
2660 > return this._abort(chat); codexAgent.ts ×8
2661 > },
2662 > changeModel: (chat: URI, model: ModelSelection): Promise<void> => { codexAgent.ts ×158
2663 > return this._changeModel(chat, model); codexAgent.ts ×8
2664 > },
2665 > changeAgent: (_chat: URI, _agent: AgentSelection | undefined): Promise<void> => { codexAgent.ts ×158
2666 > // Codex does not support selecting a custom agent. codexAgent.ts ×8
2667 > return Promise.resolve();
2668 > },
2669 > getMessages: (chat: URI): Promise<readonly Turn[]> => { codexAgent.ts ×158
2670 > return this.getSessionMessages(chat); codexAgent.ts ×8
2671 > },
2673 >
2674 > async createSession(config: IAgentCreateSessionConfig = {}): Promise<IAgentCreateSessionResult> {
2675 this._logService.info(`[Codex DEBUG] createSession usageSource=${this._usageSource} accountStatus=${codexAccountStateForUsageSource(this._usageSource, this._openAIAccountState).status} session=${config.session?.toString() ?? '(none)'} model=${config.model?.id ?? '(none)'} cwd=${config.workingDirectory?.toString() ?? '(none)'}`);
2676 let validation = this._usageSourceValidation;
2677 await validation;
2678 while (validation !== this._usageSourceValidation) {
2679 validation = this._usageSourceValidation;
2680 await validation;
2681 }
2682 this._ensureAuthenticated();
2683 if (config.fork) {
2684 return this._forkSession(config, config.fork);
2685 }
2686 // Codex requires a working directory to start a thread, but the client
2687 // may not have one to give (e.g. an editor window with no workspace
2688 // folder open). Rather than reject session creation — which would break
2689 // both the session and the first-use SDK download progress notification
2690 // that keys off a successful `createSession` — defer: a managed temp
2691 // folder is created lazily at materialize time (see `_materialize`).
2692
2693 // Provisional / lazy materialize. We DON'T call `thread/start` here
2694 // because the workbench may rebind this URI to a fresh one when the
2695 // user changes a chip selection, and we'd otherwise leak an
2696 // orphan codex thread per rebind. The actual `thread/start` happens
2697 // on the first `sendMessage` (or `getSessionMetadata` for restore).
2698 const effectiveModel = this._supportedModelOrUndefined(config.model);
2699 const sessionId = config.session ? AgentSession.id(config.session) : generateUuid();
2700 const sessionUri = config.session ?? AgentSession.uri(this.id, sessionId);
2701
2702 // If the workbench is rebinding this URI (createSession arriving
2703 // after a previous dispose for the same id), reuse the existing
2704 // entry so we don't lose accumulated state.
2705 const existing = this._sessions.get(sessionId);
2706 if (existing) {
2707 existing.model = effectiveModel ?? existing.model;
2708 return {
2709 session: sessionUri,
2710 workingDirectory: existing.workingDirectory ?? config.workingDirectory,
2711 provisional: existing.threadId === undefined,
2712 };
2713 }
2714
2715 const clientToolSet = new ActiveClientToolSet();
2716 const session: ICodexSession = {
2717 sessionId,
2718 threadId: undefined,
2719 sessionUri,
2720 workingDirectory: config.workingDirectory,
2721 managedWorkingDirectory: undefined,
2722 mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? []), clientToolSet),
2723 pendingCommandApprovals: new PendingRequestRegistry<CommandExecutionApprovalDecision>(),
2724 acceptedForSession: new Set<string>(),
2725 handledGuardianReviews: new Set<string>(),
2726 pendingGuardianReviewCards: new Set<string>(),
2727 pendingSteeringFlips: new Map<string, PendingMessage>(),
2728 clientToolSet,
2729 pendingClientToolCalls: new PendingRequestRegistry<ToolCallResult>(),
2730 pendingUserInputs: new PendingRequestRegistry<ICodexUserInputResult>(),
2731 materializedToolsSig: undefined,
2732 materializedMcpSig: undefined,
2733 firstTurnSent: false,
2734 model: effectiveModel,
2735 currentTurnId: undefined,
2736 turnStopWatch: undefined,
2737 currentAppTurnId: undefined,
2738 hostTurnIdByAppTurnId: new Map<string, string>(),
2739 codexTurnIdByHostTurnId: new Map<string, string>(),
2740 needsResume: false,
2741 lastPromptText: '',
2742 disposed: false,
2743 materializePromise: undefined,
2744 materializedEventFired: false,
2745 prewarmTimer: undefined,
2746 prewarmClaimed: false,
2747 serverToolsAdvertised: false,
2748 mcpController: undefined,
2749 clientCustomizations: new CodexClientCustomizationStore(),
2750 };
2751 this._sessions.set(sessionId, session);
2752 this._schedulePrewarm(session);
2753 return {
2754 session: sessionUri,
2755 workingDirectory: config.workingDirectory,
2756 provisional: true,
2757 };
2758 }
2760 > /**
2761 > * Build an {@link ICodexSession} entry for a thread that already exists on
2762 > * the app-server (a restored session or a freshly forked one). Such a
2763 > * session skips materialization — its first {@link _sendMessage} issues a
2764 > * `thread/resume` (`needsResume: true`) — so the prewarm/first-turn flags
2765 > * are pre-set to their post-materialization values.
2766 > */
2767 > private _createResumedSessionEntry(sessionId: string, threadId: string, sessionUri: URI, workingDirectory: URI | undefined, model: ModelSelection | undefined): ICodexSession {
2768 const clientToolSet = new ActiveClientToolSet();
2769 return {
2770 sessionId,
2771 threadId,
2772 sessionUri,
2773 workingDirectory,
2774 managedWorkingDirectory: undefined,
2775 mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? []), clientToolSet),
2776 pendingCommandApprovals: new PendingRequestRegistry<CommandExecutionApprovalDecision>(),
2777 acceptedForSession: new Set<string>(),
2778 handledGuardianReviews: new Set<string>(),
2779 pendingGuardianReviewCards: new Set<string>(),
2780 pendingSteeringFlips: new Map<string, PendingMessage>(),
2781 clientToolSet,
2782 pendingClientToolCalls: new PendingRequestRegistry<ToolCallResult>(),
2783 pendingUserInputs: new PendingRequestRegistry<ICodexUserInputResult>(),
2784 materializedToolsSig: undefined,
2785 materializedMcpSig: undefined,
2786 firstTurnSent: true,
2787 model,
2788 currentTurnId: undefined,
2789 turnStopWatch: undefined,
2790 currentAppTurnId: undefined,
2791 hostTurnIdByAppTurnId: new Map<string, string>(),
2792 codexTurnIdByHostTurnId: new Map<string, string>(),
2793 needsResume: true,
2794 lastPromptText: '',
2795 disposed: false,
2796 materializePromise: undefined,
2797 materializedEventFired: true,
2798 prewarmTimer: undefined,
2799 prewarmClaimed: true,
2800 serverToolsAdvertised: false,
2801 mcpController: undefined,
2802 clientCustomizations: new CodexClientCustomizationStore(),
2803 };
2804 }
2806 > /**
2807 > * Fork an existing codex session at a turn into a brand-new session.
2808 > *
2809 > * Codex is single-chat, so the workbench routes the "fork conversation"
2810 > * gesture here (via {@link AgentHostSessionHandler}) instead of minting a
2811 > * peer chat. We `thread/fork` the source thread — which copies its full
2812 > * history — then `thread/rollback` the trailing turns so the fork retains
2813 > * only the turns up to and including `fork.turnId`. The forked thread is
2814 > * registered as a resumable session (its first send issues a
2815 > * `thread/resume`) keyed by its new thread id, preserving the Codex
2816 > * convention that a session id equals its thread id.
2817 > */
2818 > private async _forkSession(config: IAgentCreateSessionConfig, fork: NonNullable<IAgentCreateSessionConfig['fork']>): Promise<IAgentCreateSessionResult> {
2819 const sourceRead = await this._readSession(fork.session);
2820 if (!sourceRead) {
2821 throw new Error(`Cannot fork codex session ${fork.session.toString()}: source thread could not be read`);
2822 }
2823 const sourceThreadId = sourceRead.thread.id;
2824 const sourceTurns = sourceRead.thread.turns ?? [];
2825
2826 // Resolve how many trailing turns to drop so the fork keeps turns up to
2827 // and including `fork.turnId`. A live source maps host turn ids to codex
2828 // turn ids; a restored source already uses codex ids. Fall back to the
2829 // caller-supplied `turnIndex` when the id can't be resolved.
2830 const sourceSession = this._sessions.get(AgentSession.id(fork.session));
2831 const codexTurnId = sourceSession?.codexTurnIdByHostTurnId.get(fork.turnId) ?? fork.turnId;
2832 // Reject an unresolvable fork boundary rather than silently keeping the
2833 // full history: if neither the mapped codex turn id nor the caller's
2834 // `turnIndex` lands inside the source turns, a `numTurnsToDrop` of 0 would
2835 // branch from the wrong point (the tip instead of the requested turn).
2836 const boundary = resolveForkBoundary(sourceTurns.map(t => t.id), codexTurnId, fork.turnIndex);
2837 if (!boundary.resolved) {
2838 throw new Error(`Cannot fork codex session ${sourceThreadId}: unable to resolve fork boundary for turn ${fork.turnId} (turnIndex=${fork.turnIndex}, turns=${sourceTurns.length})`);
2839 }
2840 const { keepThroughIndex, numTurnsToDrop } = boundary;
2841
2842 const conn = await this._ensureConnection();
2843 const model = this._supportedModelOrUndefined(config.model);
2844 // Inherit the source session's effective permissions so forking an
2845 // auto-review / full-access / read-only session doesn't silently reset the
2846 // fork back to the Default preset. Fork callers typically pass an empty
2847 // `config.config`; any explicit override there still wins.
2848 const sourceConfigValues = this._configurationService.getSessionConfigValues(fork.session.toString());
2849 const forkDefaults = {
2850 approvalPolicy: codexSessionConfigDefaults[CodexSessionConfigKey.ApprovalPolicy],
2851 sandboxMode: codexSessionConfigDefaults[CodexSessionConfigKey.SandboxMode],
2852 };
2853 const { approvalPolicy, sandboxMode, approvalsReviewer } = resolveCodexPermissions(
2854 migrateCodexPermissionValues({ ...sourceConfigValues, ...config.config }, forkDefaults),
2855 forkDefaults,
2856 );
2857 const forkResult = await conn.client.request<'thread/fork', ThreadForkResponse>('thread/fork', {
2858 threadId: sourceThreadId,
2859 ...(model ? { model: model.id } : {}),
2860 approvalPolicy,
2861 sandbox: sandboxMode,
2862 approvalsReviewer,
2863 });
2864 const newThreadId = forkResult.thread.id;
2865
2866 // The fork copies the full source history; drop the trailing turns so
2867 // the new thread ends at the requested fork point. A failed rollback
2868 // would leave the fork carrying the very turns the user asked to branch
2869 // away from, so treat it as a hard failure: archive the orphaned fork
2870 // and reject rather than returning a session with the wrong history.
2871 if (numTurnsToDrop > 0) {
2872 try {
2873 await conn.client.request<'thread/rollback'>('thread/rollback', { threadId: newThreadId, numTurns: numTurnsToDrop });
2874 } catch (err) {
2875 const message = err instanceof Error ? err.message : String(err);
2876 this._logService.warn(`[Codex:${newThreadId}] fork rollback failed (numTurns=${numTurnsToDrop}); discarding fork: ${message}`);
2877 try {
2878 await conn.client.request<'thread/archive'>('thread/archive', { threadId: newThreadId });
2879 } catch (archiveErr) {
2880 this._logService.warn(`[Codex:${newThreadId}] failed to archive orphaned fork after rollback failure: ${archiveErr instanceof Error ? archiveErr.message : String(archiveErr)}`);
2881 }
2882 throw new Error(`Failed to fork codex session ${sourceThreadId}: could not roll back forked thread ${newThreadId} to the requested turn (${message})`);
2883 }
2884 }
2885
2886 // Codex convention (Decision 7): session id == thread id, so a restore
2887 // round-trips through `getSessionMetadata`.
2888 const newSessionUri = AgentSession.uri(this.id, newThreadId);
2889 const workingDirectory = forkResult.cwd
2890 ? URI.file(forkResult.cwd)
2891 : (sourceRead.thread.cwd ? URI.file(sourceRead.thread.cwd) : config.workingDirectory);
2892
2893 const session = this._createResumedSessionEntry(newThreadId, newThreadId, newSessionUri, workingDirectory, model);
2894 this._sessions.set(newThreadId, session);
2895 this._sessionIdByThreadId.set(newThreadId, newThreadId);
2896 // Forked threads skip materialization (the thread already exists), so
2897 // advertise the server tools here for client-side parity.
2898 if (!session.serverToolsAdvertised && this._serverToolHost) {
2899 session.serverToolsAdvertised = true;
2900 this._serverToolHost.advertise(session.sessionUri.toString());
2901 }
2902 this._persistMaterializedSession(session);
2903
2904 // Seed the host→codex turn-id map for the copied turns so a later
2905 // edit/truncate of an inherited turn can resolve its app-server turn id.
2906 // Without this, `truncateSession` can't map the host id and skips the
2907 // rollback. `thread/fork` may regenerate turn ids, so read the forked
2908 // thread's authoritative kept turns and pair them, in order, with the new
2909 // host turn ids from `fork.turnIdMapping`. Best-effort: a failed read just
2910 // leaves the map unseeded (same as before), never blocking the fork.
2911 if (fork.turnIdMapping && fork.turnIdMapping.size > 0) {
2912 try {
2913 const forkedRead = await this._readSession(newSessionUri);
2914 const forkedTurns = forkedRead?.thread.turns ?? [];
2915 const entries = planForkedTurnIdMap(
2916 sourceTurns.map(t => t.id),
2917 forkedTurns.map(t => t.id),
2918 keepThroughIndex,
2919 sourceSession?.hostTurnIdByAppTurnId,
2920 fork.turnIdMapping,
2921 );
2922 for (const [hostTurnId, forkedCodexTurnId] of entries) {
2923 session.codexTurnIdByHostTurnId.set(hostTurnId, forkedCodexTurnId);
2924 }
2925 } catch (err) {
2926 this._logService.warn(`[Codex:${newThreadId}] failed to seed forked turn-id map: ${err instanceof Error ? err.message : String(err)}`);
2927 }
2928 }
2929
2930 this._logService.info(`[Codex] forked session ${sourceThreadId}${newThreadId} (kept ${sourceTurns.length - numTurnsToDrop}/${sourceTurns.length} turns)`);
2931 return {
2932 session: newSessionUri,
2933 workingDirectory,
2934 provisional: false,
2935 };
2936 }
2938 > /**
2939 > * Lazily start (or resume) a codex thread for `session`. Idempotent:
2940 > * if `threadId` is already populated, just returns. Called from
2941 > * `sendMessage` before the first `turn/start`.
2942 > */
2943 > private async _materializeIfNeeded(session: ICodexSession, fireMaterializedEvent = true): Promise<void> {
2944 if (session.disposed) {
2945 return;
2946 }
2947 if (session.threadId !== undefined) {
2948 if (fireMaterializedEvent) {
2949 this._fireMaterialized(session);
2950 }
2951 return;
2952 }
2953 if (session.materializePromise) {
2954 await session.materializePromise;
2955 if (fireMaterializedEvent) {
2956 this._fireMaterialized(session);
2957 }
2958 return;
2959 }
2960 session.materializePromise = this._materialize(session).finally(() => {
2961 session.materializePromise = undefined;
2962 });
2963 await session.materializePromise;
2964 if (fireMaterializedEvent) {
2965 this._fireMaterialized(session);
2966 }
2967 }
2969 > private async _materialize(session: ICodexSession): Promise<void> {
2970 if (session.disposed) {
2971 return;
2972 }
2973 if (!session.workingDirectory) {
2974 // No working directory was supplied (e.g. an editor window with no
2975 // workspace folder open). Codex requires one, so create a managed
2976 // per-session temp folder and remember it for cleanup on dispose.
2977 const dir = join(os.tmpdir(), 'vscode-agent-codex', session.sessionId);
2978 await fs.promises.mkdir(dir, { recursive: true });
2979 session.workingDirectory = URI.file(dir);
2980 session.managedWorkingDirectory = session.workingDirectory;
2981 this._logService.info(`[Codex] no working directory supplied for session=${session.sessionUri.toString()}; using managed temp folder ${dir}`);
2982 }
2983 const conn = await this._ensureConnection();
2984 const config = this._readSessionConfig(session);
2985 const model = await this._resolveModel(session);
2986 const { approvalPolicy, sandboxMode, approvalsReviewer } = this._resolveSessionPermissions(session);
2987 // Attach the session's MCP servers per-thread (verified: codex starts
2988 // them for this thread only): the workbench's root `mcpServers` config
2989 // merged with this session's enabled client-plugin servers. Passing them
2990 // per-thread means a new session always reflects the current root config.
2991 const mcpServers = this._buildSessionMcpServers(session);
2992 const threadConfig: Record<string, JsonValue> = {
2993 web_search: narrowWebSearchMode(config[CodexSessionConfigKey.WebSearchMode]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.WebSearchMode],
2994 };
2995 const mcpServerNames = Object.keys(mcpServers);
2996 if (mcpServerNames.length > 0) {
2997 threadConfig.mcp_servers = mcpServers as JsonValue;
2998 this._logService.info(`[Codex] thread/start for session=${session.sessionUri.toString()} with ${mcpServerNames.length} MCP server(s): ${mcpServerNames.join(', ')}`);
2999 }
3000 const startResult = await conn.client.request<'thread/start', { thread: { id: string } }>('thread/start', {
3001 cwd: session.workingDirectory.fsPath,
3002 model: model.id,
3003 approvalPolicy,
3004 sandbox: sandboxMode,
3005 approvalsReviewer,
3006 config: threadConfig,
3007 dynamicTools: this._buildDynamicTools(session),
3008 });
3009 const threadId = startResult.thread.id;
3010 if (session.disposed) {
3011 try {
3012 await conn.client.request<'thread/unsubscribe'>('thread/unsubscribe', { threadId });
3013 } catch (err) {
3014 this._logService.info(`[Codex:${threadId}] thread/unsubscribe after disposed prewarm failed: ${err instanceof Error ? err.message : String(err)}`);
3015 }
3016 return;
3017 }
3018 session.threadId = threadId;
3019 session.materializedMcpSig = mcpServersSignature(mcpServers);
3020 session.materializedToolsSig = toolsSignature(session.clientToolSet.merged());
3021 this._logService.info(`[Codex DEBUG] materialized session=${session.sessionUri.toString()} threadId=${session.threadId}`);
3022 this._sessionIdByThreadId.set(session.threadId, session.sessionId);
3023 // Advertise the agent host's server tools on this session so clients see
3024 // them as server-provided. Execution happens in-process via
3025 // `_handleDynamicToolCallRpc`; the tools were registered with codex in
3026 // the `dynamicTools` of the `thread/start` above.
3027 if (!session.serverToolsAdvertised && this._serverToolHost) {
3028 session.serverToolsAdvertised = true;
3029 this._serverToolHost.advertise(session.sessionUri.toString());
3030 }
3031 // Surface the skills/hooks codex loaded for this working directory (from
3032 // `.agents`/`.codex`) in the Customizations view now that the connection
3033 // is ready and the cwd is known. Best-effort and fire-and-forget.
3034 void this._refreshSkillHookCustomizations(session);
3035 // Re-apply the client-plugin skill roots against the now-ready
3036 // connection (they may have been synced before it came up).
3037 void this._refreshSkillExtraRoots();
3038 }
3040 > /**
3041 > * Tear down the current codex thread and start a fresh one so the
3042 > * session's current client tools are registered as `dynamicTools`.
3043 > * Only safe before any turn has committed history on the thread.
3044 > */
3045 > private async _restartThreadWithCurrentTools(session: ICodexSession): Promise<void> {
3046 const conn = this._connection;
3047 const oldThreadId = session.threadId;
3048 this._logService.info(`[Codex:${session.sessionId}] restarting thread ${oldThreadId} to apply client tools [${session.clientToolSet.merged().map(t => t.name).join(', ') || '(none)'}]`);
3049 if (conn.kind === 'ready' && oldThreadId !== undefined) {
3050 this._sessionIdByThreadId.delete(oldThreadId);
3051 try {
3052 await conn.client.request<'thread/unsubscribe'>('thread/unsubscribe', { threadId: oldThreadId });
3053 } catch (err) {
3054 this._logService.info(`[Codex:${oldThreadId}] thread/unsubscribe during tool restart failed: ${err instanceof Error ? err.message : String(err)}`);
3055 }
3056 }
3057 session.threadId = undefined;
3058 session.materializePromise = undefined;
3059 await this._materializeIfNeeded(session);
3060 }
3062 > private _fireMaterialized(session: ICodexSession): void {
3063 if (session.disposed) {
3064 return;
3065 }
3066 if (session.materializedEventFired) {
3067 return;
3068 }
3069 session.materializedEventFired = true;
3070 this._onDidMaterializeSession.fire({
3071 session: session.sessionUri,
3072 workingDirectory: session.workingDirectory,
3073 project: undefined,
3074 });
3075 }
3077 > private _schedulePrewarm(session: ICodexSession): void {
3078 if (!session.workingDirectory) {
3079 return;
3080 }
3081 // Defer prewarm while the host has not finalized the working directory
3082 // (a fresh worktree session whose worktree is created on the first send).
3083 // Prewarming would otherwise materialize a thread in the picked folder
3084 // before the worktree exists.
3085 if (this._configurationService.isWorkingDirectoryPending(session.sessionUri.toString())) {
3086 return;
3087 }
3088 void (async () => {
3089 // Prewarm is a background latency optimization, not a user action,
3090 // so it must NOT trigger a cold SDK download. When the SDK isn't
3091 // local yet, skip prewarm; the first `sendMessage` materializes the
3092 // thread and fires the (host-level progress-reported) download then.
3093 if (!(await this._agentSdkDownloader.isSdkResolvableWithoutDownload(CodexSdkPackage))) {
3094 this._logService.info(`[Codex] SDK not downloaded yet; skipping prewarm for session=${session.sessionUri.toString()} until a message triggers the download`);
3095 return;
3096 }
3097 await this._materializeIfNeeded(session, false);
3098 if (session.prewarmClaimed || session.threadId === undefined) {
3099 return;
3100 }
3101 this._logService.info(`[Codex] prewarm ready session=${session.sessionUri.toString()} threadId=${session.threadId}`);
3102 const prewarmTimer = setTimeout(() => {
3103 void this._expirePrewarm(session);
3104 }, CodexPrewarmTtlMs);
3105 session.prewarmTimer = prewarmTimer;
3106 })().catch(err => {
3107 this._logService.warn(`[Codex] prewarm failed session=${session.sessionUri.toString()}: ${err instanceof Error ? err.message : String(err)}`);
3108 });
3109 }
3111 > private async _expirePrewarm(session: ICodexSession): Promise<void> {
3112 if (session.disposed || session.prewarmClaimed || session.threadId === undefined) {
3113 return;
3114 }
3115 const threadId = session.threadId;
3116 session.threadId = undefined;
3117 this._sessionIdByThreadId.delete(threadId);
3118 try {
3119 const conn = await this._ensureConnection();
3120 await conn.client.request<'thread/unsubscribe'>('thread/unsubscribe', { threadId });
3121 this._logService.info(`[Codex] prewarm TTL eviction session=${session.sessionUri.toString()} threadId=${threadId}`);
3122 } catch (err) {
3123 this._logService.warn(`[Codex] prewarm TTL eviction failed session=${session.sessionUri.toString()} threadId=${threadId}: ${err instanceof Error ? err.message : String(err)}`);
3124 }
3125 }
3127 > private _persistMaterializedSession(session: ICodexSession): void {
3128 if (session.disposed || !session.threadId) {
3129 return;
3130 }
3131 // Persist only once the prewarmed thread is claimed by a turn. This
3132 // avoids restoring an expired, never-used prewarm as a live session.
3133 void this._metadataStore.write(session.sessionUri, {
3134 threadId: session.threadId,
3135 cwd: session.workingDirectory,
3136 modelId: session.model?.id,
3137 });
3138 }
3140 > private _claimPrewarm(session: ICodexSession): void {
3141 session.prewarmClaimed = true;
3142 if (session.prewarmTimer) {
3143 clearTimeout(session.prewarmTimer);
3144 session.prewarmTimer = undefined;
3145 }
3146 }
3148 > private _startTurnStopWatch(session: ICodexSession): StopWatch {
3149 const stopWatch = StopWatch.create(false);
3150 session.turnStopWatch = stopWatch;
3151 return stopWatch;
3152 }
3154 > private _clearTurnStopWatch(session: ICodexSession): number {
3155 const elapsed = session.turnStopWatch?.elapsed();
3156 session.turnStopWatch = undefined;
3157 return typeof elapsed === 'number' && Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0;
3158 }
3160 > private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, workingDirectory?: URI): Promise<void> {
3161 const sessionUri = this._sessionUriFromChat(chat);
3162 this._logService.info(`[Codex DEBUG] sendMessage session=${sessionUri.toString()} prompt=${JSON.stringify(prompt).slice(0, 60)}`);
3163 const sessionId = AgentSession.id(sessionUri);
3164 const session = this._sessions.get(sessionId);
3165 if (!session) {
3166 throw new Error(`Codex session not found: ${sessionUri.toString()}`);
3167 }
3168 // The host hands us the resolved working directory (an isolated worktree for
3169 // worktree isolation) on the first send; adopt it before materialize locks
3170 // the codex subprocess cwd. The agent stays unaware of worktrees.
3171 if (workingDirectory && session.threadId === undefined) {
3172 session.workingDirectory = workingDirectory;
3173 }
3174 const conn = await this._ensureConnection();
3175 const effectiveTurnId = turnId ?? generateUuid();
3176
3177 // Materialize codex thread on first send (provisional → live).
3178 // `_materializeIfNeeded` is idempotent.
3179 try {
3180 this._claimPrewarm(session);
3181 await this._materializeIfNeeded(session);
3182 this._persistMaterializedSession(session);
3183 } catch (err) {
3184 const message = err instanceof Error ? err.message : String(err);
3185 this._logService.error(`[Codex:${sessionId}] materialize failed: ${message}`);
3186 const duration = this._clearTurnStopWatch(session);
3187 this._fire(sessionUri, {
3188 type: ActionType.ChatError,
3189 turnId: effectiveTurnId,
3190 duration,
3191 error: { errorType: 'CodexMaterializeFailed', message },
3192 });
3193 this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration });
3194 return;
3195 }
3196 // Codex registers client tools and MCP servers only at `thread/start`.
3197 // If the thread was prewarmed (or otherwise started) before the current
3198 // client tools / MCP servers were known, restart it now — before any
3199 // turn commits history, so nothing is lost — so the tools land in
3200 // `dynamicTools` and the servers in `config.mcp_servers`.
3201 const toolsChanged = toolsSignature(session.clientToolSet.merged()) !== session.materializedToolsSig;
3202 const mcpChanged = mcpServersSignature(this._buildSessionMcpServers(session)) !== session.materializedMcpSig;
3203 if (!session.firstTurnSent && !session.needsResume && (toolsChanged || mcpChanged)) {
3204 try {
3205 await this._restartThreadWithCurrentTools(session);
3206 this._persistMaterializedSession(session);
3207 } catch (err) {
3208 const message = err instanceof Error ? err.message : String(err);
3209 this._logService.error(`[Codex:${sessionId}] tool re-materialize failed: ${message}`);
3210 const duration = this._clearTurnStopWatch(session);
3211 this._fire(sessionUri, {
3212 type: ActionType.ChatError,
3213 turnId: effectiveTurnId,
3214 duration,
3215 error: { errorType: 'CodexMaterializeFailed', message },
3216 });
3217 this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration });
3218 return;
3219 }
3220 }
3221 const threadId = session.threadId!;
3222 if (session.needsResume) {
3223 try {
3224 // Carry the current MCP servers (with any injected auth token)
3225 // so a resumed thread reconnects auth-gated servers, matching
3226 // the config a fresh `thread/start` would apply.
3227 const mcpServers = this._buildSessionMcpServers(session);
3228 await conn.client.request<'thread/resume'>('thread/resume', buildCodexResumeParams(this._usageSource, threadId, mcpServers));
3229 session.materializedMcpSig = mcpServersSignature(mcpServers);
3230 session.needsResume = false;
3231 } catch (err) {
3232 const duration = this._clearTurnStopWatch(session);
3233 this._fire(sessionUri, {
3234 type: ActionType.ChatError,
3235 turnId: effectiveTurnId,
3236 duration,
3237 error: {
3238 errorType: 'CodexResumeFailed',
3239 message: err instanceof Error ? err.message : String(err),
3240 },
3241 });
3242 this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration });
3243 return;
3244 }
3245 }
3246
3247 const { input, cleanupPaths } = resolveCodexInput(prompt, attachments);
3248 // Buffer the prompt text for `turn/started`'s userMessage fallback.
3249 session.lastPromptText = prompt;
3250 session.currentTurnId = effectiveTurnId;
3251 this._startTurnStopWatch(session);
3252 try {
3253 const model = await this._resolveModel(session);
3254 const turnOptions = this._turnStartOptions(session, model.id);
3255 await conn.client.request<'turn/start'>('turn/start', {
3256 threadId,
3257 input: input.slice(),
3258 model: model.id,
3259 ...turnOptions,
3260 });
3261 // The thread now has committed history; client tools are locked to
3262 // what was registered at `thread/start` and won't be re-applied.
3263 session.firstTurnSent = true;
3264 // We don't await turn completion here — the notification
3265 // stream emits ChatTurnComplete asynchronously.
3266 } catch (err) {
3267 if (err instanceof CancellationError) {
3268 this._fire(sessionUri, { type: ActionType.ChatTurnCancelled, turnId: effectiveTurnId, duration: this._clearTurnStopWatch(session) });
3269 return;
3270 }
3271 const message = err instanceof Error ? err.message : String(err);
3272 this._logService.error(`[Codex:${sessionId}] turn/start error: ${message}`);
3273 const duration = this._clearTurnStopWatch(session);
3274 this._fire(sessionUri, {
3275 type: ActionType.ChatError,
3276 turnId: effectiveTurnId,
3277 duration,
3278 error: { errorType: 'CodexTurnError', ...extractForwardedErrorInfo(message) },
3279 });
3280 this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration });
3281 } finally {
3282 // Best-effort temp-file cleanup. Image-on-localImage will be
3283 // re-read by codex synchronously during the turn so this is
3284 // safe to defer slightly; we delete after a generous grace.
3285 if (cleanupPaths.length > 0) {
3286 setTimeout(() => {
3287 for (const p of cleanupPaths) {
3288 try { fs.unlinkSync(p); } catch { /* ignore */ }
3289 }
3290 }, 30_000);
3291 }
3292 }
3293 }
3295 > setPendingMessages(chat: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void {
3296 // Queued messages are consumed server-side (AgentSideEffects drives a
3297 // fresh turn per `idle`); only the single steering message reaches the
3298 // agent for mid-turn injection.
3299 if (!steeringMessage) {
3300 return;
3301 }
3302 // Codex is single-chat: a session owns exactly one (default) chat, so
3303 // the addressed chat channel always resolves to its owning session.
3304 const sessionUri = this._sessionUriFromChat(chat);
3305 const sessionId = AgentSession.id(sessionUri);
3306 const session = this._sessions.get(sessionId);
3307 if (!session) {
3308 return;
3309 }
3310 // `_syncPendingMessages` re-sends the current steering message on every
3311 // pending-state change; ignore a steering message already in flight.
3312 if (session.pendingSteeringFlips.has(steeringMessage.id)) {
3313 return;
3314 }
3315 const appTurnId = session.currentAppTurnId;
3316 const conn = this._connection;
3317 const text = steeringMessage.message.text;
3318 const hasContent = text.length > 0 || (steeringMessage.message.attachments?.length ?? 0) > 0;
3319 // Steering only makes sense mid-turn. Without an active codex turn, a
3320 // ready connection, a thread, or any content we cannot steer — clear
3321 // the pending bubble so it doesn't stick (the model never saw it).
3322 if (!appTurnId || conn.kind !== 'ready' || session.threadId === undefined || !hasContent) {
3323 this._fireSteeringConsumed(session, steeringMessage.id);
3324 return;
3325 }
3326 const { input } = resolveCodexInput(text, steeringMessage.message.attachments);
3327 const threadId = session.threadId;
3328 // Buffer so the codex `userMessage` echo can promote this into a
3329 // visible turn (see {@link _handleSteeredUserMessage}).
3330 session.pendingSteeringFlips.set(steeringMessage.id, steeringMessage);
3331 void conn.client.request<'turn/steer'>('turn/steer', {
3332 threadId,
3333 input: input.slice(),
3334 expectedTurnId: appTurnId,
3335 }).catch(err => {
3336 // Steer rejected (commonly an `expectedTurnId` mismatch because the
3337 // turn just completed). Drop the buffered entry and clear the
3338 // pending bubble so it doesn't stick.
3339 if (session.pendingSteeringFlips.delete(steeringMessage.id)) {
3340 this._fireSteeringConsumed(session, steeringMessage.id);
3341 }
3342 if (err instanceof JsonRpcError) {
3343 this._logService.info(`[Codex:${sessionId}] turn/steer skipped: ${err.message}`);
3344 return;
3345 }
3346 this._logService.warn(`[Codex:${sessionId}] turn/steer failed: ${err instanceof Error ? err.message : String(err)}`);
3347 });
3348 }
3350 > private async _abort(chat: URI): Promise<void> {
3351 const sessionUri = this._sessionUriFromChat(chat);
3352 const sessionId = AgentSession.id(sessionUri);
3353 const session = this._sessions.get(sessionId);
3354 if (!session) {
3355 return;
3356 }
3357 // Clear any steering buffered for the turn we're aborting so its
3358 // pending bubble doesn't outlive the turn.
3359 this._drainPendingSteering(session);
3360 if (!session.currentAppTurnId || session.threadId === undefined) {
3361 return;
3362 }
3363 const threadId = session.threadId;
3364 const conn = this._connection;
3365 if (conn.kind !== 'ready') {
3366 return;
3367 }
3368 try {
3369 await conn.client.request<'turn/interrupt'>('turn/interrupt', {
3370 threadId,
3371 turnId: session.currentAppTurnId,
3372 });
3373 } catch (err) {
3374 this._logService.warn(`[Codex:${sessionId}] turn/interrupt failed: ${err instanceof Error ? err.message : String(err)}`);
3375 }
3376 }
3378 > async disposeSession(sessionUri: URI): Promise<void> {
3379 this._logService.info(`[Codex DEBUG] disposeSession session=${sessionUri.toString()}`);
3380 const sessionId = AgentSession.id(sessionUri);
3381 const session = this._sessions.get(sessionId);
3382 if (!session) {
3383 return;
3384 }
3385 await this._teardownSessionInMemory(session, sessionId);
3386 }
3388 > /**
3389 > * Non-destructive counterpart to {@link disposeSession}: releases the
3390 > * session's in-memory resources but keeps its codex thread resumable — the
3391 > * on-disk rollout is preserved and the shared codex process stays alive, so
3392 > * the session transparently resumes on the next access. Used by idle-session
3393 > * eviction to bound memory in long-lived host processes.
3394 > *
3395 > * No-ops for sessions that have nothing durable to resume from (provisional
3396 > * sessions whose codex thread was never started) and for sessions with a
3397 > * turn in flight — `thread/unsubscribe` mid-turn would drop live progress.
3398 > */
3399 > async releaseSession(sessionUri: URI): Promise<void> {
3400 const sessionId = AgentSession.id(sessionUri);
3401 const session = this._sessions.get(sessionId);
3402 if (!session) {
3403 return;
3404 }
3405 // Provisional sessions have no codex thread on disk to resume from;
3406 // releasing them would lose their in-memory state. Leave them in place.
3407 if (session.threadId === undefined) {
3408 return;
3409 }
3410 // Defensive active-turn guard: the orchestrator already skips eviction
3411 // while a turn is active, but one could have started between that check
3412 // and this call.
3413 if (session.currentTurnId !== undefined) {
3414 return;
3415 }
3416 this._logService.info(`[Codex:${session.threadId}] Releasing idle session from memory (durable state preserved)`);
3417 await this._teardownSessionInMemory(session, sessionId);
3418 }
3420 > /**
3421 > * Shared in-memory teardown for a codex session: drops the tracked entry,
3422 > * disposes its MCP controller, unparks pending approvals / client tool calls
3423 > * / user inputs, and unsubscribes the codex thread (`thread/unsubscribe`).
3424 > * Non-destructive — the codex thread's on-disk rollout is preserved, so the
3425 > * session can be resumed later. Shared by {@link disposeSession} (which the
3426 > * orchestrator pairs with durable deletion) and the non-destructive
3427 > * {@link releaseSession}.
3428 > */
3429 > private async _teardownSessionInMemory(session: ICodexSession, sessionId: string): Promise<void> {
3430 session.disposed = true;
3431 this._claimPrewarm(session);
3432 this._sessions.delete(sessionId);
3433 session.mcpController?.dispose();
3434 // If the session contributed client-plugin skills, drop them from the
3435 // process-global skill-root union now that it is gone.
3436 if (!session.clientCustomizations.isEmpty()) {
3437 void this._refreshSkillExtraRoots();
3438 }
3439 // Remove the managed temp folder created for a session that had no
3440 // client-supplied working directory. Best-effort; the OS temp dir is
3441 // reclaimed anyway, but clean up proactively so it doesn't accumulate.
3442 if (session.managedWorkingDirectory) {
3443 const dir = session.managedWorkingDirectory.fsPath;
3444 fs.promises.rm(dir, { recursive: true, force: true }).catch(err => {
3445 this._logService.info(`[Codex] failed to remove managed temp folder ${dir}: ${err instanceof Error ? err.message : String(err)}`);
3446 });
3447 }
3448 if (session.threadId !== undefined) {
3449 this._sessionIdByThreadId.delete(session.threadId);
3450 }
3451 // Unpark any pending approvals so codex doesn't deadlock waiting
3452 // on a response we will never deliver.
3453 session.pendingCommandApprovals.denyAll('decline');
3454 // Reject any in-flight client tool calls so their `item/tool/call`
3455 // handlers unwind instead of awaiting a response that won't arrive.
3456 session.pendingClientToolCalls.rejectAll(new CancellationError());
3457 session.pendingUserInputs.rejectAll(new CancellationError());
3458 // Clear any buffered steering so its pending bubble doesn't leak.
3459 this._drainPendingSteering(session);
3460 // Tear down any live subagent child threads spawned by this session so
3461 // their parked approvals unwind and their tracking doesn't leak. The
3462 // orchestrator closes the peer chats as part of session teardown.
3463 for (const [childThreadId, subagent] of this._subagentsByThreadId) {
3464 if (subagent.parentSessionId === sessionId) {
3465 subagent.session.pendingCommandApprovals.denyAll('decline');
3466 this._subagentsByThreadId.delete(childThreadId);
3467 }
3468 }
3469 const conn = this._connection;
3470 if (conn.kind === 'ready' && session.threadId !== undefined) {
3471 const threadId = session.threadId;
3472 // `thread/unsubscribe` is the codex-native way to release a
3473 // session. Codex evicts after its 30-minute idle grace.
3474 try {
3475 await conn.client.request<'thread/unsubscribe'>('thread/unsubscribe', { threadId });
3476 } catch (err) {
3477 this._logService.info(`[Codex:${threadId}] thread/unsubscribe failed: ${err instanceof Error ? err.message : String(err)}`);
3478 }
3479 }
3480 }
3482 > private async _changeModel(chat: URI, model: ModelSelection): Promise<void> {
3483 const sessionUri = this._sessionUriFromChat(chat);
3484 const session = this._sessions.get(AgentSession.id(sessionUri));
3485 if (session) {
3486 const supported = this._supportedModelOrUndefined(model);
3487 if (supported) {
3488 session.model = supported;
3489 }
3490 }
3491 }
3493 > async truncateSession(sessionUri: URI, turnId?: string): Promise<void> {
3494 // Codex rolls back by a count of trailing turns. Resolve how many turns
3495 // follow `turnId` (or all of them when omitted) from the persisted
3496 // thread, whose turn ids match the workbench's restored turn ids
3497 // (see {@link replayThreadToTurns}). Unknown ids no-op to avoid data loss.
3498 const read = await this._readSession(sessionUri);
3499 if (!read) {
3500 return;
3501 }
3502 const turns = read.thread.turns ?? [];
3503 if (turns.length === 0) {
3504 return;
3505 }
3506 let numTurns: number;
3507 if (turnId === undefined) {
3508 numTurns = turns.length;
3509 } else {
3510 // A live session's workbench turn id maps to a codex turn id; a
3511 // restored session already uses codex turn ids, so fall back to the
3512 // id as-is on a miss.
3513 const session = this._sessions.get(AgentSession.id(sessionUri));
3514 const codexTurnId = session?.codexTurnIdByHostTurnId.get(turnId) ?? turnId;
3515 const index = turns.findIndex(t => t.id === codexTurnId);
3516 if (index === -1) {
3517 this._logService.warn(`[Codex] truncateSession: turnId ${turnId} not found in thread ${read.thread.id}; skipping`);
3518 return;
3519 }
3520 numTurns = turns.length - (index + 1);
3521 }
3522 if (numTurns <= 0) {
3523 return;
3524 }
3525 try {
3526 const conn = await this._ensureConnection();
3527 await conn.client.request<'thread/rollback'>('thread/rollback', { threadId: read.thread.id, numTurns });
3528 } catch (err) {
3529 this._logService.warn(`[Codex:${read.thread.id}] thread/rollback failed: ${err instanceof Error ? err.message : String(err)}`);
3530 }
3531 }
3533 > async onArchivedChanged(sessionUri: URI, isArchived: boolean): Promise<void> {
3534 const threadId = await this._resolveThreadId(sessionUri);
3535 if (threadId === undefined) {
3536 return;
3537 }
3538 const conn = this._connection;
3539 if (conn.kind !== 'ready') {
3540 return;
3541 }
3542 try {
3543 if (isArchived) {
3544 await conn.client.request<'thread/archive'>('thread/archive', { threadId });
3545 } else {
3546 await conn.client.request<'thread/unarchive'>('thread/unarchive', { threadId });
3547 }
3548 } catch (err) {
3549 this._logService.warn(`[Codex:${threadId}] thread/${isArchived ? 'archive' : 'unarchive'} failed: ${err instanceof Error ? err.message : String(err)}`);
3550 }
3551 }
3553 > /** Resolve the codex thread id for a session: in-memory → persisted overlay. */
3554 > private async _resolveThreadId(sessionUri: URI): Promise<string | undefined> {
3555 const existing = this._sessions.get(AgentSession.id(sessionUri));
3556 if (existing?.threadId !== undefined) {
3557 return existing.threadId;
3558 }
3559 const overlay = await this._metadataStore.read(sessionUri);
3560 return overlay.threadId;
3561 }
3563 > respondToPermissionRequest(requestId: string, approved: boolean): void {
3564 // `requestId` is the host-side toolCallId; iterate sessions (including
3565 // live subagent child sessions, whose command approvals live on their
3566 // own registry) and resolve the first match. Mirrors Claude/Copilot.
3567 const sessions = [
3568 ...this._sessions.values(),
3569 ...[...this._subagentsByThreadId.values()].map(s => s.session),
3570 ];
3571 for (const session of sessions) {
3572 if (session.pendingCommandApprovals.respond(requestId, approved ? 'accept' : 'decline')) {
3573 if (!approved) {
3574 // Remember the decline so the tool's `item/completed` (which
3575 // codex reports as a generic failure) maps to `userCancelled`.
3576 session.mapState.declinedToolCalls.add(requestId);
3577 }
3578 return;
3579 }
3580 }
3581 this._logService.info(`[Codex] respondToPermissionRequest: unknown requestId=${requestId}`);
3582 }
3584 > respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record<string, ChatInputAnswer>): void {
3585 // `requestId` was minted per request; find the owning session and
3586 // resolve its parked deferred. Mirrors respondToPermissionRequest.
3587 for (const session of this._sessions.values()) {
3588 if (session.pendingUserInputs.respond(requestId, { response, answers })) {
3589 return;
3590 }
3591 }
3592 this._logService.info(`[Codex] respondToUserInputRequest: unknown requestId=${requestId}`);
3593 }
3595 > getSessionMessages(chat: URI): Promise<readonly Turn[]> {
3596 return this._readSession(this._sessionUriFromChat(chat)).then(read => read ? replayThreadToTurns(read.thread) : []);
3597 }
3599 > async getSessionMetadata(session: URI): Promise<IAgentSessionMetadata | undefined> {
3600 const sessionId = AgentSession.id(session);
3601 const read = await this._readSession(session);
3602 if (!read) {
3603 return undefined;
3604 }
3605 // Register the session in our map so subsequent sendMessage triggers
3606 // thread/resume (Decision 8). The threadId came from the metadata
3607 // overlay or from `thread/list` (when the session was materialized
3608 // in a prior process); `_readSession` returns the resolved id.
3609 if (!this._sessions.has(sessionId)) {
3610 const workingDirectory = read.thread.cwd ? URI.file(read.thread.cwd) : undefined;
3611 const threadId = read.thread.id;
3612 const restored = this._createResumedSessionEntry(sessionId, threadId, session, workingDirectory, undefined);
3613 this._sessions.set(sessionId, restored);
3614 this._sessionIdByThreadId.set(threadId, sessionId);
3615 if (!isCodexThreadProviderCompatible(this._usageSource, read.thread.modelProvider)) {
3616 this._resetSessionForUsageSourceChange(restored, this._usageSource);
3617 }
3618 // Compatible restored threads skip materialization because the thread
3619 // already exists. Incompatible ones rematerialize on the next send.
3620 // Either way, advertise server tools now for client-side parity.
3621 if (!restored.serverToolsAdvertised && this._serverToolHost) {
3622 restored.serverToolsAdvertised = true;
3623 this._serverToolHost.advertise(restored.sessionUri.toString());
3624 }
3625 }
3626 return this._threadToMetadata(read.thread, session);
3627 }
3629 > private async _readSession(session: URI): Promise<ThreadReadResponse | undefined> {
3630 // Resolve the codex thread id for this session URI. Resolution
3631 // order: in-memory session → persisted metadata overlay → URI host
3632 // (for sessions materialized in a prior process where sessionId
3633 // equals threadId by convention).
3634 const sessionId = AgentSession.id(session);
3635 const existing = this._sessions.get(sessionId);
3636 let threadId = existing?.threadId;
3637 if (threadId === undefined) {
3638 const overlay = await this._metadataStore.read(session);
3639 threadId = overlay.threadId ?? sessionId;
3640 }
3641 try {
3642 const conn = await this._ensureConnection();
3643 const response = await conn.client.request<'thread/read', ThreadReadResponse>('thread/read', {
3644 threadId,
3645 includeTurns: true,
3646 });
3647 return response;
3648 } catch (err) {
3649 const message = err instanceof Error ? err.message : String(err);
3650 // `thread not loaded` is app-server's expected response for any
3651 // thread we have not yet resumed in this process; sendMessage's
3652 // `thread/resume` path will handle it. Log at info level.
3653 if (/thread not loaded/i.test(message)) {
3654 this._logService.info(`[Codex:${threadId}] thread/read: not loaded yet (will resume on first send)`);
3655 } else {
3656 this._logService.warn(`[Codex:${threadId}] thread/read failed: ${message}`);
3657 }
3658 return undefined;
3659 }
3660 }
3662 > async listSessions(): Promise<IAgentSessionMetadata[]> {
3663 if (!this._githubToken) {
3664 return [];
3665 }
3666 // Don't connect (and trigger a cold SDK download) just to list threads
3667 // at startup. When the SDK isn't local yet, surface an empty list; the
3668 // download fires (with host-level progress) once the user starts a
3669 // session, and the next `listSessions` — driven by the renderer's
3670 // post-turn refresh — returns the full list.
3671 if (!(await this._agentSdkDownloader.isSdkResolvableWithoutDownload(CodexSdkPackage))) {
3672 this._logService.info('[Codex] SDK not downloaded yet; deferring thread/list until a session triggers the download');
3673 return [];
3674 }
3675 try {
3676 const conn = await this._ensureConnection();
3677 const response = await conn.client.request<'thread/list', ThreadListResponse>('thread/list', {
3678 limit: 200,
3679 });
3680 // Map persisted threads back to the URI the workbench already
3681 // knows them by. After `_materializeIfNeeded` runs, the codex
3682 // thread is persisted to disk under its thread id but the
3683 // workbench/state-manager keyed the session by its provisional
3684 // URI (`codex:/<provisional-uuid>`). If we returned a fresh
3685 // `codex:/<threadId>` URI here, `_refreshSessions` would treat
3686 // the provisional URI as missing and evict the live session
3687 // the user is actively viewing.
3688 const liveUriByThreadId = new Map<string, URI>();
3689 for (const s of this._sessions.values()) {
3690 if (s.threadId !== undefined) {
3691 liveUriByThreadId.set(s.threadId, s.sessionUri);
3692 }
3693 }
3694 return response.data.map(t => this._threadToMetadata(
3695 t,
3696 liveUriByThreadId.get(t.id) ?? AgentSession.uri(this.id, t.id),
3697 ));
3698 } catch (err) {
3699 this._logService.warn(`[Codex] thread/list failed: ${err instanceof Error ? err.message : String(err)}`);
3700 return [];
3701 }
3702 }
3704 > private _threadToMetadata(thread: Thread, sessionUri: URI): IAgentSessionMetadata {
3705 return {
3706 session: sessionUri,
3707 // Codex returns Unix seconds; the agent host expects ms.
3708 startTime: (thread.createdAt ?? 0) * 1000,
3709 modifiedTime: (thread.updatedAt ?? thread.createdAt ?? 0) * 1000,
3710 summary: thread.name ?? thread.preview ?? undefined,
3711 workingDirectory: thread.cwd ? URI.file(thread.cwd) : undefined,
3712 };
3713 }
3715 > setServerToolHost(host: IAgentServerToolHost): void {
3716 this._serverToolHost = host;
3717 }
3719 > getOrCreateActiveClient(session: URI, client: { readonly clientId: string; readonly displayName?: string }): IActiveClient {
3720 const sessionId = AgentSession.id(session);
3721 return new CodexActiveClientHandle(
3722 () => this._sessions.get(sessionId),
3723 client.clientId,
3724 client.displayName,
3725 tools => this._logService.info(`[Codex:${sessionId}] active client ${client.clientId} tools=[${tools.map(t => t.name).join(', ') || '(none)'}]`),
3726 customizations => { void this._syncClientCustomizations(session, client.clientId, [...customizations]); },
3727 );
3728 }
3730 > removeActiveClient(session: URI, clientId: string): void {
3731 const sessionId = AgentSession.id(session);
3732 const sess = this._sessions.get(sessionId);
3733 sess?.clientToolSet.delete(clientId);
3734 if (sess?.clientCustomizations.removeClient(clientId)) {
3735 // A departing client's skills may drop out of the process-global union.
3736 void this._refreshSkillExtraRoots();
3737 }
3738 }
3740 > onClientToolCallComplete(session: URI, _chat: URI, toolCallId: string, result: ToolCallResult): void {
3741 const sessionId = AgentSession.id(session);
3742 const sess = this._sessions.get(sessionId);
3743 // `AgentSideEffects` forwards every `ChatToolCallComplete` envelope
3744 // (including codex-owned tools like shell); a miss is the expected path.
3745 sess?.pendingClientToolCalls.respondOrBuffer(toolCallId, result);
3746 }
3748 > // ---- Client-pushed plugin customizations -------------------------------
3749 >
3750 > /**
3751 > * Materialize + parse a client's pushed plugin customizations and store
3752 > * them on the session. Mirrors the Claude client-plugin path: the shared
3753 > * {@link IAgentPluginManager} copies each plugin to local disk (nonce
3754 > * cached), we parse the resulting directory into its
3755 > * {@link IParsedPlugin | components}, publish the customization surface,
3756 > * and refresh the process-global skill roots. MCP servers are attached
3757 > * per-thread at the next {@link _materialize}.
3758 > */
3759 > private async _syncClientCustomizations(sessionUri: URI, clientId: string, customizations: readonly ClientPluginCustomization[]): Promise<void> {
3760 const session = this._sessions.get(AgentSession.id(sessionUri));
3761 if (!session) {
3762 return;
3763 }
3764 const synced = await this._pluginManager.syncCustomizations(
3765 clientId,
3766 [...customizations],
3767 status => this._fire(sessionUri, { type: ActionType.SessionCustomizationUpdated, customization: status }),
3768 );
3769 if (session.disposed) {
3770 return;
3771 }
3772 const plugins = await Promise.all(synced.map(item => this._parseClientPlugin(session, item)));
3773 if (session.disposed) {
3774 return;
3775 }
3776 session.clientCustomizations.setClient(clientId, plugins);
3777 this._publishClientCustomizations(session);
3778 await this._refreshSkillExtraRoots();
3779 }
3781 > /** Parse one synced plugin directory into its components (best-effort). */
3782 > private async _parseClientPlugin(session: ICodexSession, synced: ISyncedCustomization): Promise<ICodexClientPlugin> {
3783 if (!synced.pluginDir) {
3784 return { synced, parsed: undefined };
3785 }
3786 try {
3787 const parsed = await parsePlugin(synced.pluginDir, this._fileService, session.workingDirectory, this._environmentService.userHome, synced.pluginDir);
3788 return { synced, parsed };
3789 } catch (err) {
3790 this._logService.warn(`[Codex] failed to parse client plugin ${synced.customization.uri}: ${err instanceof Error ? err.message : String(err)}`);
3791 return { synced, parsed: undefined };
3792 }
3793 }
3795 > /** Publish the session's client-plugin customizations as upsert actions. */
3796 > private _publishClientCustomizations(session: ICodexSession): void {
3797 for (const customization of session.clientCustomizations.toCustomizations()) {
3798 this._fire(session.sessionUri, { type: ActionType.SessionCustomizationUpdated, customization });
3799 }
3800 }
3802 > /**
3803 > * Recompute the process-global skill roots from every live session's
3804 > * enabled client plugins and push them to codex via `skills/extraRoots/set`.
3805 > * codex's extra skill roots are a single shared list (there is no per-thread
3806 > * equivalent), so we send the union across all sessions — which matches the
3807 > * global nature of client plugin choices. No-op when the connection is not
3808 > * ready; the next {@link _materialize} re-applies.
3809 > */
3810 > private async _refreshSkillExtraRoots(): Promise<void> {
3811 if (this._connection.kind !== 'ready') {
3812 return;
3813 }
3814 const plugins: ICodexClientPlugin[] = [];
3815 for (const session of this._sessions.values()) {
3816 if (!session.disposed) {
3817 plugins.push(...session.clientCustomizations.enabledPlugins());
3818 }
3819 }
3820 const roots = codexSkillRootsFromPlugins(plugins);
3821 try {
3822 await this._connection.client.request<'skills/extraRoots/set'>('skills/extraRoots/set', { extraRoots: roots });
3823 if (roots.length > 0) {
3824 this._logService.info(`[Codex] applied ${roots.length} client-plugin skill root(s)`);
3825 }
3826 } catch (err) {
3827 this._logService.warn(`[Codex] skills/extraRoots/set failed: ${err instanceof Error ? err.message : String(err)}`);
3828 }
3829 }
3831 > // ---- MCP servers -------------------------------------------------------
3832 >
3833 > /**
3834 > * Surfaces codex's MCP servers to AHP clients as per-session
3835 > * customizations. Codex has no plugin/directory customization layer, so
3836 > * every server is a bare top-level {@link McpServerCustomization}. The
3837 > * returned snapshot reflects the current connection-global inventory;
3838 > * subsequent lifecycle transitions arrive as customization actions
3839 > * emitted by the session's {@link McpCustomizationController}.
3840 > */
3841 > async getSessionCustomizations(sessionUri: URI): Promise<readonly Customization[]> {
3842 const session = this._sessions.get(AgentSession.id(sessionUri));
3843 if (!session) {
3844 return [];
3845 }
3846 const controller = this._getOrCreateMcpController(session);
3847 controller.applyAll(inventoryToSdkServers(this._mcpInventory));
3848 this._refreshMcpCustomizationIds(session, controller);
3849 // Append the skills/hooks codex loaded for this session's working
3850 // directory (best-effort; empty until the app-server connection is
3851 // ready, after which `_refreshSkillHookCustomizations` pushes updates).
3852 const skillHookContainers = await this._fetchSkillHookContainers(session);
3853 // Client-pushed ("Open Plugin") customizations first (they carry the
3854 // user's enablement overlay), then codex's discovered MCP servers and
3855 // the `.agents`/`.codex` skills/hooks.
3856 return [
3857 ...session.clientCustomizations.toCustomizations(),
3858 ...controller.topLevelCustomizations(),
3859 ...skillHookContainers,
3860 ];
3861 }
3863 > /**
3864 > * Fetches the skills and hooks codex has loaded for `session`'s working
3865 > * directory (`skills/list` + `hooks/list`, both cwd-scoped) and projects
3866 > * them into {@link DirectoryCustomization} containers. Best-effort: returns
3867 > * an empty array when no connection is ready, no working directory is known,
3868 > * or the app-server rejects the request.
3869 > */
3870 > private async _fetchSkillHookContainers(session: ICodexSession): Promise<DirectoryCustomization[]> {
3871 if (this._connection.kind !== 'ready' || !session.workingDirectory) {
3872 return [];
3873 }
3874 const cwd = session.workingDirectory.fsPath;
3875 const client = this._connection.client;
3876 const [skills, hooks] = await Promise.all([
3877 client.request<'skills/list', SkillsListResponse>('skills/list', { cwds: [cwd] })
3878 .catch(err => { this._logService.warn(`[Codex] skills/list failed: ${err instanceof Error ? err.message : String(err)}`); return undefined; }),
3879 client.request<'hooks/list', HooksListResponse>('hooks/list', { cwds: [cwd] })
3880 .catch(err => { this._logService.warn(`[Codex] hooks/list failed: ${err instanceof Error ? err.message : String(err)}`); return undefined; }),
3881 ]);
3882 return [...codexSkillsToContainers(skills), ...codexHooksToContainers(hooks)];
3883 }
3885 > /**
3886 > * Re-fetches this session's skill/hook customizations and upserts each
3887 > * container into session state via {@link ActionType.SessionCustomizationUpdated}.
3888 > * Called after materialization (when the connection is ready and the cwd is
3889 > * known) so the workbench Customizations surface reflects what codex loaded
3890 > * from the working directory's `.agents`/`.codex` folders. Upserts (keyed by
3891 > * customization id) leave the MCP customizations untouched.
3892 > */
3893 > private async _refreshSkillHookCustomizations(session: ICodexSession): Promise<void> {
3894 if (session.disposed) {
3895 return;
3896 }
3897 const containers = await this._fetchSkillHookContainers(session);
3898 if (session.disposed) {
3899 return;
3900 }
3901 for (const container of containers) {
3902 this._fire(session.sessionUri, { type: ActionType.SessionCustomizationUpdated, customization: container });
3903 }
3904 }
3906 > /**
3907 > * Routes an MCP request received on this session's `mcp://` side channel
3908 > * to codex. Read-only methods (`tools/list`, `resources/list`,
3909 > * `resources/templates/list`) are answered from the cached inventory;
3910 > * `tools/call` and `resources/read` round-trip to the app-server with the
3911 > * session's thread id. Unknown servers / methods reject with
3912 > * `Method not found` so the protocol server maps them to JSON-RPC
3913 > * `-32601`.
3914 > */
3915 > async handleMcpRequest(sessionUri: URI, serverName: string, method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
3916 const sessionId = AgentSession.id(sessionUri);
3917 const session = this._sessions.get(sessionId);
3918 if (!session) {
3919 throw new Error(`Method not found: no active session ${sessionId}`);
3920 }
3921 const entry = this._mcpInventory.get(serverName);
3922 if (!entry) {
3923 throw new Error(`Method not found: unknown MCP server '${serverName}'`);
3924 }
3925 const read = buildCodexMcpReadResult(method, entry);
3926 if (read.handled) {
3927 return read.result;
3928 }
3929 switch (method) {
3930 case 'tools/call': {
3931 const tool = params && typeof params['name'] === 'string' ? params['name'] : undefined;
3932 if (!tool) {
3933 throw new Error(`tools/call missing 'name' parameter`);
3934 }
3935 const threadId = await this._ensureThreadId(session);
3936 const conn = await this._ensureConnection();
3937 return conn.client.request<'mcpServer/tool/call', McpServerToolCallResponse>('mcpServer/tool/call', {
3938 threadId,
3939 server: serverName,
3940 tool,
3941 arguments: (params ? params['arguments'] : undefined) as JsonValue,
3942 });
3943 }
3944 case 'resources/read': {
3945 const uri = params && typeof params['uri'] === 'string' ? params['uri'] : undefined;
3946 if (!uri) {
3947 throw new Error(`resources/read missing 'uri' parameter`);
3948 }
3949 const threadId = await this._ensureThreadId(session);
3950 const conn = await this._ensureConnection();
3951 return conn.client.request<'mcpServer/resource/read', McpResourceReadResponse>('mcpServer/resource/read', {
3952 threadId,
3953 server: serverName,
3954 uri,
3955 });
3956 }
3957 default:
3958 throw new Error(`Method not found: ${method}`);
3959 }
3960 }
3962 > async startMcpServer(sessionUri: URI, id: string): Promise<void> {
3963 const session = this._sessions.get(AgentSession.id(sessionUri));
3964 const serverName = session ? this._resolveMcpServerName(session, id) : undefined;
3965 if (!session || !serverName) {
3966 this._logService.warn(`[Codex] Cannot start unknown MCP server customization ${id}`);
3967 return;
3968 }
3969 const conn = await this._ensureConnection();
3970 await conn.client.request<'config/mcpServer/reload'>('config/mcpServer/reload', undefined);
3971 await this._refreshMcpInventory(conn.client);
3972 }
3974 > async stopMcpServer(sessionUri: URI, id: string): Promise<void> {
3975 const session = this._sessions.get(AgentSession.id(sessionUri));
3976 const serverName = session ? this._resolveMcpServerName(session, id) : undefined;
3977 if (!session || !serverName) {
3978 this._logService.warn(`[Codex] Cannot stop unknown MCP server customization ${id}`);
3979 return;
3980 }
3981 // TODO: Wire this when Codex exposes a typed MCP server stop request.
3982 }
3984 > private _resolveMcpServerName(session: ICodexSession, id: string): string | undefined {
3985 const controller = this._getOrCreateMcpController(session);
3986 controller.applyAll(inventoryToSdkServers(this._mcpInventory));
3987 this._refreshMcpCustomizationIds(session, controller);
3988 return controller.serverNameForCustomizationId(id);
3989 }
3991 > /**
3992 > * Lazily create the per-session {@link McpCustomizationController}. Not
3993 > * registered on the agent (sessions come and go) — disposed explicitly
3994 > * when the session is removed.
3995 > */
3996 > private _getOrCreateMcpController(session: ICodexSession): McpCustomizationController {
3997 if (!session.mcpController) {
3998 session.mcpController = this._instantiationService.createInstance(McpCustomizationController, {
3999 providerId: this.id,
4000 sessionId: session.sessionId,
4001 sessionUri: session.sessionUri,
4002 resolveChildId: () => undefined,
4003 emit: action => this._fire(session.sessionUri, action),
4004 capabilities: CODEX_MCP_APP_CAPABILITIES,
4005 });
4006 }
4007 return session.mcpController;
4008 }
4010 > /** Mirrors the connection-global inventory onto every live session. */
4011 > private _applyMcpInventoryToSessions(): void {
4012 const servers = inventoryToSdkServers(this._mcpInventory);
4013 for (const session of this._sessions.values()) {
4014 if (session.disposed) {
4015 continue;
4016 }
4017 const controller = this._getOrCreateMcpController(session);
4018 controller.applyAll(servers);
4019 this._refreshMcpCustomizationIds(session, controller);
4020 }
4021 }
4023 > /**
4024 > * Refreshes the session's mapper snapshot of server name → customization id
4025 > * (read when stamping the MCP contributor on tool calls). Plain data, owned
4026 > * here — the mapper never reaches back into the controller. Must run on every
4027 > * inventory change because MCP servers are discovered asynchronously, after a
4028 > * session (and possibly its first tool call) already exists.
4029 > */
4030 > private _refreshMcpCustomizationIds(session: ICodexSession, controller: McpCustomizationController): void {
4031 const ids = session.mapState.mcpCustomizationIds;
4032 ids.clear();
4033 for (const serverName of this._mcpInventory.keys()) {
4034 const id = controller.customizationIdForServer(serverName);
4035 if (id !== undefined) {
4036 ids.set(serverName, id);
4037 }
4038 }
4039 }
4041 > /**
4042 > * Re-reads the full MCP inventory from the app-server (paginated) and
4043 > * re-publishes it to every session. Fires `notifications/tools/list_changed`
4044 > * on each ready channel whose tool set changed.
4045 > */
4046 > private async _refreshMcpInventory(client: ICodexAppServerClient): Promise<void> {
4047 let data: ListMcpServerStatusResponse['data'] = [];
4048 try {
4049 let cursor: string | null | undefined = null;
4050 do {
4051 const response: ListMcpServerStatusResponse = await client.request<'mcpServerStatus/list', ListMcpServerStatusResponse>('mcpServerStatus/list', { cursor, detail: 'full' });
4052 data = data.concat(response.data);
4053 cursor = response.nextCursor;
4054 } while (cursor);
4055 } catch (err) {
4056 this._logService.warn(`[Codex] Failed to list MCP servers: ${err instanceof Error ? err.message : String(err)}`);
4057 return;
4058 }
4059 // Drop the result if the connection was replaced while we were listing.
4060 if (this._connection.kind === 'ready' && this._connection.client !== client) {
4061 return;
4062 }
4063 const next = codexMcpListToInventory(data);
4064 const toolsChanged: string[] = [];
4065 for (const [name, entry] of next) {
4066 const prev = this._mcpInventory.get(name);
4067 if (prev && codexMcpToolsChanged(prev, entry)) {
4068 toolsChanged.push(name);
4069 }
4070 }
4071 for (const [name, entry] of this._mcpInventory) {
4072 if (!next.has(name) && entry.state.kind !== McpServerStatus.Ready) {
4073 next.set(name, entry);
4074 }
4075 }
4076 this._mcpInventory.clear();
4077 for (const [name, entry] of next) {
4078 this._mcpInventory.set(name, entry);
4079 }
4080 this._logService.info(`[Codex] MCP inventory refreshed: ${this._mcpInventory.size === 0 ? '(none)' : [...this._mcpInventory].map(([name, entry]) => `${name} [${entry.state.kind}, ${entry.tools.length} tool(s)]`).join(', ')}`);
4081 this._applyMcpInventoryToSessions();
4082 for (const name of toolsChanged) {
4083 this._fireMcpToolsListChanged(name);
4084 }
4085 }
4087 > /**
4088 > * Handles a `mcpServer/startupStatus/updated` notification. `ready`
4089 > * triggers a full inventory refresh (to pull the now-loaded tools);
4090 > * other transitions update the cached state in place so the UI sees the
4091 > * server settle into starting/error/stopped promptly.
4092 > */
4093 > private _handleMcpStartupStatus(client: ICodexAppServerClient, name: string, status: McpServerStartupState, error: string | null): void {
4094 if (this._connection.kind === 'ready' && this._connection.client !== client) {
4095 return;
4096 }
4097 this._logService.info(`[Codex] MCP server '${name}' startup status: ${status}${error ? ` (${error})` : ''}`);
4098 if (status === 'ready') {
4099 void this._refreshMcpInventory(client);
4100 return;
4101 }
4102 // An auth-gated http server whose sign-in we can drive: discover its
4103 // OAuth metadata asynchronously (codex's failure notification omits it)
4104 // and then surface `AuthRequired`. The server stays in its current
4105 // (starting) state until discovery resolves.
4106 if (status === 'failed' && codexStartupErrorNeedsAuth(error)) {
4107 const url = this._mcpServerUrlForName(name);
4108 const normalized = url !== undefined ? normalizeCodexMcpResourceUrl(url) : undefined;
4109 if (url !== undefined && normalized !== undefined) {
4110 // A token we already injected was rejected (expired/revoked/
4111 // insufficient scopes). Drop it so the user is re-prompted
4112 // instead of getting stuck on a terminal error with no way to
4113 // re-authenticate.
4114 if (this._mcpAuthTokens.delete(normalized)) {
4115 this._logService.info(`[Codex] MCP server '${name}' rejected the stored token; clearing it to allow re-authentication`);
4116 }
4117 void this._surfaceMcpAuthRequired(client, name, url, error);
4118 return;
4119 }
4120 }
4121 this._setMcpServerState(name, translateCodexMcpStartupState(status, error));
4122 }
4124 > /** Upserts a server's lifecycle state in the inventory (preserving cached tools) and republishes. */
4125 > private _setMcpServerState(name: string, state: McpServerState): void {
4126 const prev = this._mcpInventory.get(name);
4127 this._mcpInventory.set(name, {
4128 state,
4129 tools: prev?.tools ?? [],
4130 resources: prev?.resources ?? [],
4131 resourceTemplates: prev?.resourceTemplates ?? [],
4132 });
4133 this._applyMcpInventoryToSessions();
4134 }
4136 > /**
4137 > * Surfaces an auth-gated http MCP server as {@link McpServerStatus.AuthRequired}
4138 > * so the workbench runs the *same* OAuth sign-in it uses for the Copilot
4139 > * agent. codex's `failed` notification carries no RFC 9728 metadata, and the
4140 > * workbench's `resolveMcpServerAuthentication` needs the resource's
4141 > * `authorization_servers` to know where to sign in — so we discover the
4142 > * Protected Resource Metadata (`<url>/.well-known/oauth-protected-resource`)
4143 > * here, mirroring the discovery the Copilot SDK does internally. On
4144 > * discovery failure we still surface `AuthRequired` with bare metadata (the
4145 > * server genuinely needs auth); the one-click sign-in just can't complete
4146 > * without the authorization server, which is logged.
4147 > */
4148 > private async _surfaceMcpAuthRequired(client: ICodexAppServerClient, name: string, url: string, error: string | null): Promise<void> {
4149 let resource: ProtectedResourceMetadata = { resource: url, resource_name: name };
4150 let requiredScopes: string[] | undefined;
4151 try {
4152 const discovered = await raceTimeout(fetchResourceMetadata(url, undefined), 15_000);
4153 if (discovered) {
4154 resource = discovered.metadata;
4155 requiredScopes = discovered.metadata.scopes_supported;
4156 this._logService.info(`[Codex] discovered OAuth metadata for MCP server '${name}': authorization_servers=[${(discovered.metadata.authorization_servers ?? []).join(', ')}]`);
4157 } else {
4158 this._logService.warn(`[Codex] timed out discovering OAuth metadata for MCP server '${name}' at ${url}; the Authenticate action may not be able to complete`);
4159 }
4160 } catch (err) {
4161 this._logService.warn(`[Codex] failed to discover OAuth metadata for MCP server '${name}' at ${url}; the Authenticate action may not be able to complete: ${err instanceof Error ? err.message : String(err)}`);
4162 }
4163 // Drop the result if the connection was replaced while discovering.
4164 if (this._connection.kind === 'ready' && this._connection.client !== client) {
4165 return;
4166 }
4167 // Record which server URL this OAuth resource unlocks: discovery can
4168 // return a `resource` that differs from the configured server URL, and
4169 // the token the workbench later pushes back is keyed by that resource.
4170 const normalizedServer = normalizeCodexMcpResourceUrl(url);
4171 const normalizedResource = normalizeCodexMcpResourceUrl(resource.resource) ?? normalizedServer;
4172 if (normalizedServer !== undefined && normalizedResource !== undefined) {
4173 const servers = this._mcpAuthServerUrlsByResource.get(normalizedResource) ?? new Set<string>();
4174 servers.add(normalizedServer);
4175 this._mcpAuthServerUrlsByResource.set(normalizedResource, servers);
4176 }
4177 this._logService.info(`[Codex] MCP server '${name}' requires authentication for ${url}`);
4178 this._setMcpServerState(name, {
4179 kind: McpServerStatus.AuthRequired,
4180 reason: McpAuthRequiredReason.Required,
4181 resource,
4182 requiredScopes: requiredScopes && requiredScopes.length > 0 ? requiredScopes : undefined,
4183 description: error ?? undefined,
4184 });
4185 }
4187 > /**
4188 > * Broadcasts `notifications/tools/list_changed` for `serverName` on every
4189 > * session whose channel for that server is currently ready. Clients
4190 > * refetch `tools/list` in response.
4191 > */
4192 > private _fireMcpToolsListChanged(serverName: string): void {
4193 for (const session of this._sessions.values()) {
4194 const channel = session.mcpController?.channelForServer(serverName);
4195 if (channel) {
4196 this._onMcpNotification.fire({ channel, method: 'notifications/tools/list_changed' });
4197 }
4198 }
4199 }
4201 > /**
4202 > * Ensures the session has a materialized codex thread and returns its id.
4203 > * MCP tool calls (`mcpServer/tool/call`) are thread-scoped, so a call
4204 > * arriving before the first turn lazily starts the thread.
4205 > */
4206 > private async _ensureThreadId(session: ICodexSession): Promise<string> {
4207 await this._materializeIfNeeded(session, false);
4208 if (session.threadId === undefined) {
4209 throw new Error(`Cannot run MCP tool: codex session ${session.sessionId} is not materialized`);
4210 }
4211 return session.threadId;
4212 }
4214 > async shutdown(): Promise<void> {
4215 this._disposeConnection();
4216 for (const s of this._sessions.values()) {
4217 s.pendingCommandApprovals.denyAll('decline');
4218 s.pendingClientToolCalls.rejectAll(new CancellationError());
4219 s.pendingUserInputs.rejectAll(new CancellationError());
4220 s.mcpController?.dispose();
4221 }
4222 this._sessions.clear();
4223 this._sessionIdByThreadId.clear();
4224 this._mcpInventory.clear();
4225 }
4227 > resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {
4228 > const values = codexSessionConfigSchema.validateOrDefault(params.config, codexSessionConfigDefaults); codexAgent.ts ×1
4229 > const schema = codexVisibleSessionConfigSchema.toProtocol();
4230 > // Preserve every value the caller previously persisted. This return
4231 > // REPLACES the stored session config on restore (see
4232 > // `AgentService._resolveCreatedSessionConfig`), so cherry-picking only
4233 > // the visible keys here would reset all the others (reasoning effort,
4234 > // personality, sandbox axes, …) back to their defaults on resume.
4235 > const resolvedValues: Record<string, unknown> = {
4236 > ...params.config,
4237 > [SessionConfigKey.Mode]: values[SessionConfigKey.Mode],
4238 > };
4239 > // Migrate the permission axes off the raw config. `validateOrDefault`
4240 > // always materializes `permissionsPreset='default'`, but blindly storing
4241 > // that would silently escalate a legacy session that persisted only the
4242 > // individual `sandboxMode`/`approvalPolicy` axes (e.g. `read-only`) —
4243 > // `resolveCodexPermissions` checks the preset first. Drop all three
4244 > // permission keys, then re-apply only the ones the migration decides are
4245 > // safe (an explicit or exactly-equivalent preset, else the raw axes).
4246 > delete resolvedValues[CodexSessionConfigKey.PermissionsPreset];
4247 > delete resolvedValues[CodexSessionConfigKey.ApprovalPolicy];
4248 > delete resolvedValues[CodexSessionConfigKey.SandboxMode];
4249 > Object.assign(resolvedValues, migrateCodexPermissionValues(params.config, {
4250 > approvalPolicy: codexSessionConfigDefaults[CodexSessionConfigKey.ApprovalPolicy],
4251 > sandboxMode: codexSessionConfigDefaults[CodexSessionConfigKey.SandboxMode],
4252 > }));
4253 > return Promise.resolve({ values: resolvedValues, schema });
4254 > }
4256 > async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> {
4257 if (params.property !== CodexSessionConfigKey.AdditionalDirectories) {
4258 return { items: [] };
4259 }
4260 const query = params.query?.trim();
4261 if (!query) {
4262 return { items: [] };
4263 }
4264 const workingDirectory = params.workingDirectory?.fsPath;
4265 const resolved = isAbsolute(query)
4266 ? query
4267 : resolve(workingDirectory ?? process.cwd(), query);
4268 const parent = query.endsWith(sep) ? resolved : dirname(resolved);
4269 const prefix = query.endsWith(sep) ? '' : basename(resolved).toLowerCase();
4270 try {
4271 const entries = await fs.promises.readdir(parent, { withFileTypes: true });
4272 return {
4273 items: entries
4274 .filter(entry => entry.isDirectory() && entry.name.toLowerCase().startsWith(prefix))
4275 .slice(0, 50)
4276 .map(entry => {
4277 const value = join(parent, entry.name);
4278 return { value, label: entry.name, description: value };
4279 }),
4280 };
4281 } catch {
4282 return { items: [] };
4283 }
4284 }
4286 > // #endregion
4287 >
4288 > private _fire(sessionUri: URI, action: SessionAction | ChatAction): void {
4289 this._onDidSessionProgress.fire({ kind: 'action', resource: isChatAction(action) ? URI.parse(buildDefaultChatUri(sessionUri)) : sessionUri, action });
4290 }
4292 > override dispose(): void {
4293 > this._disposeConnection(); codexAgent.ts ×24
4294 > for (const s of this._sessions.values()) {
4295 s.pendingCommandApprovals.denyAll('decline');
4296 s.pendingClientToolCalls.rejectAll(new CancellationError());
4297 s.pendingUserInputs.rejectAll(new CancellationError());
4298 s.mcpController?.dispose();
4299 }
4300 > for (const subagent of this._subagentsByThreadId.values()) { codexAgent.ts ×24
4301 subagent.session.pendingCommandApprovals.denyAll('decline');
4302 }
4303 > this._subagentsByThreadId.clear(); codexAgent.ts ×24
4304 > this._sessions.clear();
4305 > this._sessionIdByThreadId.clear();
4306 > this._mcpInventory.clear();
4307 > super.dispose();
4308 > }
4310 >
4311 function parseBinaryArgs(json: string | undefined): string[] {
4312 if (!json) {
4313 return [];
4314 }
4315 try {
4316 const parsed = JSON.parse(json);
4317 return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
4318 } catch {
4319 return [];
4320 }
4321 }
4323 > /**
4324 > * The suffix Codex uses for its platform `optionalDependencies` packages
4325 > * (`@openai/codex-${suffix}`). Codex's Linux binaries are statically
4326 > * musl-linked and ship under the same `linux-<arch>` package regardless of
4327 > * host libc, so this never returns a `-musl` suffix.
4328 > *
4329 > * Returns undefined for unsupported `(platform, arch)` combinations — the
4330 > * caller surfaces the error.
4331 > */
4332 > export function codexPackageSuffix(platform: NodeJS.Platform, arch: string): string | undefined {
4333 > if ((platform !== 'linux' && platform !== 'darwin' && platform !== 'win32') || codexAgent.ts ×1
4334 > (arch !== 'x64' && arch !== 'arm64')) {
4335 > return undefined; codexAgent.ts ×1
4336 > }
4337 > return `${platform}-${arch}`; codexAgent.ts ×1
4338 > }
4340 > /**
4341 > * Mirrors the triple table inside `@openai/codex/bin/codex.js` so we can spawn
4342 > * the native binary at `vendor/<triple>/bin/codex` directly without going
4343 > * through the JS shim launcher.
4344 > */
4345 > export function codexBinaryTriple(sdkTarget: string): string | undefined {
4346 > switch (sdkTarget) { codexAgent.ts ×1
4347 > case 'linux-x64': return 'x86_64-unknown-linux-musl';
4348 > case 'linux-arm64': return 'aarch64-unknown-linux-musl';
4349 > case 'darwin-x64': return 'x86_64-apple-darwin';
4350 > case 'darwin-arm64': return 'aarch64-apple-darwin';
4351 > case 'win32-x64': return 'x86_64-pc-windows-msvc';
4352 > case 'win32-arm64': return 'aarch64-pc-windows-msvc';
4353 > default: return undefined;
4354 > }
4355 > }
4357 > /**
4358 > * Locate the SDK root for the dev (running-from-source) fallback by resolving
4359 > * `@openai/codex` — a devDependency in source checkouts — out of this repo's
4360 > * `node_modules`. Returns the directory that *contains* that `node_modules`
4361 > * (i.e. the value `_startConnection` joins `node_modules/@openai/codex-<target>`
4362 > * onto), or undefined when the package can't be resolved (e.g. a built product
4363 > * where it isn't shipped). `@openai/codex` declares no `exports` map, so its
4364 > * `package.json` is resolvable.
4365 > *
4366 > * `resolvePackageJsonPath` is a seam for tests; production resolves the path
4367 > * via {@link defaultResolveCodexPackageJsonPath}.
4368 > */
4369 > export async function resolveCodexDevSdkRoot( codexAgent.ts ×3
4370 > resolvePackageJsonPath: () => string | Promise<string> = defaultResolveCodexPackageJsonPath,
4371 > ): Promise<string | undefined> {
4372 > try {
4373 > const pkgJson = await resolvePackageJsonPath();
4374 > // <root>/node_modules/@openai/codex/package.json → <root> codexAgent.ts ×1
4375 > return dirname(dirname(dirname(dirname(pkgJson))));
4376 > } catch { codexAgent.ts ×3
4377 > return undefined; codexAgent.ts ×1
4378 > }
4381 async function defaultResolveCodexPackageJsonPath(): Promise<string> {
4382 // Dynamic import of `node:module` (not a static top-level import): the
4383 // unit-test electron renderer that loads this module for
4384 // `codexPackagePaths.test` cannot fetch a static `node:module` import, so
4385 // the sibling WSL/SSH host services resolve `createRequire` the same way
4386 // for the same reason.
4387 const { createRequire } = await import('node:module');
4388 return createRequire(import.meta.url).resolve('@openai/codex/package.json');
4389 }