feat: 支持 acp-link 包进行 acp 通用的 remote-control (#292)

* fix: 修复超时问题

* feat: 添加 acp-link 代码

* refactor: 样式重构完成

* feat: RCS 添加 ACP 后端支持

- 新增 ACP WebSocket handler (agent 注册、EventBus 订阅)
- 新增 relay handler (前端 WS → acp-link 透传 + EventBus inbound 转发)
- 新增 SSE event stream 供外部消费者订阅 channel group 事件
- ACP REST 接口无鉴权 (agents、channel-groups)
- WebSocket 端点保留 token 鉴权
- SPA 路由 /acp/ 指向 acp.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 添加 ACP 专属前端界面

- 新增 /acp/ SPA 页面 (agent 列表 + 实时交互)
- Agent 列表按 channel group 分组,显示在线状态
- 通过 RCS WebSocket relay 与 agent 通信
- Vite multi-page 构建 (index.html + acp.html)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: acp-link 支持 RCS relay 双向通信

- rcs-upstream 新增 messageHandler 转发非控制消息
- server.ts 新增虚拟 WS + relay client state 处理 relay ACP 消息
- newSession/loadSession 补充 mcpServers 参数
- 连接成功后显示 ACP Dashboard URL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 移除 FileExplorer 及文件操作相关代码

- 删除 FileExplorer 组件
- ACPMain 移除 Files tab,仅保留 Chat 和 History
- client.ts 移除 listDir/readFile/onFileChanges 等方法
- types.ts 移除 FileItem/FileContent/FileChange 等类型

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复类型问题

* feat: RCS 后端统一 ACP/Bridge 注册逻辑

- store: EnvironmentRecord 增加 capabilities 字段、storeFindEnvironmentByMachineName 复用逻辑
- store: 新增 storeGetSessionOwners,支持未绑定 session 自动 claim
- environment: registerEnvironment 支持 ACP 复用已有记录,返回 session_id
- session: resolveOwnedWebSessionId 支持无 owner session 自动绑定
- acp-ws-handler: 新增 handleIdentify 支持 REST+WS 两步注册
- acp routes: /acp/relay 和 /acp/agents 支持 UUID 认证
- event-bus: 增加 error 类型 payload 日志

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: acp-link 改 REST 注册 + WS identify 两步流程

- rcs-upstream: 新增 registerViaRest() 通过 POST /v1/environments/bridge 注册
- rcs-upstream: WS 连接后发送 identify 替代 register,携带 agentId
- rcs-upstream: 入口链接改为 /code/?sid=${sessionId} 实现用户绑定
- server: 修复心跳跳过 relay 虚拟连接的 bug
- server: maxSessions 配置传入 RCS upstream

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 前端统一 Chat 组件 + ACP 聊天界面重构

- 新增 chat/ 组件: ChatView, ChatInput, MessageBubble, ToolCallGroup, PermissionPanel, SessionSidebar, CommandMenu
- ACPMain: 重构支持完整 ACP 协议交互(session/prompt/permission)
- rcs-chat-adapter: 统一 bridge session SSE 适配器
- ACPClient: 增强 session 管理、permission 流程、streaming 支持
- index.css: 新增 chat 相关样式、动画、布局
- useCommands: 新增快捷命令 hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 删除 /acp/ 独立页面,ACP 聊天统一到 /code/:sessionId

- 删除 acp.html、acp-main.tsx 入口文件和 pages/acp/ 目录
- SessionDetail: ACP session 在同一页面渲染 ACPSessionDetail 组件
- App.tsx: ?sid= 参数自动调用 apiBind 绑定用户 UUID
- Dashboard: 统一 session 列表导航,ACP 显示紫色标签
- relay-client: 改用 UUID 认证替代 API token
- EnvironmentList: 显示 workerType 标签(ACP Agent / Claude Code)
- index.ts: 移除 /acp/ SPA 路由,vite.config 移除 acp 入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* build: 更新构建及测试修复

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-18 17:59:29 +08:00
committed by GitHub
parent 29cc74a170
commit 34154ee3f5
142 changed files with 17847 additions and 5577 deletions

View File

@@ -0,0 +1,768 @@
import type {
ACPSettings,
AgentCapabilities,
AgentSessionInfo,
BrowserToolParams,
BrowserToolResult,
ConnectionState,
ContentBlock,
ListSessionsRequest,
ListSessionsResponse,
LoadSessionRequest,
PermissionRequestPayload,
PromptCapabilities,
ProxyMessage,
ProxyResponse,
ResumeSessionRequest,
SessionUpdate,
SessionModelState,
ModelInfo,
AvailableCommand,
} from "./types";
/**
* Error thrown when disconnect() is called while a connection is in progress.
* Callers can use `instanceof` to distinguish this from real connection errors.
*/
export class DisconnectRequestedError extends Error {
constructor() {
super("Disconnect requested");
this.name = "DisconnectRequestedError";
}
}
export type ConnectionStateHandler = (
state: ConnectionState,
error?: string,
) => void;
export type SessionUpdateHandler = (sessionId: string, update: SessionUpdate) => void;
export type SessionCreatedHandler = (sessionId: string) => void;
export type PromptCompleteHandler = (stopReason: string) => void;
export type PermissionRequestHandler = (request: PermissionRequestPayload) => void;
export type BrowserToolCallHandler = (
params: BrowserToolParams,
) => Promise<BrowserToolResult>;
export type ErrorMessageHandler = (message: string) => void;
export type ModelChangedHandler = (modelId: string) => void;
export type ModelStateChangedHandler = (state: SessionModelState | null) => void;
export type AvailableCommandsChangedHandler = (commands: AvailableCommand[]) => void;
// Handler for session loaded/resumed events
export type SessionLoadedHandler = (sessionId: string) => void;
// Handler fired before switching the active session.
// This matches Zed's model more closely: the UI changes active thread first,
// then receives updates for that thread while load/resume is in flight.
export type SessionSwitchingHandler = (sessionId: string) => void;
export class ACPClient {
private ws: WebSocket | null = null;
private settings: ACPSettings;
private connectionState: ConnectionState = "disconnected";
private sessionId: string | null = null;
private pendingSessionTarget: string | null = null;
// Reference: Zed stores full agentCapabilities from initialize response
// Used to check supports_load_session, supports_resume_session, etc.
private _agentCapabilities: AgentCapabilities | null = null;
// Reference: Zed's prompt_capabilities in MessageEditor
// Stores capabilities from agent's initialize response
private _promptCapabilities: PromptCapabilities | null = null;
// Reference: Zed stores model state from NewSessionResponse
private _modelState: SessionModelState | null = null;
private _availableCommands: AvailableCommand[] = [];
private onModelChanged: ModelChangedHandler | null = null;
private onModelStateChanged: ModelStateChangedHandler | null = null;
private onAvailableCommandsChanged: AvailableCommandsChangedHandler | null = null;
private onSessionLoaded: SessionLoadedHandler | null = null;
private onSessionSwitching: SessionSwitchingHandler | null = null;
private onConnectionStateChange: Set<ConnectionStateHandler> = new Set();
private onSessionUpdate: SessionUpdateHandler | null = null;
private onSessionCreated: SessionCreatedHandler | null = null;
private onPromptComplete: PromptCompleteHandler | null = null;
private onPermissionRequest: PermissionRequestHandler | null = null;
private onBrowserToolCall: BrowserToolCallHandler | null = null;
private onErrorMessage: ErrorMessageHandler | null = null;
// Pending session operations
private pendingSessionList: { resolve: (response: ListSessionsResponse) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> } | null = null;
private pendingSessionLoad: { resolve: (sessionId: string) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> } | null = null;
private pendingSessionResume: { resolve: (sessionId: string) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> } | null = null;
private connectResolve: ((value: void) => void) | null = null;
private connectReject: ((error: Error) => void) | null = null;
// Heartbeat state
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private heartbeatTimeout: ReturnType<typeof setTimeout> | null = null;
private missedPongs = 0;
private static readonly HEARTBEAT_INTERVAL_MS = 30_000;
private static readonly PONG_TIMEOUT_MS = 10_000;
private static readonly MAX_MISSED_PONGS = 2;
constructor(settings: ACPSettings) {
this.settings = settings;
}
updateSettings(settings: ACPSettings): void {
this.settings = settings;
}
setConnectionStateHandler(handler: ConnectionStateHandler): void {
this.onConnectionStateChange.add(handler);
}
removeConnectionStateHandler(handler: ConnectionStateHandler): void {
this.onConnectionStateChange.delete(handler);
}
setSessionUpdateHandler(handler: SessionUpdateHandler): void {
this.onSessionUpdate = handler;
}
setSessionCreatedHandler(handler: SessionCreatedHandler): void {
this.onSessionCreated = handler;
}
setPromptCompleteHandler(handler: PromptCompleteHandler): void {
this.onPromptComplete = handler;
}
setModelChangedHandler(handler: ModelChangedHandler): void {
this.onModelChanged = handler;
}
/**
* Set handler for model state changes (called when session is created/destroyed).
* This replaces polling - the handler is called immediately with current state,
* and again whenever session is created or disconnected.
*/
setModelStateChangedHandler(handler: ModelStateChangedHandler): void {
this.onModelStateChanged = handler;
// Immediately notify with current state
handler(this._modelState);
}
setAvailableCommandsChangedHandler(handler: AvailableCommandsChangedHandler): void {
this.onAvailableCommandsChanged = handler;
handler(this._availableCommands);
}
setPermissionRequestHandler(handler: PermissionRequestHandler): void {
this.onPermissionRequest = handler;
}
setBrowserToolCallHandler(handler: BrowserToolCallHandler): void {
this.onBrowserToolCall = handler;
}
setErrorMessageHandler(handler: ErrorMessageHandler): void {
this.onErrorMessage = handler;
}
setSessionSwitchingHandler(handler: SessionSwitchingHandler | null): void {
this.onSessionSwitching = handler;
}
private setState(state: ConnectionState, error?: string): void {
this.connectionState = state;
for (const handler of this.onConnectionStateChange) {
handler(state, error);
}
}
getState(): ConnectionState {
return this.connectionState;
}
getSessionId(): string | null {
return this.sessionId;
}
// Reference: Zed's supports_images() in MessageEditor
// Returns true if the agent supports image content in prompts
get supportsImages(): boolean {
return this._promptCapabilities?.image === true;
}
// Reference: Zed's prompt_capabilities in MessageEditor
getPromptCapabilities(): PromptCapabilities | null {
return this._promptCapabilities;
}
/**
* Get the current model state (available models and current model ID).
* Reference: Zed's AgentModelSelector reads from state.available_models
*/
get modelState(): SessionModelState | null {
return this._modelState;
}
/**
* Get the list of available commands from the agent.
*/
get availableCommands(): AvailableCommand[] {
return this._availableCommands;
}
/**
* Check if the agent supports model selection.
* Reference: Zed's model_selector() returns Option<Rc<dyn AgentModelSelector>>
*/
get supportsModelSelection(): boolean {
return this._modelState !== null && this._modelState.availableModels.length > 0;
}
// ============================================================================
// Session Capability Getters
// Reference: Zed's AgentConnection supports_* methods
// ============================================================================
/**
* Get the full agent capabilities.
* Reference: Zed's AcpConnection.agent_capabilities
*/
get agentCapabilities(): AgentCapabilities | null {
return this._agentCapabilities;
}
/**
* Check if the agent supports loading existing sessions.
* Reference: Zed's AcpConnection.supports_load_session()
*/
get supportsLoadSession(): boolean {
return this._agentCapabilities?.loadSession === true;
}
/**
* Check if the agent supports resuming existing sessions.
* Reference: Zed's AcpConnection.supports_resume_session()
*/
get supportsResumeSession(): boolean {
return this._agentCapabilities?.sessionCapabilities?.resume !== undefined
&& this._agentCapabilities?.sessionCapabilities?.resume !== null;
}
/**
* Check if the agent supports listing sessions.
* Reference: Zed checks agent_capabilities.session_capabilities.list
*/
get supportsSessionList(): boolean {
return this._agentCapabilities?.sessionCapabilities?.list !== undefined
&& this._agentCapabilities?.sessionCapabilities?.list !== null;
}
/**
* Check if the agent supports session history (load or resume).
* Reference: Zed's AgentConnection.supports_session_history()
*/
get supportsSessionHistory(): boolean {
return this.supportsLoadSession || this.supportsResumeSession;
}
async connect(): Promise<void> {
// Clean up any existing connection first
if (this.ws) {
const oldWs = this.ws;
this.ws = null;
try { oldWs.close(); } catch { /* ignore */ }
this.stopHeartbeat();
this.connectResolve = null;
this.connectReject = null;
}
this.setState("connecting");
return new Promise((resolve, reject) => {
this.connectResolve = resolve;
this.connectReject = reject;
try {
// Build WebSocket URL with token if provided
let wsUrl = this.settings.proxyUrl;
if (this.settings.token) {
const url = new URL(wsUrl);
url.searchParams.set("token", this.settings.token);
wsUrl = url.toString();
}
const ws = new WebSocket(wsUrl);
this.ws = ws;
ws.onopen = () => {
// Guard against race condition: check if this WebSocket is still current
if (this.ws !== ws) {
console.log("[ACPClient] WebSocket opened but already disconnected/replaced, closing stale socket");
ws.close();
return;
}
console.log("[ACPClient] WebSocket connected, sending connect command");
this.send({ type: "connect" });
};
ws.onmessage = (event) => {
// Ignore messages from stale sockets
if (this.ws !== ws) return;
try {
const response: ProxyResponse = JSON.parse(event.data);
this.handleResponse(response);
} catch (error) {
console.error("[ACPClient] Failed to parse message:", error);
}
};
ws.onerror = () => {
// Ignore errors from stale sockets
if (this.ws !== ws) return;
console.error("[ACPClient] WebSocket error");
this.setState("error", "WebSocket connection error");
this.connectReject?.(new Error("WebSocket connection error"));
this.connectResolve = null;
this.connectReject = null;
};
ws.onclose = (event) => {
// Ignore close events from stale sockets (replaced by a new connection)
if (this.ws !== ws) return;
console.log("[ACPClient] WebSocket closed", event.code, event.reason);
// Check if closed due to auth failure (code 4001) or other error during connect
if (this.connectReject) {
const errorMessage = event.reason || `Connection closed (code: ${event.code})`;
this.setState("error", errorMessage);
this.connectReject(new Error(errorMessage));
this.connectResolve = null;
this.connectReject = null;
} else {
this.setState("disconnected");
}
this.ws = null;
this.sessionId = null;
};
} catch (error) {
this.setState("error", (error as Error).message);
reject(error);
}
});
}
private handleResponse(response: ProxyResponse): void {
console.log("[ACPClient] Received:", response.type);
switch (response.type) {
case "status":
if (response.payload.connected) {
// Reference: Zed stores full agentCapabilities from status message
this._agentCapabilities = response.payload.capabilities ?? null;
this.setState("connected");
this.startHeartbeat();
this.connectResolve?.();
} else {
this.stopHeartbeat();
this.setState("disconnected");
}
this.connectResolve = null;
this.connectReject = null;
break;
case "error":
console.error("[ACPClient] Error:", response.payload);
const errorMsg = response.payload?.message || JSON.stringify(response.payload);
this.pendingSessionTarget = null;
// Reject pending session operations if any (clear their timers)
if (this.pendingSessionList) {
clearTimeout(this.pendingSessionList.timer);
this.pendingSessionList.reject(new Error(errorMsg));
this.pendingSessionList = null;
}
if (this.pendingSessionLoad) {
clearTimeout(this.pendingSessionLoad.timer);
this.pendingSessionLoad.reject(new Error(errorMsg));
this.pendingSessionLoad = null;
}
if (this.pendingSessionResume) {
clearTimeout(this.pendingSessionResume.timer);
this.pendingSessionResume.reject(new Error(errorMsg));
this.pendingSessionResume = null;
}
// If during connect phase, reject the connect promise
if (this.connectReject) {
this.connectReject(new Error(errorMsg));
this.connectResolve = null;
this.connectReject = null;
} else {
// After connected, notify UI about the error
console.error("[ACPClient] Agent error:", errorMsg);
this.onErrorMessage?.(errorMsg);
}
break;
case "session_created":
this.sessionId = response.payload.sessionId;
this.pendingSessionTarget = null;
// Reference: Zed stores promptCapabilities from session/initialize response
this._promptCapabilities = response.payload.promptCapabilities ?? null;
// Reference: Zed stores model state from NewSessionResponse.models
this._modelState = response.payload.models ?? null;
console.log("[ACPClient] Session created, promptCapabilities:", this._promptCapabilities, "models:", this._modelState);
this.onSessionCreated?.(response.payload.sessionId);
// Notify model state subscribers (replaces polling in useModels)
this.onModelStateChanged?.(this._modelState);
break;
// Session history responses - Reference: Zed's AgentSessionList
case "session_list":
console.log("[ACPClient] Session list received:", response.payload.sessions.length, "sessions");
if (this.pendingSessionList) {
clearTimeout(this.pendingSessionList.timer);
this.pendingSessionList.resolve(response.payload);
this.pendingSessionList = null;
}
break;
case "session_loaded":
this.sessionId = response.payload.sessionId;
this.pendingSessionTarget = null;
this._promptCapabilities = response.payload.promptCapabilities ?? null;
this._modelState = response.payload.models ?? null;
console.log("[ACPClient] Session loaded:", response.payload.sessionId);
if (this.pendingSessionLoad) {
clearTimeout(this.pendingSessionLoad.timer);
this.pendingSessionLoad.resolve(response.payload.sessionId);
this.pendingSessionLoad = null;
}
this.onSessionLoaded?.(response.payload.sessionId);
this.onModelStateChanged?.(this._modelState);
break;
case "session_resumed":
this.sessionId = response.payload.sessionId;
this.pendingSessionTarget = null;
this._promptCapabilities = response.payload.promptCapabilities ?? null;
this._modelState = response.payload.models ?? null;
console.log("[ACPClient] Session resumed:", response.payload.sessionId);
if (this.pendingSessionResume) {
clearTimeout(this.pendingSessionResume.timer);
this.pendingSessionResume.resolve(response.payload.sessionId);
this.pendingSessionResume = null;
}
this.onSessionLoaded?.(response.payload.sessionId);
this.onModelStateChanged?.(this._modelState);
break;
case "session_update":
// Intercept available_commands_update for internal state
const updateType = response.payload.update?.sessionUpdate;
console.log("[ACPClient] session_update type:", updateType, "payload:", response.payload);
if (updateType === "available_commands_update") {
this._availableCommands = response.payload.update.availableCommands;
console.log("[ACPClient] Available commands updated:", this._availableCommands.length, "commands");
this.onAvailableCommandsChanged?.(this._availableCommands);
}
this.onSessionUpdate?.(response.payload.sessionId, response.payload.update);
break;
case "prompt_complete":
this.onPromptComplete?.(response.payload.stopReason);
break;
case "permission_request":
console.log("[ACPClient] Permission request:", response.payload);
this.onPermissionRequest?.(response.payload);
break;
case "model_changed":
console.log("[ACPClient] Model changed:", response.payload.modelId);
if (this._modelState) {
this._modelState = {
...this._modelState,
currentModelId: response.payload.modelId,
};
}
this.onModelChanged?.(response.payload.modelId);
break;
case "browser_tool_call":
this.handleBrowserToolCall(response.callId, response.params);
break;
case "pong":
this.missedPongs = 0;
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout);
this.heartbeatTimeout = null;
}
break;
}
}
private async handleBrowserToolCall(
callId: string,
params: BrowserToolParams,
): Promise<void> {
console.log("[ACPClient] Browser tool call:", callId, params);
if (!this.onBrowserToolCall) {
console.error("[ACPClient] No browser tool handler registered");
this.send({
type: "browser_tool_result",
callId,
result: { error: "No browser tool handler registered" },
});
return;
}
try {
const result = await this.onBrowserToolCall(params);
this.send({
type: "browser_tool_result",
callId,
result,
});
} catch (error) {
console.error("[ACPClient] Browser tool error:", error);
this.send({
type: "browser_tool_result",
callId,
result: { error: (error as Error).message },
});
}
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.missedPongs = 0;
this.heartbeatInterval = setInterval(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.stopHeartbeat();
return;
}
this.ws.send(JSON.stringify({ type: "ping" }));
this.heartbeatTimeout = setTimeout(() => {
this.missedPongs++;
if (this.missedPongs >= ACPClient.MAX_MISSED_PONGS) {
console.warn(`[ACPClient] Server unresponsive (${this.missedPongs} missed pongs), closing connection`);
this.stopHeartbeat();
this.ws?.close(4000, "Heartbeat timeout");
}
}, ACPClient.PONG_TIMEOUT_MS);
}, ACPClient.HEARTBEAT_INTERVAL_MS);
}
private stopHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout);
this.heartbeatTimeout = null;
}
}
private send(message: ProxyMessage): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket not connected");
}
this.ws.send(JSON.stringify(message));
}
async createSession(cwd?: string): Promise<void> {
// Use provided cwd, or fall back to settings.cwd
const sessionCwd = cwd ?? this.settings.cwd;
this.send({ type: "new_session", payload: { cwd: sessionCwd } });
}
// Reference: Zed's MessageEditor.contents() builds Vec<acp::ContentBlock>
// and sends via AcpThread.send()
// Accepts either a string (for backward compatibility) or ContentBlock[]
async sendPrompt(content: string | ContentBlock[]): Promise<void> {
if (!this.sessionId) {
throw new Error("No active session");
}
// Convert string to ContentBlock[] for backward compatibility
const contentBlocks: ContentBlock[] = typeof content === "string"
? [{ type: "text", text: content }]
: content;
this.send({ type: "prompt", payload: { content: contentBlocks } });
}
cancel(): void {
this.send({ type: "cancel" });
}
/**
* Set the model for the current session.
* Reference: Zed's AgentModelSelector.select_model() calls connection.set_session_model()
*/
async setSessionModel(modelId: string): Promise<void> {
if (!this.sessionId) {
throw new Error("No active session");
}
this.send({ type: "set_session_model", payload: { modelId } });
}
respondToPermission(requestId: string, optionId: string | null): void {
const outcome = optionId
? { outcome: "selected" as const, optionId }
: { outcome: "cancelled" as const };
this.send({
type: "permission_response",
payload: { requestId, outcome },
});
}
// ============================================================================
// Session History Methods
// Reference: Zed's AgentSessionList trait and AgentConnection methods
// ============================================================================
/**
* Set handler for session loaded/resumed events.
*/
setSessionLoadedHandler(handler: SessionLoadedHandler): void {
this.onSessionLoaded = handler;
}
/**
* List existing sessions from the agent.
* Reference: Zed's AcpSessionList.list_sessions()
* @throws Error if agent doesn't support session listing
*/
async listSessions(request?: ListSessionsRequest): Promise<ListSessionsResponse> {
if (!this.supportsSessionList) {
throw new Error("Listing sessions is not supported by this agent");
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pendingSessionList) {
this.pendingSessionList = null;
reject(new Error("List sessions timed out"));
}
}, 30000);
this.pendingSessionList = { resolve, reject, timer };
try {
this.send({ type: "list_sessions", payload: request });
} catch (err) {
clearTimeout(timer);
this.pendingSessionList = null;
reject(err);
}
});
}
/**
* Load an existing session with history replay.
* Reference: Zed's AcpConnection.load_session()
* @throws Error if agent doesn't support session loading
*/
async loadSession(request: LoadSessionRequest): Promise<string> {
if (!this.supportsLoadSession) {
throw new Error("Loading sessions is not supported by this agent");
}
return new Promise((resolve, reject) => {
this.pendingSessionTarget = request.sessionId;
this.onSessionSwitching?.(request.sessionId);
const timer = setTimeout(() => {
if (this.pendingSessionLoad) {
this.pendingSessionTarget = null;
this.pendingSessionLoad = null;
reject(new Error("Load session timed out"));
}
}, 60000);
this.pendingSessionLoad = { resolve, reject, timer };
try {
this.send({ type: "load_session", payload: request });
} catch (err) {
clearTimeout(timer);
this.pendingSessionTarget = null;
this.pendingSessionLoad = null;
reject(err);
}
});
}
/**
* Resume an existing session without history replay.
* Reference: Zed's AcpConnection.resume_session()
* @throws Error if agent doesn't support session resuming
*/
async resumeSession(request: ResumeSessionRequest): Promise<string> {
if (!this.supportsResumeSession) {
throw new Error("Resuming sessions is not supported by this agent");
}
return new Promise((resolve, reject) => {
this.pendingSessionTarget = request.sessionId;
this.onSessionSwitching?.(request.sessionId);
const timer = setTimeout(() => {
if (this.pendingSessionResume) {
this.pendingSessionTarget = null;
this.pendingSessionResume = null;
reject(new Error("Resume session timed out"));
}
}, 30000);
this.pendingSessionResume = { resolve, reject, timer };
try {
this.send({ type: "resume_session", payload: request });
} catch (err) {
clearTimeout(timer);
this.pendingSessionTarget = null;
this.pendingSessionResume = null;
reject(err);
}
});
}
disconnect(): void {
this.stopHeartbeat();
// Reject any pending connect promise with a distinguishable error
// This ensures the promise settles and callers can catch/ignore it
if (this.connectReject) {
this.connectReject(new DisconnectRequestedError());
}
this.connectResolve = null;
this.connectReject = null;
if (this.ws) {
try {
// Don't send disconnect to acp-link — keep agent process alive for reconnection
// Just close the WebSocket
} catch {
// Ignore send errors during disconnect
}
this.ws.close();
this.ws = null;
}
this.setState("disconnected");
this.sessionId = null;
this.pendingSessionTarget = null;
this._modelState = null;
this._agentCapabilities = null;
this._availableCommands = [];
// Notify model state subscribers that session is gone
this.onModelStateChanged?.(null);
this.onAvailableCommandsChanged?.([]);
// Reject all pending operations before clearing (clear their timers too)
const disconnectError = new Error("Disconnected");
if (this.pendingSessionList) {
clearTimeout(this.pendingSessionList.timer);
this.pendingSessionList.reject(disconnectError);
this.pendingSessionList = null;
}
if (this.pendingSessionLoad) {
clearTimeout(this.pendingSessionLoad.timer);
this.pendingSessionLoad.reject(disconnectError);
this.pendingSessionLoad = null;
}
if (this.pendingSessionResume) {
clearTimeout(this.pendingSessionResume.timer);
this.pendingSessionResume.reject(disconnectError);
this.pendingSessionResume = null;
}
}
}