mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 06:45:50 +00:00
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:
768
packages/remote-control-server/web/src/acp/client.ts
Normal file
768
packages/remote-control-server/web/src/acp/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/remote-control-server/web/src/acp/index.ts
Normal file
2
packages/remote-control-server/web/src/acp/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./client";
|
||||
24
packages/remote-control-server/web/src/acp/relay-client.ts
Normal file
24
packages/remote-control-server/web/src/acp/relay-client.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ACPClient } from "./client";
|
||||
import type { ACPSettings } from "./types";
|
||||
import { getUuid } from "../api/client";
|
||||
|
||||
/**
|
||||
* Build the RCS relay WebSocket URL for a given agent.
|
||||
* Uses UUID auth (same as /code/ pages).
|
||||
*/
|
||||
export function buildRelayUrl(agentId: string): string {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const uuid = getUuid();
|
||||
return `${protocol}//${window.location.host}/acp/relay/${agentId}?uuid=${encodeURIComponent(uuid)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ACPClient that connects to an agent through the RCS relay.
|
||||
* The relay transparently forwards ACP protocol messages between
|
||||
* the frontend and the target acp-link instance.
|
||||
*/
|
||||
export function createRelayClient(agentId: string): ACPClient {
|
||||
const relayUrl = buildRelayUrl(agentId);
|
||||
const settings: ACPSettings = { proxyUrl: relayUrl };
|
||||
return new ACPClient(settings);
|
||||
}
|
||||
548
packages/remote-control-server/web/src/acp/types.ts
Normal file
548
packages/remote-control-server/web/src/acp/types.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
// Permission option kinds (from ACP protocol)
|
||||
export type PermissionOptionKind =
|
||||
| "allow_once"
|
||||
| "allow_always"
|
||||
| "reject_once"
|
||||
| "reject_always";
|
||||
|
||||
// Permission option (from ACP protocol)
|
||||
export interface PermissionOption {
|
||||
optionId: string;
|
||||
name: string;
|
||||
kind: PermissionOptionKind;
|
||||
}
|
||||
|
||||
// Permission request payload (sent from server to client)
|
||||
export interface PermissionRequestPayload {
|
||||
requestId: string; // Unique ID for this request (generated by server)
|
||||
sessionId: string;
|
||||
options: PermissionOption[];
|
||||
toolCall: {
|
||||
toolCallId: string; // Tool call ID to match with existing tool calls
|
||||
title?: string;
|
||||
content?: ToolCallContent[];
|
||||
};
|
||||
}
|
||||
|
||||
// Permission response (sent from client to server)
|
||||
export interface PermissionResponsePayload {
|
||||
requestId: string;
|
||||
outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Browser Tool Types
|
||||
// ============================================================================
|
||||
// IMPORTANT: These types MUST stay in sync with packages/proxy-server/src/mcp/types.ts
|
||||
// They define the protocol between proxy-server and browser extension.
|
||||
// ============================================================================
|
||||
|
||||
export interface BrowserToolParams {
|
||||
action: "tabs" | "read" | "execute";
|
||||
tabId?: number; // Required for read/execute
|
||||
script?: string; // Required for execute
|
||||
}
|
||||
|
||||
export interface BrowserTabInfo {
|
||||
id: number;
|
||||
url: string;
|
||||
title: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserTabsResult {
|
||||
action: "tabs";
|
||||
tabs: BrowserTabInfo[];
|
||||
}
|
||||
|
||||
export interface BrowserReadResult {
|
||||
action: "read";
|
||||
tabId: number;
|
||||
url: string;
|
||||
title: string;
|
||||
dom: string;
|
||||
viewport: {
|
||||
width: number;
|
||||
height: number;
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
};
|
||||
selection: string | null;
|
||||
}
|
||||
|
||||
export interface BrowserExecuteResult {
|
||||
action: "execute";
|
||||
tabId: number;
|
||||
url: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type BrowserToolResult =
|
||||
| BrowserTabsResult
|
||||
| BrowserReadResult
|
||||
| BrowserExecuteResult;
|
||||
|
||||
// Messages sent TO the proxy server
|
||||
// Reference: Zed's MessageEditor.contents() builds Vec<acp::ContentBlock>
|
||||
export type ProxyMessage =
|
||||
| { type: "connect" }
|
||||
| { type: "disconnect" }
|
||||
| { type: "new_session"; payload?: { cwd?: string } }
|
||||
| { type: "prompt"; payload: { content: ContentBlock[] } } // Changed from { text: string } to match Zed
|
||||
| { type: "cancel" }
|
||||
| { type: "permission_response"; payload: PermissionResponsePayload }
|
||||
| { type: "browser_tool_result"; callId: string; result: BrowserToolResult | { error: string } }
|
||||
| { type: "set_session_model"; payload: { modelId: string } }
|
||||
// Session history operations - Reference: Zed's AgentSessionList trait
|
||||
| { type: "list_sessions"; payload?: ListSessionsRequest }
|
||||
| { type: "load_session"; payload: LoadSessionRequest }
|
||||
| { type: "resume_session"; payload: ResumeSessionRequest }
|
||||
// Heartbeat
|
||||
| { type: "ping" };
|
||||
|
||||
// Messages received FROM the proxy server
|
||||
// Reference: Zed's AgentConnection stores agentCapabilities from initialize response
|
||||
export interface ProxyStatusMessage {
|
||||
type: "status";
|
||||
payload: {
|
||||
connected: boolean;
|
||||
agentInfo?: { name?: string; version?: string };
|
||||
/** Full agent capabilities from initialize response */
|
||||
capabilities?: AgentCapabilities;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProxyErrorMessage {
|
||||
type: "error";
|
||||
payload: { message: string };
|
||||
}
|
||||
|
||||
// Reference: Zed's session/initialize response includes promptCapabilities and models
|
||||
export interface ProxySessionCreatedMessage {
|
||||
type: "session_created";
|
||||
payload: {
|
||||
sessionId: string;
|
||||
promptCapabilities?: PromptCapabilities; // From agent's initialize response
|
||||
models?: SessionModelState | null; // Model state if agent supports model selection
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProxySessionUpdateMessage {
|
||||
type: "session_update";
|
||||
payload: {
|
||||
sessionId: string;
|
||||
update: SessionUpdate;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProxyPromptCompleteMessage {
|
||||
type: "prompt_complete";
|
||||
payload: { stopReason: string };
|
||||
}
|
||||
|
||||
export interface ProxyPermissionRequestMessage {
|
||||
type: "permission_request";
|
||||
payload: PermissionRequestPayload;
|
||||
}
|
||||
|
||||
export interface ProxyBrowserToolCallMessage {
|
||||
type: "browser_tool_call";
|
||||
callId: string;
|
||||
params: BrowserToolParams;
|
||||
}
|
||||
|
||||
export interface ProxyPongMessage {
|
||||
type: "pong";
|
||||
}
|
||||
|
||||
export interface ProxyModelChangedMessage {
|
||||
type: "model_changed";
|
||||
payload: {
|
||||
modelId: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session History Response Types
|
||||
// Reference: Zed's AgentSessionList in acp_thread/src/connection.rs
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Response containing list of sessions.
|
||||
* Reference: Zed's AgentSessionListResponse
|
||||
*/
|
||||
export interface ProxySessionListMessage {
|
||||
type: "session_list";
|
||||
payload: ListSessionsResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response when a session is loaded (with history replay).
|
||||
* Reference: Zed's load_session returns Entity<AcpThread>
|
||||
*/
|
||||
export interface ProxySessionLoadedMessage {
|
||||
type: "session_loaded";
|
||||
payload: {
|
||||
sessionId: string;
|
||||
promptCapabilities?: PromptCapabilities;
|
||||
models?: SessionModelState | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Response when a session is resumed (without history replay).
|
||||
* Reference: Zed's resume_session returns Entity<AcpThread>
|
||||
*/
|
||||
export interface ProxySessionResumedMessage {
|
||||
type: "session_resumed";
|
||||
payload: {
|
||||
sessionId: string;
|
||||
promptCapabilities?: PromptCapabilities;
|
||||
models?: SessionModelState | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type ProxyResponse =
|
||||
| ProxyStatusMessage
|
||||
| ProxyErrorMessage
|
||||
| ProxySessionCreatedMessage
|
||||
| ProxySessionUpdateMessage
|
||||
| ProxyPromptCompleteMessage
|
||||
| ProxyPermissionRequestMessage
|
||||
| ProxyBrowserToolCallMessage
|
||||
| ProxyModelChangedMessage
|
||||
| ProxyPongMessage
|
||||
// Session history responses
|
||||
| ProxySessionListMessage
|
||||
| ProxySessionLoadedMessage
|
||||
| ProxySessionResumedMessage;
|
||||
|
||||
// Content block types (matches @agentclientprotocol/sdk ContentBlock)
|
||||
// Reference: Zed's acp::ContentBlock in agent-client-protocol crate
|
||||
export interface TextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ImageContent {
|
||||
type: "image";
|
||||
mimeType: string;
|
||||
data: string; // base64 encoded image data
|
||||
uri?: string; // optional URI for the image source
|
||||
}
|
||||
|
||||
export interface ResourceLinkContent {
|
||||
type: "resource_link";
|
||||
uri: string;
|
||||
name: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
mimeType?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export type ContentBlock = TextContent | ImageContent | ResourceLinkContent | { type: string; text?: string };
|
||||
|
||||
// Session update types from ACP
|
||||
export interface AgentMessageChunkUpdate {
|
||||
sessionUpdate: "agent_message_chunk";
|
||||
content: ContentBlock;
|
||||
}
|
||||
|
||||
// Tool call content types from ACP
|
||||
export interface ToolCallContentBlock {
|
||||
type: "content";
|
||||
content: ContentBlock;
|
||||
}
|
||||
|
||||
export interface ToolCallDiffContent {
|
||||
type: "diff";
|
||||
path: string;
|
||||
oldText?: string | null;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
export interface ToolCallTerminalContent {
|
||||
type: "terminal";
|
||||
terminalId: string;
|
||||
}
|
||||
|
||||
export type ToolCallContent = ToolCallContentBlock | ToolCallDiffContent | ToolCallTerminalContent;
|
||||
|
||||
export interface ToolCallUpdate {
|
||||
sessionUpdate: "tool_call";
|
||||
toolCallId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
content?: ToolCallContent[];
|
||||
rawInput?: Record<string, unknown>;
|
||||
rawOutput?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolCallStatusUpdate {
|
||||
sessionUpdate: "tool_call_update";
|
||||
toolCallId: string;
|
||||
status?: string;
|
||||
title?: string;
|
||||
content?: ToolCallContent[];
|
||||
rawInput?: Record<string, unknown>;
|
||||
rawOutput?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentThoughtChunkUpdate {
|
||||
sessionUpdate: "agent_thought_chunk";
|
||||
content: ContentBlock;
|
||||
}
|
||||
|
||||
export interface PlanUpdate {
|
||||
sessionUpdate: "plan";
|
||||
}
|
||||
|
||||
export interface UserMessageChunkUpdate {
|
||||
sessionUpdate: "user_message_chunk";
|
||||
content: ContentBlock;
|
||||
}
|
||||
|
||||
// Available command from agent (matches ACP SDK AvailableCommand)
|
||||
export interface AvailableCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
input?: { hint: string };
|
||||
}
|
||||
|
||||
export interface AvailableCommandsUpdate {
|
||||
sessionUpdate: "available_commands_update";
|
||||
availableCommands: AvailableCommand[];
|
||||
}
|
||||
|
||||
export type SessionUpdate =
|
||||
| AgentMessageChunkUpdate
|
||||
| ToolCallUpdate
|
||||
| ToolCallStatusUpdate
|
||||
| AgentThoughtChunkUpdate
|
||||
| PlanUpdate
|
||||
| UserMessageChunkUpdate
|
||||
| AvailableCommandsUpdate;
|
||||
|
||||
// Connection state
|
||||
export type ConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "error";
|
||||
|
||||
// PromptCapabilities from ACP protocol
|
||||
// Reference: Zed's acp::PromptCapabilities in agent-client-protocol crate
|
||||
// Used to check what content types the agent supports
|
||||
export interface PromptCapabilities {
|
||||
audio?: boolean; // Agent supports audio content
|
||||
embeddedContext?: boolean; // Agent supports embedded context in prompts
|
||||
image?: boolean; // Agent supports image content
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Capabilities Types (matches @agentclientprotocol/sdk exactly)
|
||||
// Reference: Zed's AgentCapabilities in agent_servers/src/acp.rs
|
||||
// SDK types: @agentclientprotocol/sdk/dist/schema/types.gen.d.ts
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* MCP capabilities supported by the agent.
|
||||
* Reference: acp::McpCapabilities
|
||||
*/
|
||||
export interface McpCapabilities {
|
||||
/** Agent supports client-provided MCP servers */
|
||||
clientServers?: boolean;
|
||||
/** Reserved for extensibility */
|
||||
_meta?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session list capability configuration.
|
||||
* Reference: SDK's SessionListCapabilities (note: plural)
|
||||
* @experimental - This capability is not part of the spec yet
|
||||
*/
|
||||
export interface SessionListCapabilities {
|
||||
/** Reserved for extensibility */
|
||||
_meta?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session resume capability configuration.
|
||||
* Reference: SDK's SessionResumeCapabilities (note: plural)
|
||||
* @experimental - This capability is not part of the spec yet
|
||||
*/
|
||||
export interface SessionResumeCapabilities {
|
||||
/** Reserved for extensibility */
|
||||
_meta?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session fork capability configuration.
|
||||
* Reference: SDK's SessionForkCapabilities
|
||||
* @experimental - This capability is not part of the spec yet
|
||||
*/
|
||||
export interface SessionForkCapabilities {
|
||||
/** Reserved for extensibility */
|
||||
_meta?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session capabilities supported by the agent.
|
||||
* Reference: acp::SessionCapabilities
|
||||
*
|
||||
* As a baseline, all Agents MUST support session/new, session/prompt,
|
||||
* session/cancel, and session/update.
|
||||
* Optionally, they MAY support other session methods by specifying
|
||||
* additional capabilities.
|
||||
*/
|
||||
export interface SessionCapabilities {
|
||||
/** Reserved for extensibility */
|
||||
_meta?: Record<string, unknown> | null;
|
||||
/** @experimental Agent supports forking sessions via session/fork */
|
||||
fork?: SessionForkCapabilities | null;
|
||||
/** @experimental Agent supports listing sessions via session/list */
|
||||
list?: SessionListCapabilities | null;
|
||||
/** @experimental Agent supports resuming sessions via session/resume */
|
||||
resume?: SessionResumeCapabilities | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capabilities supported by the agent.
|
||||
* Advertised during initialization to inform the client about
|
||||
* available features and content types.
|
||||
* Reference: acp::AgentCapabilities
|
||||
*/
|
||||
export interface AgentCapabilities {
|
||||
/** Reserved for extensibility */
|
||||
_meta?: Record<string, unknown> | null;
|
||||
/** Whether the agent supports session/load */
|
||||
loadSession?: boolean;
|
||||
/** MCP capabilities supported by the agent */
|
||||
mcpCapabilities?: McpCapabilities;
|
||||
/** Prompt capabilities supported by the agent */
|
||||
promptCapabilities?: PromptCapabilities;
|
||||
/** Session capabilities supported by the agent */
|
||||
sessionCapabilities?: SessionCapabilities;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session List/Load/Resume Types
|
||||
// Reference: Zed's AgentSessionInfo, AgentSessionList in acp_thread/src/connection.rs
|
||||
// SDK types: @agentclientprotocol/sdk SessionInfo, ListSessionsResponse
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Information about an existing session.
|
||||
* Returned by session/list and used for load/resume operations.
|
||||
* Reference: acp::SessionInfo (SDK), Zed's AgentSessionInfo
|
||||
* Note: SDK's SessionInfo has cwd as REQUIRED, but Zed's AgentSessionInfo has it optional
|
||||
* We follow SDK here for protocol compatibility
|
||||
*/
|
||||
export interface AgentSessionInfo {
|
||||
/** Reserved for extensibility */
|
||||
_meta?: Record<string, unknown> | null;
|
||||
/** Working directory for the session (required per SDK) */
|
||||
cwd: string;
|
||||
/** Unique identifier for the session */
|
||||
sessionId: string;
|
||||
/** Human-readable title for the session */
|
||||
title?: string | null;
|
||||
/** ISO 8601 timestamp when the session was last updated */
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to list sessions.
|
||||
* Reference: acp::ListSessionsRequest
|
||||
*/
|
||||
export interface ListSessionsRequest {
|
||||
/** Reserved for extensibility */
|
||||
_meta?: Record<string, unknown> | null;
|
||||
/** Filter sessions by working directory */
|
||||
cwd?: string;
|
||||
/** Pagination cursor for fetching more results */
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from listing sessions.
|
||||
* Reference: acp::ListSessionsResponse (SDK)
|
||||
*/
|
||||
export interface ListSessionsResponse {
|
||||
/** Reserved for extensibility */
|
||||
_meta?: Record<string, unknown> | null;
|
||||
/** Cursor for fetching the next page of results */
|
||||
nextCursor?: string | null;
|
||||
/** Array of session info objects */
|
||||
sessions: AgentSessionInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to load an existing session.
|
||||
* Reference: acp::LoadSessionRequest
|
||||
*/
|
||||
export interface LoadSessionRequest {
|
||||
/** Reserved for extensibility */
|
||||
_meta?: Record<string, unknown> | null;
|
||||
/** Session ID to load */
|
||||
sessionId: string;
|
||||
/** Working directory for the session */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to resume an existing session without replaying history.
|
||||
* Reference: acp::ResumeSessionRequest
|
||||
*/
|
||||
export interface ResumeSessionRequest {
|
||||
/** Reserved for extensibility */
|
||||
_meta?: Record<string, unknown> | null;
|
||||
/** Session ID to resume */
|
||||
sessionId: string;
|
||||
/** Working directory for the session */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Model Selection Types (matches @agentclientprotocol/sdk)
|
||||
// Reference: Zed's AgentModelSelector trait in acp_thread/src/connection.rs
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Information about a selectable model.
|
||||
* Matches ACP SDK's ModelInfo type.
|
||||
*/
|
||||
export interface ModelInfo {
|
||||
/** Unique identifier for the model */
|
||||
modelId: string;
|
||||
/** Human-readable name of the model */
|
||||
name: string;
|
||||
/** Optional description of the model */
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of models and the one currently active.
|
||||
* Matches ACP SDK's SessionModelState type.
|
||||
*/
|
||||
export interface SessionModelState {
|
||||
/** The set of models that the Agent can use */
|
||||
availableModels: ModelInfo[];
|
||||
/** The current model the Agent is using */
|
||||
currentModelId: string;
|
||||
}
|
||||
|
||||
// Settings
|
||||
export interface ACPSettings {
|
||||
proxyUrl: string;
|
||||
/** Auth token for remote access (passed as ?token=xxx query param) */
|
||||
token?: string;
|
||||
/** Working directory for the agent session */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: ACPSettings = {
|
||||
proxyUrl: "ws://localhost:9315/ws",
|
||||
};
|
||||
Reference in New Issue
Block a user