diff --git a/src/agents/claude-runner.ts b/src/agents/claude-runner.ts index 61a6aab..a9bad7b 100644 --- a/src/agents/claude-runner.ts +++ b/src/agents/claude-runner.ts @@ -241,6 +241,7 @@ export class AgentRunner { private config?: Config; private activeSessions: Map = new Map(); private activeStreams = new Map>(); + private activeAbortControllers = new Map(); private interruptFns = new Map Promise>(); private onSessionIdUpdate?: (sessionId: string, agentSessionId: string) => void; private onCompactStart?: (sessionId: string) => void; @@ -699,6 +700,11 @@ export class AgentRunner { env: this.getAgentEnv() }; + // AbortController for this query — enables interrupt() to cancel both + // the connection phase and the streaming phase + const controller = new AbortController(); + this.activeAbortControllers.set(sessionId, controller); + const createQuery = (promptInput: string | MessageStream, resumeSessionId?: string) => { if (useSettingSources) { // 新方式:SDK 自动加载 CLAUDE.md 和 MCP 配置 @@ -706,6 +712,7 @@ export class AgentRunner { prompt: promptInput as any, options: { ...commonOptions, + abortController: controller, settingSources: ['project', 'user'], systemPrompt: { type: 'preset' as const, @@ -752,6 +759,7 @@ export class AgentRunner { prompt: promptInput as any, options: { ...commonOptions, + abortController: controller, ...(resumeSessionId ? { resume: resumeSessionId } : {}), ...(Object.keys(globalMcpServers).length > 0 ? { mcpServers: globalMcpServers } : {}), ...(fullAppend ? { @@ -787,6 +795,13 @@ export class AgentRunner { } async interrupt(sessionId: string): Promise { + // Abort via controller first (covers connection phase before stream starts) + const controller = this.activeAbortControllers.get(sessionId); + if (controller) { + controller.abort('User interrupt'); + this.activeAbortControllers.delete(sessionId); + } + const fn = this.interruptFns.get(sessionId); if (fn) { try { @@ -810,6 +825,7 @@ export class AgentRunner { cleanupStream(sessionId: string): void { this.activeStreams.delete(sessionId); + this.activeAbortControllers.delete(sessionId); this.interruptFns.delete(sessionId); } @@ -885,6 +901,7 @@ export class AgentRunner { async closeSession(sessionId: string): Promise { this.activeSessions.delete(sessionId); this.activeStreams.delete(sessionId); + this.activeAbortControllers.delete(sessionId); this.interruptFns.delete(sessionId); } diff --git a/src/core/message/message-processor.ts b/src/core/message/message-processor.ts index 203faaf..6f6b50f 100644 --- a/src/core/message/message-processor.ts +++ b/src/core/message/message-processor.ts @@ -494,19 +494,31 @@ ${suggestions}`, const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined; // 可重试错误(403/429/5xx)指数退避重试,最多 3 次 + const connectionTimeoutMs = (this.config.idleMonitor?.connectionTimeout ?? 30) * 1000; const MAX_RETRIES = 3; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { let streamRegistered = false; try { - const stream = await agent.runQuery( - session.id, - effectivePrompt, - absoluteProjectPath, - session.agentSessionId, - message.images, - effectiveSystemPrompt, - this.sessionManager - ); + // Connection-phase timeout: if runQuery() (API handshake) hangs + // beyond connectionTimeout, abort and throw a clear error. + let connectionTimer: ReturnType | undefined; + const stream = await Promise.race([ + agent.runQuery( + session.id, + effectivePrompt, + absoluteProjectPath, + session.agentSessionId, + message.images, + effectiveSystemPrompt, + this.sessionManager + ).then(s => { clearTimeout(connectionTimer); return s; }), + new Promise((_, reject) => { + connectionTimer = setTimeout(() => { + agent.interrupt(streamKey).catch(() => {}); + reject(new Error('Connection timeout: agent failed to start stream within ' + (connectionTimeoutMs / 1000) + 's')); + }, connectionTimeoutMs); + }), + ]); agent.registerStream(streamKey, stream); streamRegistered = true; diff --git a/src/types.ts b/src/types.ts index 229543b..af3fe83 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,7 @@ export interface Config { enabled?: boolean; // 是否启用空闲监控,默认 true safeModeThreshold?: number; // 连续错误几次进入安全模式,默认 3;设为 0 关闭 safe mode timeout?: number; // 无输出超时(秒),默认 120 + connectionTimeout?: number; // 连接阶段超时(秒),默认 30;query() 调用到流开始前的最大等待 }; showActivities?: 'all' | 'dm-only' | 'owner-dm-only' | 'none'; // 中间输出显示范围(工具活动+流式文本),默认 'all' }