mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
* chore: add CLI type checking and caching to ci workflow - Added a new cache step for CLI dependencies in the GitHub Actions test workflow to improve build performance. - Included a step to install CLI dependencies using `npm ci`. - Updated the `ci:check-all` script in `package.json` to include CLI type checking. - Added a `cli:typecheck` script to handle type checking within the CLI directory. * Fix type and import issues for cli * Includes CI tests in test workflow * use npx npm-run-all * update ci:check-all * ci: skip npm ci steps on cache hit in test workflow Update the test workflow to conditionally run npm installation steps only when a cache hit is not found. This optimization reduces CI execution time by avoiding redundant dependency installations when the node_modules are already restored from cache. * ci: update cache keys and add dependency verification in test workflow Updated the cache keys for root, webview-ui, cli, and testing-platform dependencies by adding a version prefix (v1). This ensures a clean cache state and helps avoid potential corruption or mismatch issues. Additionally, added a verification step in the test job to log cache hit status and check for the presence of key dependencies like biome and globby. This helps diagnose issues where the cache might be restored but dependencies are not correctly available for subsequent steps. * update Verify and fix root dependencies * fix type check script * add isSettingsKey check * update settingskey set * apply feedback * npx * feat: flashing dot for streaming chat messages in CI (#9054) Introduce an ink-spinner to the DotRow component to provide visual feedback when messages are being streamed. This improves the CLI user experience by clearly indicating that a tool call or message is currently in progress. - Add `flashing` prop to `DotRow` component - Replace static dot with `toggle8` spinner when `flashing` is true - Update `ChatMessage` to pass `flashing` state based on `isStreaming` and `partial` message properties Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> * ci: simplify dependency caching using built-in npm cache Replace manual actions/cache steps with setup-node's built-in npm caching feature across all workflow jobs. This change: - Removes redundant cache action steps for root, webview-ui, cli, and testing-platform dependencies - Uses setup-node's native `cache: 'npm'` option with `cache-dependency-path` to handle multiple package-lock.json files - Eliminates conditional installation steps based on cache hits - Reduces workflow complexity and maintenance overhead while maintaining caching functionality The built-in caching provides the same performance benefits with less configuration and better integration with the Node.js setup action. --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
1237 lines
43 KiB
TypeScript
1237 lines
43 KiB
TypeScript
/**
|
|
* ClineAgent - Decoupled ACP Agent implementation for Cline CLI.
|
|
*
|
|
* This class implements the ACP (Agent Client Protocol) Agent interface,
|
|
* allowing Cline to be used programmatically without stdio dependency.
|
|
* It uses a callback pattern for permission requests and EventEmitters
|
|
* for session updates, enabling embedding in other Node.js applications.
|
|
*
|
|
* For stdio-based ACP communication, use the AcpAgent wrapper class.
|
|
*
|
|
* @module acp
|
|
*/
|
|
|
|
import type * as acp from "@agentclientprotocol/sdk"
|
|
import { PROTOCOL_VERSION, RequestError } from "@agentclientprotocol/sdk"
|
|
import type { ClineMessageChange } from "@core/task/message-state"
|
|
import type { ApiProvider } from "@shared/api"
|
|
import {
|
|
anthropicDefaultModelId,
|
|
anthropicModels,
|
|
bedrockDefaultModelId,
|
|
bedrockModels,
|
|
deepSeekDefaultModelId,
|
|
deepSeekModels,
|
|
geminiDefaultModelId,
|
|
geminiModels,
|
|
groqDefaultModelId,
|
|
groqModels,
|
|
mistralDefaultModelId,
|
|
mistralModels,
|
|
openAiCodexDefaultModelId,
|
|
openAiNativeDefaultModelId,
|
|
openAiNativeModels,
|
|
xaiDefaultModelId,
|
|
xaiModels,
|
|
} from "@shared/api"
|
|
import type { ClineAsk, ClineMessage as ClineMessageType } from "@shared/ExtensionMessage"
|
|
import { CLI_ONLY_COMMANDS, VSCODE_ONLY_COMMANDS } from "@shared/slashCommands"
|
|
import { ProviderToApiKeyMap } from "@shared/storage"
|
|
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
|
|
import { ClineEndpoint } from "@/config.js"
|
|
import { Controller } from "@/core/controller"
|
|
import { getAvailableSlashCommands } from "@/core/controller/slash/getAvailableSlashCommands"
|
|
import { StateManager } from "@/core/storage/StateManager"
|
|
import { AuthHandler } from "@/hosts/external/AuthHandler.js"
|
|
import { ExternalCommentReviewController } from "@/hosts/external/ExternalCommentReviewController.js"
|
|
import { ExternalWebviewProvider } from "@/hosts/external/ExternalWebviewProvider.js"
|
|
import { HostProvider } from "@/hosts/host-provider.js"
|
|
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
|
|
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
|
import { StandaloneTerminalManager } from "@/integrations/terminal/index.js"
|
|
import { AuthService } from "@/services/auth/AuthService.js"
|
|
import { Logger } from "@/shared/services/Logger.js"
|
|
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
|
import type { Mode } from "@/shared/storage/types"
|
|
import { openExternal } from "@/utils/env"
|
|
import { ACPDiffViewProvider } from "../acp/ACPDiffViewProvider.js"
|
|
import { ACPHostBridgeClientProvider } from "../acp/ACPHostBridgeClientProvider.js"
|
|
import { AcpTerminalManager } from "../acp/AcpTerminalManager.js"
|
|
import { fetchOpenRouterModels, usesOpenRouterModels } from "../utils/openrouter-models"
|
|
import { CliContextResult, initializeCliContext } from "../vscode-context.js"
|
|
import { ClineSessionEmitter } from "./ClineSessionEmitter.js"
|
|
import { translateMessage } from "./messageTranslator.js"
|
|
import { handlePermissionResponse } from "./permissionHandler.js"
|
|
import type { AcpSessionState, ClineAcpSession, ClineAgentOptions, PermissionHandler } from "./types.js"
|
|
|
|
// Map providers to their static model lists and defaults (copied from ModelPicker.tsx)
|
|
const providerModels: Record<string, { models: Record<string, unknown>; defaultId: string }> = {
|
|
anthropic: { models: anthropicModels, defaultId: anthropicDefaultModelId },
|
|
"openai-native": { models: openAiNativeModels, defaultId: openAiNativeDefaultModelId },
|
|
gemini: { models: geminiModels, defaultId: geminiDefaultModelId },
|
|
bedrock: { models: bedrockModels, defaultId: bedrockDefaultModelId },
|
|
deepseek: { models: deepSeekModels, defaultId: deepSeekDefaultModelId },
|
|
mistral: { models: mistralModels, defaultId: mistralDefaultModelId },
|
|
groq: { models: groqModels, defaultId: groqDefaultModelId },
|
|
xai: { models: xaiModels, defaultId: xaiDefaultModelId },
|
|
}
|
|
|
|
function hasStaticModels(provider: string): boolean {
|
|
return provider in providerModels
|
|
}
|
|
|
|
function getModelList(provider: string): string[] {
|
|
if (!hasStaticModels(provider)) return []
|
|
return Object.keys(providerModels[provider].models)
|
|
}
|
|
|
|
/**
|
|
* Cline's implementation of the ACP Agent interface.
|
|
*
|
|
* This agent bridges the ACP protocol with Cline's core Controller,
|
|
* translating ACP requests into Controller operations and emitting
|
|
* session updates via EventEmitters.
|
|
*
|
|
* This class is decoupled from the stdio connection, enabling:
|
|
* - Programmatic usage without stdio dependency
|
|
* - Running multiple concurrent sessions
|
|
* - Handling ACP events via EventEmitter pattern
|
|
*
|
|
* For stdio-based ACP communication, use the AcpAgent wrapper class.
|
|
*/
|
|
export class ClineAgent implements acp.Agent {
|
|
private readonly options: ClineAgentOptions
|
|
private readonly ctx: CliContextResult
|
|
readonly sessions: Map<string, ClineAcpSession> = new Map()
|
|
|
|
/** Runtime state for active sessions */
|
|
private readonly sessionStates: Map<string, AcpSessionState> = new Map()
|
|
|
|
/** Per-session event emitters for session updates */
|
|
private readonly sessionEmitters: Map<string, ClineSessionEmitter> = new Map()
|
|
|
|
/** Permission handler callback for requesting user permission */
|
|
private permissionHandler?: PermissionHandler
|
|
|
|
/** Client capabilities received during initialization */
|
|
private clientCapabilities?: acp.ClientCapabilities
|
|
|
|
/** Track last sent content for partial messages to compute deltas */
|
|
private partialMessageLastContent: Map<number, string> = new Map()
|
|
|
|
/** Map message timestamps to toolCallIds to avoid creating duplicate tool calls during streaming */
|
|
private messageToToolCallId: Map<number, string> = new Map()
|
|
|
|
/** Current active session ID for use by DiffViewProvider */
|
|
private currentActiveSessionId: string | undefined
|
|
|
|
/** Shared WebviewProvider instance for auth and other operations */
|
|
private webviewProvider: ReturnType<typeof HostProvider.get.prototype.createWebviewProvider> | undefined
|
|
|
|
constructor(options: ClineAgentOptions) {
|
|
this.options = options
|
|
this.ctx = initializeCliContext()
|
|
}
|
|
|
|
/**
|
|
* Set the permission handler callback.
|
|
*
|
|
* This handler is called when the agent needs permission for a tool call.
|
|
* The handler should present the request to the user and call the resolve
|
|
* callback with their response.
|
|
*
|
|
* @param handler - The permission handler callback
|
|
*/
|
|
setPermissionHandler(handler: PermissionHandler): void {
|
|
this.permissionHandler = handler
|
|
}
|
|
|
|
/**
|
|
* Get the event emitter for a session.
|
|
*
|
|
* Use this to subscribe to session events like agent_message_chunk,
|
|
* tool_call, etc.
|
|
*
|
|
* @param sessionId - The session ID
|
|
* @returns The session's event emitter
|
|
*/
|
|
emitterForSession(sessionId: string): ClineSessionEmitter {
|
|
let emitter = this.sessionEmitters.get(sessionId)
|
|
if (!emitter) {
|
|
emitter = new ClineSessionEmitter()
|
|
this.sessionEmitters.set(sessionId, emitter)
|
|
}
|
|
return emitter
|
|
}
|
|
|
|
/**
|
|
* Initialize the agent and return its capabilities.
|
|
*
|
|
* This is the first method called by the client after establishing
|
|
* the connection. The agent returns its protocol version and capabilities.
|
|
*/
|
|
async initialize(params: acp.InitializeRequest, connection?: acp.AgentSideConnection): Promise<acp.InitializeResponse> {
|
|
this.clientCapabilities = params.clientCapabilities
|
|
this.initializeHostProvider(this.clientCapabilities, connection)
|
|
await ClineEndpoint.initialize()
|
|
await StateManager.initialize(this.ctx.extensionContext)
|
|
|
|
return {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
agentCapabilities: {
|
|
loadSession: true,
|
|
promptCapabilities: {
|
|
image: true,
|
|
audio: false,
|
|
embeddedContext: true,
|
|
},
|
|
mcpCapabilities: {
|
|
http: true,
|
|
sse: false, // deprecated by MCP spec
|
|
},
|
|
},
|
|
agentInfo: {
|
|
name: "cline",
|
|
version: this.options.version,
|
|
},
|
|
authMethods: [
|
|
{
|
|
id: "cline-oauth",
|
|
name: "Sign in with Cline",
|
|
description: "Authenticate with your Cline account via browser OAuth",
|
|
},
|
|
{
|
|
id: "openai-codex-oauth",
|
|
name: "Sign in with ChatGPT",
|
|
description: "Authenticate with your ChatGPT Plus/Pro/Team subscription",
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the host provider with optional connection for ACP mode.
|
|
*
|
|
* When used with the AcpAgent wrapper, a connection is provided for
|
|
* host bridge operations. When used programmatically, connection is
|
|
* undefined and standalone providers are used.
|
|
*
|
|
* @param clientCapabilities - Client capabilities from initialization
|
|
* @param connection - Optional ACP connection for host bridge operations
|
|
*/
|
|
initializeHostProvider(clientCapabilities?: acp.ClientCapabilities, connection?: acp.AgentSideConnection): void {
|
|
const hostBridgeClientProvider = new ACPHostBridgeClientProvider(
|
|
clientCapabilities,
|
|
() => this.currentActiveSessionId,
|
|
() => this.sessions.get(this.currentActiveSessionId ?? "")?.cwd ?? process.cwd(),
|
|
this.options.version,
|
|
)
|
|
|
|
HostProvider.initialize(
|
|
() => new ExternalWebviewProvider(this.ctx.extensionContext),
|
|
() => {
|
|
if (clientCapabilities?.fs && connection) {
|
|
return new ACPDiffViewProvider(connection, clientCapabilities, () => this.currentActiveSessionId)
|
|
}
|
|
// Fallback for programmatic use
|
|
return new FileEditProvider()
|
|
},
|
|
() => new ExternalCommentReviewController(),
|
|
() => {
|
|
if (clientCapabilities?.terminal && connection) {
|
|
return new AcpTerminalManager(connection, clientCapabilities, () => this.currentActiveSessionId)
|
|
}
|
|
// Fallback for programmatic use
|
|
return new StandaloneTerminalManager()
|
|
},
|
|
hostBridgeClientProvider,
|
|
(message: string) => Logger.info(message),
|
|
async () => {
|
|
return AuthHandler.getInstance().getCallbackUrl()
|
|
},
|
|
async () => "", // get binary location not needed in ACP mode
|
|
this.ctx.EXTENSION_DIR,
|
|
this.ctx.DATA_DIR,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Create a new session.
|
|
*
|
|
* A session represents a conversation/task with the agent. The client
|
|
* provides the working directory and optionally MCP servers to use.
|
|
*/
|
|
async newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
|
// Check if authentication is required
|
|
const isAuthenticated = await this.isAuthConfigured()
|
|
if (!isAuthenticated) {
|
|
throw RequestError.authRequired()
|
|
}
|
|
|
|
const sessionId = crypto.randomUUID()
|
|
|
|
Logger.debug("[ClineAgent] newSession called:", {
|
|
sessionId,
|
|
cwd: params.cwd,
|
|
mcpServers: params.mcpServers?.length ?? 0,
|
|
})
|
|
|
|
// Create Controller for this session
|
|
const controller = new Controller(this.ctx.extensionContext)
|
|
|
|
// Create session record with all resources
|
|
const session: ClineAcpSession = {
|
|
sessionId,
|
|
cwd: params.cwd,
|
|
mode: (await controller.getStateToPostToWebview()).mode,
|
|
mcpServers: params.mcpServers ?? [],
|
|
createdAt: Date.now(),
|
|
lastActivityAt: Date.now(),
|
|
controller,
|
|
}
|
|
|
|
this.sessions.set(sessionId, session)
|
|
|
|
// Initialize session state
|
|
const sessionState: AcpSessionState = {
|
|
sessionId,
|
|
isProcessing: false,
|
|
cancelled: false,
|
|
pendingToolCalls: new Map(),
|
|
}
|
|
|
|
this.sessionStates.set(sessionId, sessionState)
|
|
|
|
// Send available slash commands to the client
|
|
// This is done asynchronously after session creation
|
|
await this.sendAvailableCommands(sessionId, controller).catch((error) => {
|
|
Logger.debug("[ClineAgent] Failed to send available commands:", error)
|
|
})
|
|
|
|
// Get current model configuration for the response
|
|
const modelState = await this.getSessionModelState(session.mode)
|
|
|
|
return {
|
|
sessionId,
|
|
modes: {
|
|
availableModes: [
|
|
{ id: "plan", name: "Plan", description: "Gather information and create a detailed plan" },
|
|
{ id: "act", name: "Act", description: "Execute actions to accomplish the task" },
|
|
],
|
|
currentModeId: session.mode,
|
|
},
|
|
models: modelState,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current model state for ACP responses.
|
|
* Returns available models and the current model ID based on the session mode.
|
|
*/
|
|
private async getSessionModelState(mode: Mode): Promise<acp.SessionModelState> {
|
|
const stateManager = StateManager.get()
|
|
|
|
// Get current provider and model for the mode
|
|
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
|
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as ApiProvider | undefined
|
|
|
|
// Use provider-specific model ID key (e.g., cline uses actModeOpenRouterModelId)
|
|
const modelKey = currentProvider ? getProviderModelIdKey(currentProvider, mode) : null
|
|
const currentModelId = modelKey ? stateManager.getGlobalSettingsKey(modelKey) : undefined
|
|
|
|
// Build the current model ID in provider/model format
|
|
const currentFullModelId =
|
|
currentProvider && currentModelId ? `${currentProvider}/${currentModelId}` : currentProvider || ""
|
|
|
|
// Get available models based on provider
|
|
let modelIds: string[] = []
|
|
|
|
if (currentProvider) {
|
|
if (usesOpenRouterModels(currentProvider)) {
|
|
// Fetch OpenRouter models (async)
|
|
modelIds = await fetchOpenRouterModels()
|
|
} else if (hasStaticModels(currentProvider)) {
|
|
// Use static model list
|
|
modelIds = getModelList(currentProvider)
|
|
}
|
|
}
|
|
|
|
// Convert to ACP ModelInfo format with provider prefix
|
|
const availableModels: acp.ModelInfo[] = modelIds.map((modelId) => ({
|
|
modelId: currentProvider ? `${currentProvider}/${modelId}` : modelId,
|
|
name: modelId,
|
|
}))
|
|
|
|
return {
|
|
currentModelId: currentFullModelId,
|
|
availableModels,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the model for a session.
|
|
*
|
|
* This method allows changing the model for either plan or act mode.
|
|
* The modelId format is "provider/modelId" (e.g., "anthropic/claude-3-5-sonnet-20241022").
|
|
*
|
|
* @experimental This is an unstable API that may change.
|
|
*/
|
|
async unstable_setSessionModel(params: acp.SetSessionModelRequest): Promise<acp.SetSessionModelResponse> {
|
|
const session = this.sessions.get(params.sessionId)
|
|
|
|
if (!session) {
|
|
throw new Error(`Session not found: ${params.sessionId}`)
|
|
}
|
|
|
|
Logger.debug("[ClineAgent] unstable_setSessionModel called:", {
|
|
sessionId: params.sessionId,
|
|
modelId: params.modelId,
|
|
})
|
|
|
|
// Parse the modelId format: "provider/modelId"
|
|
const slashIndex = params.modelId.indexOf("/")
|
|
if (slashIndex === -1) {
|
|
throw new Error(`Invalid modelId format: ${params.modelId}. Expected "provider/modelId".`)
|
|
}
|
|
|
|
const provider = params.modelId.substring(0, slashIndex) as ApiProvider
|
|
const modelId = params.modelId.substring(slashIndex + 1)
|
|
|
|
const stateManager = StateManager.get()
|
|
|
|
// Update provider for both modes
|
|
stateManager.setGlobalState("actModeApiProvider", provider)
|
|
stateManager.setGlobalState("planModeApiProvider", provider)
|
|
|
|
// Update model ID using provider-specific keys (e.g., cline uses actModeOpenRouterModelId)
|
|
const actProviderModelKey = getProviderModelIdKey(provider, "act")
|
|
if (actProviderModelKey) {
|
|
stateManager.setGlobalState(actProviderModelKey, modelId)
|
|
}
|
|
const planProviderModelKey = getProviderModelIdKey(provider, "plan")
|
|
if (planProviderModelKey) {
|
|
stateManager.setGlobalState(planProviderModelKey, modelId)
|
|
}
|
|
|
|
// Store the model override in the session for both modes
|
|
session.actModeModelId = params.modelId
|
|
session.planModeModelId = params.modelId
|
|
|
|
session.lastActivityAt = Date.now()
|
|
|
|
// Flush state changes
|
|
await stateManager.flushPendingState()
|
|
|
|
return {}
|
|
}
|
|
|
|
/**
|
|
* Handle a user prompt.
|
|
*
|
|
* This is the main entry point for user interaction. The agent
|
|
* processes the prompt and sends updates back via sessionUpdate.
|
|
*
|
|
* The prompt flow:
|
|
* 1. Extract content from the ACP prompt (text, images, files)
|
|
* 2. Set up state broadcasting (subscribe to controller updates)
|
|
* 3. Initialize or continue task with Controller
|
|
* 4. Translate ClineMessages to ACP SessionUpdates
|
|
* 5. Handle permission requests for tools/commands
|
|
* 6. Return when task completes, is cancelled, or needs user input
|
|
*/
|
|
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
|
const session = this.sessions.get(params.sessionId)
|
|
const sessionState = this.sessionStates.get(params.sessionId)
|
|
|
|
if (!session || !sessionState) {
|
|
throw new Error(`Session not found: ${params.sessionId}`)
|
|
}
|
|
|
|
if (sessionState.isProcessing) {
|
|
throw new Error(`Session ${params.sessionId} is already processing a prompt`)
|
|
}
|
|
|
|
const controller = session.controller
|
|
if (!controller) {
|
|
throw new Error("Controller not initialized for session. This is a bug in the ACP agent setup.")
|
|
}
|
|
|
|
Logger.debug("[ClineAgent] prompt called:", {
|
|
sessionId: params.sessionId,
|
|
promptLength: params.prompt.length,
|
|
})
|
|
|
|
// Mark session as processing and set as current active session
|
|
sessionState.isProcessing = true
|
|
sessionState.cancelled = false
|
|
session.lastActivityAt = Date.now()
|
|
this.currentActiveSessionId = params.sessionId
|
|
|
|
// Clear delta tracking state for new prompt cycle
|
|
this.partialMessageLastContent.clear()
|
|
this.messageToToolCallId.clear()
|
|
|
|
// Track cleanup functions for subscriptions
|
|
const cleanupFunctions: (() => void)[] = []
|
|
|
|
// Promise that resolves when task completes, is cancelled, or needs input
|
|
let resolvePrompt: (response: acp.PromptResponse) => void
|
|
let _rejectPrompt: (error: Error) => void
|
|
const promptPromise = new Promise<acp.PromptResponse>((resolve, reject) => {
|
|
resolvePrompt = resolve
|
|
_rejectPrompt = reject
|
|
})
|
|
|
|
// Track if we've already resolved/rejected (object for pass-by-reference)
|
|
const promptResolved = { value: false }
|
|
|
|
try {
|
|
// Extract text content from prompt
|
|
const textContent = params.prompt
|
|
.filter((block): block is acp.TextContent & { type: "text" } => block.type === "text")
|
|
.map((block) => block.text)
|
|
.join("\n")
|
|
|
|
// Extract image content as base64 data URLs
|
|
const imageContent = params.prompt
|
|
.filter((block): block is acp.ImageContent & { type: "image" } => block.type === "image")
|
|
.map((block) => `data:${block.mimeType || "image/png"};base64,${block.data}`)
|
|
|
|
// Extract file resources (embedded resources)
|
|
const fileResources = params.prompt
|
|
.filter((block): block is acp.EmbeddedResource & { type: "resource" } => block.type === "resource")
|
|
.map((block) => block.resource.uri)
|
|
|
|
// Determine if this is a new task, continuation, or loaded session resume
|
|
const hasActiveTask = controller.task !== undefined
|
|
const isLoadedSession = session.isLoadedFromHistory === true
|
|
|
|
if (isLoadedSession && !hasActiveTask) {
|
|
// First prompt on a loaded session - resume the task from history
|
|
Logger.debug("[ClineAgent] Resuming loaded session:", params.sessionId)
|
|
|
|
// Clear the flag so subsequent prompts are handled normally
|
|
session.isLoadedFromHistory = false
|
|
|
|
// Resume the task using its history item
|
|
await controller.reinitExistingTaskFromId(params.sessionId)
|
|
|
|
// After reinit, the task should be in a waiting state (resume_task ask)
|
|
// Send the user's prompt as a response to continue
|
|
if (controller.task) {
|
|
await controller.task.handleWebviewAskResponse("messageResponse", textContent, imageContent, fileResources)
|
|
}
|
|
} else if (hasActiveTask && controller.task) {
|
|
// Continue existing task - respond to pending ask
|
|
Logger.debug("[ClineAgent] Continuing existing task:", controller.task.taskId)
|
|
|
|
// Find the last ask message and respond to it
|
|
const messages = controller.task.messageStateHandler.getClineMessages()
|
|
const lastAskMessage = [...messages].reverse().find((m) => m.type === "ask")
|
|
|
|
if (lastAskMessage) {
|
|
await controller.task.handleWebviewAskResponse("messageResponse", textContent, imageContent, fileResources)
|
|
} else {
|
|
// No pending ask - treat as new user message
|
|
// This shouldn't normally happen but handle gracefully
|
|
Logger.debug("[ClineAgent] No pending ask found, starting new task")
|
|
await controller.initTask(textContent, imageContent, fileResources)
|
|
}
|
|
} else {
|
|
// Start new task
|
|
Logger.debug("[ClineAgent] Starting new task")
|
|
await controller.initTask(textContent, imageContent, fileResources)
|
|
}
|
|
|
|
// Subscribe to clineMessages changes after task is created
|
|
if (controller.task) {
|
|
const onClineMessagesChanged = (change: ClineMessageChange) => {
|
|
this.handleClineMessagesChanged(params.sessionId, sessionState, change, resolvePrompt, promptResolved).catch(
|
|
(error) => {
|
|
Logger.debug("[ClineAgent] Error handling clineMessagesChanged:", error)
|
|
},
|
|
)
|
|
}
|
|
|
|
controller.task.messageStateHandler.on("clineMessagesChanged", onClineMessagesChanged)
|
|
cleanupFunctions.push(() => {
|
|
controller.task?.messageStateHandler.off("clineMessagesChanged", onClineMessagesChanged)
|
|
})
|
|
}
|
|
|
|
// Return the promise that will resolve when task completes
|
|
return await promptPromise
|
|
} catch (error) {
|
|
if (!promptResolved.value) {
|
|
promptResolved.value = true
|
|
// Send error as session update before returning
|
|
await this.emitSessionUpdate(params.sessionId, {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: {
|
|
type: "text",
|
|
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
},
|
|
})
|
|
return { stopReason: "error" as acp.StopReason }
|
|
}
|
|
throw error
|
|
} finally {
|
|
// Clean up subscriptions
|
|
for (const cleanup of cleanupFunctions) {
|
|
try {
|
|
cleanup()
|
|
} catch (error) {
|
|
Logger.debug("[ClineAgent] Error during cleanup:", error)
|
|
}
|
|
}
|
|
sessionState.isProcessing = false
|
|
}
|
|
}
|
|
|
|
private async handleClineMessagesChanged(
|
|
sessionId: string,
|
|
sessionState: AcpSessionState,
|
|
change: ClineMessageChange,
|
|
resolvePrompt: (response: acp.PromptResponse) => void,
|
|
promptResolved: { value: boolean },
|
|
): Promise<void> {
|
|
Logger.debug("[ClineAgent] handleClineMessagesChanged:", change)
|
|
try {
|
|
switch (change.type) {
|
|
case "add":
|
|
// Process the newly added message
|
|
if (change.message) {
|
|
await this.processMessageWithDelta(sessionId, sessionState, change.message)
|
|
this.checkMessageForPromptResolution(change.message, resolvePrompt, promptResolved)
|
|
}
|
|
break
|
|
|
|
case "update":
|
|
// Process the updated message (streaming updates)
|
|
if (change.message) {
|
|
await this.processMessageWithDelta(sessionId, sessionState, change.message)
|
|
// Also check for prompt resolution on updates - message may have transitioned from partial to complete
|
|
this.checkMessageForPromptResolution(change.message, resolvePrompt, promptResolved)
|
|
}
|
|
break
|
|
case "set":
|
|
// Check the last message for prompt resolution
|
|
break
|
|
case "delete":
|
|
// Message deleted - no action needed for ACP updates
|
|
break
|
|
}
|
|
} catch (error) {
|
|
Logger.debug("[ClineAgent] Error handling clineMessagesChanged:", error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a permission request for an ask message.
|
|
*
|
|
* This method:
|
|
* 1. Sends the permission request to the client
|
|
* 2. Waits for the user's decision
|
|
* 3. Responds to Cline's ask based on the decision
|
|
*
|
|
* @param sessionId - The session ID
|
|
* @param sessionState - The session state
|
|
* @param message - The Cline ask message
|
|
* @param permissionRequest - The permission request details from translateMessage
|
|
*/
|
|
private async handlePermissionRequest(
|
|
sessionId: string,
|
|
sessionState: AcpSessionState,
|
|
message: ClineMessageType,
|
|
permissionRequest: Omit<acp.RequestPermissionRequest, "sessionId">,
|
|
): Promise<void> {
|
|
const session = this.sessions.get(sessionId)
|
|
const controller = session?.controller
|
|
|
|
if (!controller?.task) {
|
|
Logger.debug("[ClineAgent] No active task for permission request")
|
|
return
|
|
}
|
|
|
|
const askType = message.ask as ClineAsk
|
|
|
|
try {
|
|
// Request permission from the client
|
|
const response = await this.requestPermission(sessionId, permissionRequest.toolCall, permissionRequest.options)
|
|
|
|
Logger.debug("[ClineAgent] Permission response received:", response.outcome)
|
|
|
|
// Handle the response
|
|
const result = handlePermissionResponse(response, askType)
|
|
|
|
// Update tool call status based on permission result
|
|
if (sessionState.currentToolCallId) {
|
|
if (result.cancelled) {
|
|
await this.emitSessionUpdate(sessionId, {
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: sessionState.currentToolCallId,
|
|
status: "failed",
|
|
rawOutput: { reason: "cancelled" },
|
|
})
|
|
} else if (result.response === "noButtonClicked") {
|
|
await this.emitSessionUpdate(sessionId, {
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: sessionState.currentToolCallId,
|
|
status: "failed",
|
|
rawOutput: { reason: "rejected" },
|
|
})
|
|
} else {
|
|
// Permission granted - mark as in_progress
|
|
await this.emitSessionUpdate(sessionId, {
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: sessionState.currentToolCallId,
|
|
status: "in_progress",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Respond to Cline's ask based on the permission result
|
|
if (result.cancelled) {
|
|
// Cancellation - reject the operation
|
|
await controller.task.handleWebviewAskResponse("noButtonClicked")
|
|
} else {
|
|
// Pass the response to Cline
|
|
await controller.task.handleWebviewAskResponse(result.response, result.text)
|
|
}
|
|
} catch (error) {
|
|
Logger.debug("[ClineAgent] Error handling permission request:", error)
|
|
|
|
// Update tool call status to failed
|
|
if (sessionState.currentToolCallId) {
|
|
await this.emitSessionUpdate(sessionId, {
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: sessionState.currentToolCallId,
|
|
status: "failed",
|
|
rawOutput: { error: String(error) },
|
|
})
|
|
}
|
|
|
|
// Reject the operation on error
|
|
await controller.task.handleWebviewAskResponse("noButtonClicked")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a message should resolve the prompt (end the turn).
|
|
*/
|
|
private checkMessageForPromptResolution(
|
|
message: ClineMessageType,
|
|
resolvePrompt: (response: acp.PromptResponse) => void,
|
|
promptResolved: { value: boolean },
|
|
): void {
|
|
if (promptResolved.value) return
|
|
|
|
// Don't resolve for partial (still streaming) messages
|
|
if (message.partial) return
|
|
|
|
// Check for ask messages that require user input
|
|
if (message.type === "ask") {
|
|
const askType = message.ask as ClineAsk
|
|
if (
|
|
askType === "followup" ||
|
|
askType === "plan_mode_respond" ||
|
|
askType === "act_mode_respond" ||
|
|
askType === "completion_result" ||
|
|
askType === "resume_task" ||
|
|
askType === "resume_completed_task"
|
|
) {
|
|
promptResolved.value = true
|
|
resolvePrompt({ stopReason: "end_turn" })
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check for completion_result say message
|
|
if (message.type === "say" && message.say === "completion_result") {
|
|
promptResolved.value = true
|
|
resolvePrompt({ stopReason: "end_turn" })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a message and compute deltas for streaming content.
|
|
*
|
|
* This method uses translateMessage to properly map ClineMessages to ACP SessionUpdates,
|
|
* while computing deltas for text content to avoid sending duplicate content during
|
|
* streaming updates.
|
|
*
|
|
* For text-streaming messages (text, reasoning, followup, plan_mode_respond):
|
|
* - Computes delta between current and last-sent content
|
|
* - Only sends the new portion to avoid duplicates
|
|
*
|
|
* For other messages (tool calls, commands, etc.):
|
|
* - Uses translateMessage to produce proper ACP updates
|
|
* - Sends complete updates (no delta computation needed)
|
|
*/
|
|
private async processMessageWithDelta(
|
|
sessionId: string,
|
|
sessionState: AcpSessionState,
|
|
message: ClineMessageType,
|
|
): Promise<void> {
|
|
const messageKey = message.ts
|
|
const lastText = this.partialMessageLastContent.get(messageKey) || ""
|
|
|
|
// Determine if this is a text-streaming message type that needs delta handling
|
|
// Note: act_mode_respond is NOT included here because its text content was already
|
|
// sent via the say: "text" message. Including it would cause duplicate output.
|
|
const isTextStreamingMessage =
|
|
(message.type === "say" &&
|
|
(message.say === "text" || message.say === "reasoning" || message.say === "completion_result")) ||
|
|
(message.type === "ask" &&
|
|
(message.ask === "followup" || message.ask === "plan_mode_respond" || message.ask === "completion_result"))
|
|
|
|
if (isTextStreamingMessage && message.text) {
|
|
// Extract the actual text content for JSON-wrapped messages
|
|
// plan_mode_respond uses { response: string, options?: string[] }
|
|
// followup uses { question: string, options?: string[] }
|
|
let textContent = message.text
|
|
if (message.type === "ask" && (message.ask === "plan_mode_respond" || message.ask === "followup")) {
|
|
try {
|
|
const parsed = JSON.parse(message.text)
|
|
if (message.ask === "plan_mode_respond" && parsed.response !== undefined) {
|
|
textContent = parsed.response
|
|
} else if (message.ask === "followup" && parsed.question !== undefined) {
|
|
textContent = parsed.question
|
|
}
|
|
} catch {
|
|
// If parsing fails, use the raw text
|
|
}
|
|
}
|
|
|
|
// For streaming text messages, compute delta to avoid sending duplicates
|
|
let textDelta: string
|
|
if (textContent.startsWith(lastText)) {
|
|
textDelta = textContent.slice(lastText.length)
|
|
} else {
|
|
// Content changed entirely (rare), send all
|
|
textDelta = textContent
|
|
}
|
|
|
|
// Only send if there's new content
|
|
if (textDelta) {
|
|
// Determine the correct update type based on message type
|
|
const sessionUpdate: "agent_message_chunk" | "agent_thought_chunk" =
|
|
message.type === "say" && message.say === "reasoning" ? "agent_thought_chunk" : "agent_message_chunk"
|
|
|
|
// For completion_result messages, add a leading newline to separate from previous content
|
|
// This ensures the completion message appears on a new line after any preceding text
|
|
const isCompletionResult =
|
|
(message.type === "say" && message.say === "completion_result") ||
|
|
(message.type === "ask" && message.ask === "completion_result")
|
|
const needsNewline = isCompletionResult && lastText === ""
|
|
|
|
await this.emitSessionUpdate(sessionId, {
|
|
sessionUpdate,
|
|
content: { type: "text", text: needsNewline ? "\n" + textDelta : textDelta },
|
|
})
|
|
}
|
|
|
|
// Track what we've sent (use extracted text, not raw JSON)
|
|
this.partialMessageLastContent.set(messageKey, textContent)
|
|
} else {
|
|
// For non-streaming messages, use the full translator
|
|
// Check if we already have a toolCallId for this message (from a previous partial update)
|
|
const existingToolCallId = this.messageToToolCallId.get(messageKey)
|
|
|
|
const result = translateMessage(message, sessionState, {
|
|
existingToolCallId,
|
|
})
|
|
|
|
// Send all updates produced by the translator
|
|
for (const update of result.updates) {
|
|
await this.emitSessionUpdate(sessionId, update)
|
|
}
|
|
|
|
// Track the toolCallId for this message so subsequent updates reuse it
|
|
if (result.toolCallId) {
|
|
this.messageToToolCallId.set(messageKey, result.toolCallId)
|
|
}
|
|
|
|
// Handle permission requests for ask messages
|
|
// Only process permissions for non-partial (complete) ask messages
|
|
if (result.requiresPermission && result.permissionRequest && !message.partial) {
|
|
// Handle the permission request asynchronously
|
|
// This will request permission from the client and respond to Cline
|
|
await this.handlePermissionRequest(sessionId, sessionState, message, result.permissionRequest)
|
|
}
|
|
|
|
// Track text content for this message (in case of future updates)
|
|
if (message.text) {
|
|
this.partialMessageLastContent.set(messageKey, message.text)
|
|
}
|
|
|
|
// Clean up the mapping when the message is complete (not partial)
|
|
if (!message.partial && result.toolCallId) {
|
|
this.messageToToolCallId.delete(messageKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel the current operation in a session.
|
|
*
|
|
* This is a notification (no response expected). The agent should
|
|
* stop any ongoing processing for the specified session.
|
|
*/
|
|
async cancel(params: acp.CancelNotification): Promise<void> {
|
|
const session = this.sessions.get(params.sessionId)
|
|
const sessionState = this.sessionStates.get(params.sessionId)
|
|
|
|
Logger.debug("[ClineAgent] cancel called:", {
|
|
sessionId: params.sessionId,
|
|
isProcessing: sessionState?.isProcessing,
|
|
})
|
|
|
|
if (sessionState) {
|
|
sessionState.cancelled = true
|
|
|
|
// If we have an active controller task, cancel it
|
|
const controller = session?.controller
|
|
if (controller?.task) {
|
|
try {
|
|
await controller.cancelTask()
|
|
} catch (error) {
|
|
Logger.debug("[ClineAgent] Error cancelling task:", error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the session mode (plan/act).
|
|
*
|
|
* Cline supports two modes:
|
|
* - "plan": Gather information and create a detailed plan
|
|
* - "act": Execute actions to accomplish the task
|
|
*/
|
|
async setSessionMode(params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse> {
|
|
const session = this.sessions.get(params.sessionId)
|
|
|
|
if (!session) {
|
|
throw new Error(`Session not found: ${params.sessionId}`)
|
|
}
|
|
|
|
Logger.debug("[ClineAgent] setSessionMode called:", {
|
|
sessionId: params.sessionId,
|
|
modeId: params.modeId,
|
|
})
|
|
|
|
// Validate mode
|
|
const validModes = ["plan", "act"]
|
|
if (!validModes.includes(params.modeId)) {
|
|
throw new Error(`Invalid mode: ${params.modeId}. Valid modes are: ${validModes.join(", ")}`)
|
|
}
|
|
|
|
// Update session mode
|
|
session.mode = params.modeId as Mode
|
|
session.lastActivityAt = Date.now()
|
|
|
|
// Update Controller mode if active
|
|
const controller = session.controller
|
|
if (controller) {
|
|
controller.stateManager.setGlobalState("mode", session.mode)
|
|
|
|
// If there's an active task, switch its mode
|
|
if (controller.task) {
|
|
await controller.togglePlanActMode(session.mode)
|
|
}
|
|
}
|
|
|
|
return {}
|
|
}
|
|
|
|
/**
|
|
* Handle authentication requests.
|
|
*
|
|
* This method implements OAuth flows for:
|
|
* - Cline account authentication (cline-oauth)
|
|
* - OpenAI Codex/ChatGPT authentication (openai-codex-oauth)
|
|
*/
|
|
async authenticate(params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse> {
|
|
Logger.debug("[ClineAgent] authenticate called:", { methodId: params.methodId })
|
|
|
|
// Handle OpenAI Codex OAuth flow
|
|
if (params.methodId === "openai-codex-oauth") {
|
|
return this.authenticateOpenAiCodex()
|
|
}
|
|
|
|
if (params.methodId !== "cline-oauth") {
|
|
throw new Error(`Unknown authentication method: ${params.methodId}`)
|
|
}
|
|
|
|
// Enable the AuthHandler to receive OAuth callbacks
|
|
const authHandler = AuthHandler.getInstance()
|
|
authHandler.setEnabled(true)
|
|
|
|
Logger.debug("[ClineAgent] AuthHandler enabled, getting callback URL...")
|
|
|
|
// Get the callback URL first to ensure the server is ready
|
|
let callbackUrl: string
|
|
try {
|
|
callbackUrl = await authHandler.getCallbackUrl()
|
|
Logger.debug("[ClineAgent] Callback URL ready:", callbackUrl)
|
|
} catch (error) {
|
|
Logger.error("[ClineAgent] Failed to get callback URL:", error)
|
|
throw new Error(`Failed to start auth server: ${error instanceof Error ? error.message : String(error)}`)
|
|
}
|
|
|
|
// Ensure WebviewProvider exists (creates one if not already created)
|
|
// This is needed for SharedUriHandler to find the controller during OAuth callback
|
|
if (!this.webviewProvider) {
|
|
this.webviewProvider = HostProvider.get().createWebviewProvider()
|
|
}
|
|
|
|
try {
|
|
// Get the AuthService instance with the webview's controller
|
|
const authService = AuthService.getInstance(this.webviewProvider.controller)
|
|
|
|
Logger.debug("[ClineAgent] Starting OAuth flow...")
|
|
|
|
// Start the OAuth flow - this opens the browser
|
|
await authService.createAuthRequest()
|
|
|
|
Logger.debug("[ClineAgent] Browser opened, waiting for callback...")
|
|
|
|
// Wait for authentication to complete (with timeout)
|
|
const AUTH_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
|
const POLL_INTERVAL_MS = 1000 // 1 second
|
|
|
|
const startTime = Date.now()
|
|
|
|
while (Date.now() - startTime < AUTH_TIMEOUT_MS) {
|
|
// Check if auth data has been stored
|
|
const authData = await secretStorage.get("cline:clineAccountId")
|
|
if (authData) {
|
|
Logger.debug("[ClineAgent] Authentication successful")
|
|
|
|
// Set up the provider configuration for cline
|
|
const stateManager = StateManager.get()
|
|
stateManager.setGlobalState("actModeApiProvider", "cline")
|
|
stateManager.setGlobalState("planModeApiProvider", "cline")
|
|
await stateManager.flushPendingState()
|
|
|
|
return {}
|
|
}
|
|
|
|
// Wait before polling again
|
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
|
}
|
|
|
|
throw new Error("Authentication timed out. Please try again.")
|
|
} catch (error) {
|
|
Logger.error("[ClineAgent] Authentication error:", error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Helper Methods
|
|
// ============================================================
|
|
|
|
/**
|
|
* Emit a session update to the client.
|
|
*
|
|
* This method emits the update via the session's event emitter.
|
|
* Consumers (e.g. AcpAgent) subscribe to these events
|
|
*/
|
|
private async emitSessionUpdate(sessionId: string, update: acp.SessionUpdate): Promise<void> {
|
|
const emitter = this.emitterForSession(sessionId)
|
|
|
|
try {
|
|
emitter.emit(update.sessionUpdate, update)
|
|
} catch (error) {
|
|
Logger.debug("[ClineAgent] Error emitting session update:", error)
|
|
emitter.emit("error", error instanceof Error ? error : new Error(String(error)))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request permission from the client for a tool call.
|
|
*
|
|
* This method uses the permission handler callback to request permission.
|
|
* The handler is set by the AcpAgent wrapper or by programmatic users.
|
|
*
|
|
* @param sessionId - The session ID
|
|
* @param toolCall - The tool call update containing details about the operation
|
|
* @param options - Available permission options for the user to choose from
|
|
* @returns The permission response from the client
|
|
*/
|
|
protected async requestPermission(
|
|
_sessionId: string,
|
|
toolCall: acp.ToolCallUpdate,
|
|
options: acp.PermissionOption[],
|
|
): Promise<acp.RequestPermissionResponse> {
|
|
Logger.debug("[ClineAgent] Requesting permission:", {
|
|
toolCallId: toolCall.toolCallId,
|
|
options: options.map((o) => o.optionId),
|
|
})
|
|
|
|
if (!this.permissionHandler) {
|
|
// No permission handler set - auto-reject for safety
|
|
Logger.debug("[ClineAgent] No permission handler set, auto-rejecting")
|
|
return { outcome: "rejected" as unknown as acp.RequestPermissionOutcome }
|
|
}
|
|
|
|
// Use the permission handler callback pattern
|
|
return new Promise<acp.RequestPermissionResponse>((resolve) => {
|
|
this.permissionHandler!({ toolCall, options }, resolve)
|
|
})
|
|
}
|
|
|
|
async shutdown(): Promise<void> {
|
|
for (const [sessionId, session] of this.sessions) {
|
|
await session.controller?.task?.abortTask()
|
|
await session.controller?.stateManager.flushPendingState()
|
|
await session.controller?.dispose()
|
|
this.sessions.delete(sessionId)
|
|
this.sessionStates.delete(sessionId)
|
|
}
|
|
|
|
// Dispose the shared webview provider if it was created
|
|
if (this.webviewProvider) {
|
|
await this.webviewProvider.dispose()
|
|
this.webviewProvider = undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get available slash commands and send them to the client.
|
|
*
|
|
* This fetches commands from Cline's slash command system, filters out
|
|
* CLI-only and VS Code-only commands, and converts them to ACP format.
|
|
*/
|
|
private async sendAvailableCommands(sessionId: string, controller: Controller): Promise<void> {
|
|
try {
|
|
// Get all available commands from Cline
|
|
const response = await getAvailableSlashCommands(controller, {})
|
|
|
|
// Filter out CLI-only and VS Code-only commands
|
|
const cliOnlyNames = new Set(CLI_ONLY_COMMANDS.map((c) => c.name))
|
|
const vscodeOnlyNames = new Set(VSCODE_ONLY_COMMANDS.map((c) => c.name))
|
|
|
|
const filteredCommands = response.commands.filter(
|
|
(cmd) => cmd.cliCompatible && !cliOnlyNames.has(cmd.name) && !vscodeOnlyNames.has(cmd.name),
|
|
)
|
|
|
|
// Convert to ACP AvailableCommand format
|
|
const availableCommands: acp.AvailableCommand[] = filteredCommands.map((cmd) => ({
|
|
name: cmd.name,
|
|
description: cmd.description,
|
|
input: {
|
|
hint: cmd.description,
|
|
},
|
|
}))
|
|
|
|
// Send the available_commands_update notification
|
|
await this.emitSessionUpdate(sessionId, {
|
|
sessionUpdate: "available_commands_update",
|
|
availableCommands,
|
|
})
|
|
|
|
Logger.debug("[ClineAgent] Sent available commands:", {
|
|
sessionId,
|
|
commandCount: availableCommands.length,
|
|
commands: availableCommands.map((c) => c.name),
|
|
})
|
|
} catch (error) {
|
|
Logger.debug("[ClineAgent] Error sending available commands:", error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the user has authentication configured.
|
|
* Returns true if they have either:
|
|
* - Cline provider with stored auth data
|
|
* - OpenAI Codex provider with OAuth credentials
|
|
* - BYO provider with an API key configured
|
|
*/
|
|
private async isAuthConfigured(): Promise<boolean> {
|
|
const stateManager = StateManager.get()
|
|
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
|
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
|
const currentProvider = (stateManager.getGlobalSettingsKey(providerKey) as string) || "cline"
|
|
|
|
if (currentProvider === "cline") {
|
|
// For Cline provider, check if we have stored auth data
|
|
const values = await Promise.all(["clineApiKey", "clineAccountId"].map((key) => secretStorage.get(key)))
|
|
return values.some(Boolean)
|
|
}
|
|
|
|
// For OpenAI Codex provider, check OAuth credentials
|
|
if (currentProvider === "openai-codex") {
|
|
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
|
|
return await openAiCodexOAuthManager.isAuthenticated()
|
|
}
|
|
|
|
// For BYO providers, check if the API key is configured
|
|
const keyField = ProviderToApiKeyMap[currentProvider as keyof typeof ProviderToApiKeyMap]
|
|
if (!keyField) {
|
|
return false
|
|
}
|
|
|
|
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
|
for (const field of fields) {
|
|
const value = await secretStorage.get(field)
|
|
if (value) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Handle OpenAI Codex OAuth authentication flow.
|
|
*
|
|
* This method:
|
|
* 1. Initializes the OAuth manager
|
|
* 2. Opens the browser to OpenAI's auth page
|
|
* 3. Waits for the callback with tokens
|
|
* 4. Configures the provider on success
|
|
*/
|
|
private async authenticateOpenAiCodex(): Promise<acp.AuthenticateResponse> {
|
|
Logger.debug("[ClineAgent] Starting OpenAI Codex OAuth flow...")
|
|
|
|
try {
|
|
// Initialize the OAuth manager with extension context
|
|
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
|
|
|
|
// Get the authorization URL and start the callback server
|
|
const authUrl = openAiCodexOAuthManager.startAuthorizationFlow()
|
|
|
|
Logger.debug("[ClineAgent] Opening browser for OpenAI Codex auth:", authUrl)
|
|
|
|
// Open browser to authorization URL
|
|
await openExternal(authUrl)
|
|
|
|
// Wait for the callback (blocks until auth completes or times out)
|
|
await openAiCodexOAuthManager.waitForCallback()
|
|
|
|
Logger.debug("[ClineAgent] OpenAI Codex authentication successful")
|
|
|
|
// Success - configure the provider
|
|
const stateManager = StateManager.get()
|
|
stateManager.setGlobalState("actModeApiProvider", "openai-codex")
|
|
stateManager.setGlobalState("planModeApiProvider", "openai-codex")
|
|
// Use provider-specific model ID keys for consistency
|
|
const actModelKey = getProviderModelIdKey("openai-codex", "act")
|
|
const planModelKey = getProviderModelIdKey("openai-codex", "plan")
|
|
if (actModelKey) stateManager.setGlobalState(actModelKey, openAiCodexDefaultModelId)
|
|
if (planModelKey) stateManager.setGlobalState(planModelKey, openAiCodexDefaultModelId)
|
|
await stateManager.flushPendingState()
|
|
|
|
return {}
|
|
} catch (error) {
|
|
// Clean up on error
|
|
openAiCodexOAuthManager.cancelAuthorizationFlow()
|
|
Logger.error("[ClineAgent] OpenAI Codex authentication error:", error)
|
|
throw error
|
|
}
|
|
}
|
|
}
|