diff --git a/package-lock.json b/package-lock.json index cf499e1a..30a2a6d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1218,7 +1218,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2048,7 +2047,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2095,7 +2093,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2403,7 +2400,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3424,7 +3420,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7510,7 +7505,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7579,8 +7573,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tunnel": { "version": "0.0.6", @@ -7650,7 +7643,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7861,7 +7853,6 @@ "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -7911,7 +7902,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/src/config/RegistryClient.ts b/src/config/RegistryClient.ts index bde80fbb..487a70b8 100644 --- a/src/config/RegistryClient.ts +++ b/src/config/RegistryClient.ts @@ -13,6 +13,7 @@ interface Registry { } const REGISTRY_URL = 'https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json'; +const FETCH_TIMEOUT = 30000; // 30 seconds let cachedRegistry: Registry | null = null; let cacheTime = 0; @@ -30,7 +31,16 @@ export async function fetchRegistry(): Promise { try { log('Fetching ACP agent registry...'); - const response = await fetch(REGISTRY_URL); + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), FETCH_TIMEOUT); + + let response: Response; + try { + response = await fetch(REGISTRY_URL, { signal: abortController.signal }); + } finally { + clearTimeout(timeoutId); + } + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } @@ -40,6 +50,9 @@ export async function fetchRegistry(): Promise { log(`Registry fetched: ${data.agents?.length || 0} agents`); return data.agents || []; } catch (e) { + if (e instanceof Error && e.name === 'AbortError') { + log(`Registry fetch timed out after ${FETCH_TIMEOUT}ms`); + } logError('Failed to fetch registry', e); return cachedRegistry?.agents || []; } diff --git a/src/core/SessionManager.ts b/src/core/SessionManager.ts index 2716c87a..10841b52 100644 --- a/src/core/SessionManager.ts +++ b/src/core/SessionManager.ts @@ -24,6 +24,10 @@ export interface SessionInfo { availableCommands: AvailableCommand[]; } +interface ConnectOptions { + signal?: AbortSignal; +} + /** * Manages the lifecycle of ACP agent connections. * @@ -46,13 +50,48 @@ export class SessionManager extends EventEmitter { super(); } + private createCancelledError(action: string): Error { + const error = new Error(`${action} cancelled by user.`); + error.name = 'AbortError'; + return error; + } + + private isCancelled(error: unknown, signal?: AbortSignal): boolean { + if (signal?.aborted) { + return true; + } + + if (!(error instanceof Error)) { + return false; + } + + return error.name === 'AbortError' || /cancelled by user/i.test(error.message); + } + + private throwIfCancelled(signal: AbortSignal | undefined, action: string, agentId?: string): void { + if (!signal?.aborted) { + return; + } + + if (agentId) { + this.connectionManager.removeConnection(agentId); + this.agentManager.killAgent(agentId); + } + + throw this.createCancelledError(action); + } + /** * Connect to an agent and start chatting. * Only one agent can be connected at a time — automatically disconnects * any previously connected agent. * Internally creates a session via ACP protocol. */ - async connectToAgent(agentName: string): Promise { + async connectToAgent(agentName: string, options?: ConnectOptions): Promise { + const signal = options?.signal; + this.throwIfCancelled(signal, `Connection to ${agentName}`); + let onAbort: (() => void) | undefined; + // If we already have a live session with this agent, reuse it const existingSessionId = this.agentSessions.get(agentName); if (existingSessionId && this.sessions.has(existingSessionId)) { @@ -82,6 +121,14 @@ export class SessionManager extends EventEmitter { const agentInstance = this.agentManager.spawnAgent(agentName, config); const agentId = agentInstance.id; + onAbort = () => { + log(`Connection to ${agentName} cancelled by user`); + this.connectionManager.removeConnection(agentId); + this.agentManager.killAgent(agentId); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + this.throwIfCancelled(signal, `Connection to ${agentName}`, agentId); + // Listen for agent errors/close this.agentManager.on('agent-error', (evt: { agentId: string; error: Error }) => { if (evt.agentId === agentId) { @@ -117,13 +164,15 @@ export class SessionManager extends EventEmitter { let connInfo: ConnectionInfo; try { connInfo = await this.connectionManager.connect(agentId, agentProcess.process); + this.throwIfCancelled(signal, `Connection to ${agentName}`, agentId); } catch (e) { this.agentManager.killAgent(agentId); throw e; } // Create ACP session (with auth handling) - const sessionInfo = await this.createAcpSession(agentName, agentId, connInfo); + const sessionInfo = await this.createAcpSession(agentName, agentId, connInfo, signal); + this.throwIfCancelled(signal, `Connection to ${agentName}`, agentId); this.sessions.set(sessionInfo.sessionId, sessionInfo); this.agentSessions.set(agentName, sessionInfo.sessionId); @@ -136,8 +185,16 @@ export class SessionManager extends EventEmitter { sendEvent('agent/connect.end', { agentName, result: 'success' }, { duration: Date.now() - connectStartTime }); return sessionInfo; } catch (e: any) { + if (this.isCancelled(e, signal)) { + sendEvent('agent/connect.end', { agentName, result: 'cancelled' }, { duration: Date.now() - connectStartTime }); + throw this.createCancelledError(`Connection to ${agentName}`); + } sendError('agent/connect.end', { agentName, result: 'error', errorMessage: e.message || String(e) }, { duration: Date.now() - connectStartTime }); throw e; + } finally { + if (onAbort) { + signal?.removeEventListener('abort', onAbort); + } } } @@ -145,7 +202,7 @@ export class SessionManager extends EventEmitter { * Start a new conversation with the currently connected agent. * Disconnects current session, reconnects, and signals chat to clear. */ - async newConversation(): Promise { + async newConversation(options?: ConnectOptions): Promise { const activeSession = this.getActiveSession(); if (!activeSession) { return null; @@ -154,7 +211,8 @@ export class SessionManager extends EventEmitter { const agentName = activeSession.agentName; await this.disconnectAgent(agentName); this.emit('clear-chat'); - return this.connectToAgent(agentName); + this.throwIfCancelled(options?.signal, 'New conversation'); + return this.connectToAgent(agentName, options); } /** @@ -190,7 +248,9 @@ export class SessionManager extends EventEmitter { agentName: string, agentId: string, connInfo: ConnectionInfo, + signal?: AbortSignal, ): Promise { + this.throwIfCancelled(signal, `Connection to ${agentName}`, agentId); const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); let sessionResponse: NewSessionResponse; try { @@ -198,7 +258,12 @@ export class SessionManager extends EventEmitter { cwd, mcpServers: [], }); + this.throwIfCancelled(signal, `Connection to ${agentName}`, agentId); } catch (e: any) { + if (this.isCancelled(e, signal)) { + this.throwIfCancelled(signal, `Connection to ${agentName}`, agentId); + } + // Check for auth_required error (code -32000) const isAuthRequired = (e instanceof RequestError && e.code === -32000) || (e?.code === -32000) @@ -240,6 +305,7 @@ export class SessionManager extends EventEmitter { this.agentManager.killAgent(agentId); throw new Error('Authentication cancelled by user.'); } + this.throwIfCancelled(signal, `Connection to ${agentName}`, agentId); selectedMethod = picked.method; } else { // Single auth method — show a confirmation @@ -252,14 +318,19 @@ export class SessionManager extends EventEmitter { this.agentManager.killAgent(agentId); throw new Error('Authentication cancelled by user.'); } + this.throwIfCancelled(signal, `Connection to ${agentName}`, agentId); } // Perform authentication try { log(`Authenticating with method: ${selectedMethod.name} (${selectedMethod.id})`); await connInfo.connection.authenticate({ methodId: selectedMethod.id }); + this.throwIfCancelled(signal, `Connection to ${agentName}`, agentId); log('Authentication successful'); } catch (authErr: any) { + if (this.isCancelled(authErr, signal)) { + this.throwIfCancelled(signal, `Connection to ${agentName}`, agentId); + } logError('Authentication failed', authErr); this.agentManager.killAgent(agentId); throw new Error(`Authentication failed: ${authErr.message}`); @@ -271,7 +342,11 @@ export class SessionManager extends EventEmitter { cwd, mcpServers: [], }); + this.throwIfCancelled(signal, `Connection to ${agentName}`, agentId); } catch (retryErr) { + if (this.isCancelled(retryErr, signal)) { + this.throwIfCancelled(signal, `Connection to ${agentName}`, agentId); + } logError('Failed to create session after authentication', retryErr); this.agentManager.killAgent(agentId); throw retryErr; diff --git a/src/extension.ts b/src/extension.ts index 086a2aba..98752f62 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -14,6 +14,71 @@ import { initTelemetry, sendEvent } from './utils/TelemetryManager'; export function activate(context: vscode.ExtensionContext): void { log('ACP Client extension activating...'); + const showLogAction = 'Show Log'; + + const showErrorWithLogAction = async (message: string): Promise => { + const choice = await vscode.window.showErrorMessage(message, showLogAction); + if (choice === showLogAction) { + await vscode.commands.executeCommand('acp.showLog'); + } + }; + + const isCancellationError = (error: unknown): boolean => { + if (error instanceof vscode.CancellationError) { + return true; + } + + if (!(error instanceof Error)) { + return false; + } + + return error.name === 'AbortError' || /cancelled by user/i.test(error.message); + }; + + const runWithConnectNotification = async ( + title: string, + onCancelLogMessage: string, + task: (signal: AbortSignal) => Promise, + ): Promise => { + const cancelAction = 'Cancel'; + const abortController = new AbortController(); + + const settledTask = task(abortController.signal).then( + (value) => ({ status: 'fulfilled' as const, value }), + (reason) => ({ status: 'rejected' as const, reason }), + ); + + const firstResult = await Promise.race([ + settledTask.then(result => ({ type: 'task' as const, result })), + vscode.window.showInformationMessage(title, cancelAction) + .then(action => ({ type: 'action' as const, action })), + ]); + + if (firstResult.type === 'task') { + if (firstResult.result.status === 'fulfilled') { + return firstResult.result.value; + } + throw firstResult.result.reason; + } + + if (firstResult.action === cancelAction) { + log(onCancelLogMessage); + abortController.abort(); + + const finalResult = await settledTask; + if (finalResult.status === 'rejected' && !isCancellationError(finalResult.reason)) { + throw finalResult.reason; + } + throw new vscode.CancellationError(); + } + + // Dismiss/ignore notification: continue task and wait for completion. + const finalResult = await settledTask; + if (finalResult.status === 'fulfilled') { + return finalResult.value; + } + throw finalResult.reason; + }; // --- Telemetry --- const telemetryReporter = initTelemetry(); @@ -113,19 +178,19 @@ export function activate(context: vscode.ExtensionContext): void { } try { - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Connecting to ${agentName}...`, - cancellable: false, - }, - async () => { - await sessionManager.connectToAgent(agentName!); + await runWithConnectNotification( + `Connecting to ${agentName}...`, + `Connection to ${agentName} cancelled by user`, + async (signal) => { + await sessionManager.connectToAgent(agentName!, { signal }); }, ); } catch (e: any) { + if (isCancellationError(e)) { + return; + } logError('Failed to connect to agent', e); - vscode.window.showErrorMessage(`Failed to connect: ${e.message}`); + await showErrorWithLogAction(`Failed to connect to ${agentName}: ${e.message}`); } }); @@ -149,19 +214,19 @@ export function activate(context: vscode.ExtensionContext): void { } try { - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Starting new conversation with ${activeSession.agentDisplayName}...`, - cancellable: false, - }, - async () => { - await sessionManager.newConversation(); + await runWithConnectNotification( + `Starting new conversation with ${activeSession.agentDisplayName}...`, + 'New conversation cancelled by user', + async (signal) => { + await sessionManager.newConversation({ signal }); }, ); } catch (e: any) { + if (isCancellationError(e)) { + return; + } logError('Failed to start new conversation', e); - vscode.window.showErrorMessage(`Failed to start new conversation: ${e.message}`); + await showErrorWithLogAction(`Failed to start new conversation: ${e.message}`); } }); @@ -205,20 +270,23 @@ export function activate(context: vscode.ExtensionContext): void { const agentName = activeSession.agentName; try { - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Restarting ${activeSession.agentDisplayName}...`, - cancellable: false, - }, - async () => { + await runWithConnectNotification( + `Restarting ${activeSession.agentDisplayName}...`, + `Restart of ${agentName} cancelled by user`, + async (signal) => { await sessionManager.disconnectAgent(agentName); - await sessionManager.connectToAgent(agentName); + if (signal.aborted) { + throw new vscode.CancellationError(); + } + await sessionManager.connectToAgent(agentName, { signal }); }, ); vscode.window.showInformationMessage(`Restarted ${agentName}`); } catch (e: any) { - vscode.window.showErrorMessage(`Failed to restart: ${e.message}`); + if (isCancellationError(e)) { + return; + } + await showErrorWithLogAction(`Failed to restart ${agentName}: ${e.message}`); } }); diff --git a/src/utils/Logger.ts b/src/utils/Logger.ts index 272fd7f7..910af36b 100644 --- a/src/utils/Logger.ts +++ b/src/utils/Logger.ts @@ -3,6 +3,58 @@ import * as vscode from 'vscode'; let _outputChannel: vscode.OutputChannel | undefined; let _trafficChannel: vscode.OutputChannel | undefined; +function serializeForLog(value: unknown): string { + if (value === undefined) { + return ''; + } + + if (typeof value === 'string') { + return value; + } + + if (value instanceof Error) { + const errorPayload: Record = { + name: value.name, + message: value.message, + }; + + const code = (value as { code?: unknown }).code; + if (code !== undefined) { + errorPayload.code = code; + } + + if (value.stack) { + errorPayload.stack = value.stack; + } + + return JSON.stringify(errorPayload); + } + + const seen = new WeakSet(); + try { + return JSON.stringify(value, (_key, currentValue) => { + if (currentValue instanceof Error) { + return { + name: currentValue.name, + message: currentValue.message, + stack: currentValue.stack, + }; + } + + if (typeof currentValue === 'object' && currentValue !== null) { + if (seen.has(currentValue)) { + return '[Circular]'; + } + seen.add(currentValue); + } + + return currentValue; + }); + } catch { + return String(value); + } +} + export function getOutputChannel(): vscode.OutputChannel { if (!_outputChannel) { _outputChannel = vscode.window.createOutputChannel('ACP Client'); @@ -19,16 +71,23 @@ export function getTrafficChannel(): vscode.OutputChannel { export function log(message: string, ...args: unknown[]): void { const timestamp = new Date().toISOString(); - const formatted = args.length > 0 - ? `[${timestamp}] ${message} ${args.map(a => JSON.stringify(a)).join(' ')}` + const serializedArgs = args + .map(serializeForLog) + .filter(arg => arg.length > 0) + .join(' '); + const formatted = serializedArgs.length > 0 + ? `[${timestamp}] ${message} ${serializedArgs}` : `[${timestamp}] ${message}`; getOutputChannel().appendLine(formatted); } export function logError(message: string, error?: unknown): void { const timestamp = new Date().toISOString(); - const errMsg = error instanceof Error ? error.message : String(error ?? ''); - getOutputChannel().appendLine(`[${timestamp}] ERROR: ${message} ${errMsg}`); + const errMsg = serializeForLog(error); + const formatted = errMsg.length > 0 + ? `[${timestamp}] ERROR: ${message} ${errMsg}` + : `[${timestamp}] ERROR: ${message}`; + getOutputChannel().appendLine(formatted); if (error instanceof Error && error.stack) { getOutputChannel().appendLine(error.stack); }