diff --git a/packages/api/src/acp-session-info.ts b/packages/api/src/acp-session-info.ts new file mode 100644 index 0000000000..bd2962af84 --- /dev/null +++ b/packages/api/src/acp-session-info.ts @@ -0,0 +1,233 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export type AcpSessionStatus = 'idle' | 'running' | 'waiting_input' | 'completed' | 'error' | 'cancelled'; + +export interface AcpModeInfo { + modeId: string; + name: string; + description?: string; +} + +export interface AcpModelInfo { + modelId: string; + name: string; + description?: string; +} + +export interface AcpSessionConfigSelectOption { + value: string; + name: string; + description?: string; +} + +export interface AcpSessionConfigSelectGroup { + group: string; + name: string; + options: AcpSessionConfigSelectOption[]; +} + +export interface AcpSessionConfigOption { + id: string; + name: string; + category?: string; + description?: string; + type: 'select' | 'boolean'; + currentValue?: string | boolean; + options?: AcpSessionConfigSelectOption[] | AcpSessionConfigSelectGroup[]; +} + +export interface AcpSlashCommand { + name: string; + description: string; + inputHint?: string; +} + +export interface AcpSessionInfo { + id: string; + sandboxName: string; + sandboxId: string; + prompt: string; + status: AcpSessionStatus; + createdAt: number; + updatedAt: number; + agentId?: string; + agentName?: string; + cost?: AcpCost; + currentMode?: string; + availableModes?: AcpModeInfo[]; + currentModeId?: string; + availableModels?: AcpModelInfo[]; + currentModelId?: string; + availableCommands?: AcpSlashCommand[]; + configOptions?: AcpSessionConfigOption[]; + contextUsed?: number; + contextSize?: number; + error?: string; +} + +export interface AcpCost { + inputTokens: number; + outputTokens: number; + totalCost: number; + currency: string; +} + +export interface AcpPlanStep { + title: string; + state: 'done' | 'running' | 'blocked' | 'queued'; +} + +export interface AcpPermissionOption { + name: string; + kind: string; + optionId: string; +} + +export type AcpFlowEvent = + | AcpFlowPromptEvent + | AcpFlowAgentMessageEvent + | AcpFlowThinkingEvent + | AcpFlowToolCallEvent + | AcpFlowPlanEvent + | AcpFlowPermissionRequestEvent + | AcpFlowElicitationEvent + | AcpFlowStatusChangeEvent + | AcpFlowCostUpdateEvent; + +export interface AcpAttachment { + filePath: string; + fileName: string; + mimeType: string; +} + +export interface AcpFlowPromptEvent { + kind: 'prompt'; + text: string; + attachments?: { fileName: string; mimeType: string }[]; + timestamp: number; +} + +export interface AcpFlowAgentMessageEvent { + kind: 'agent_message'; + text: string; + messageId?: string; + turn?: number; + timestamp: number; +} + +export interface AcpFlowThinkingEvent { + kind: 'thinking'; + text: string; + messageId?: string; + timestamp: number; +} + +export interface AcpFlowToolCallEvent { + kind: 'tool_call'; + toolCallId: string; + title: string; + toolName?: string; + command?: string; + description?: string; + status: 'running' | 'completed' | 'error'; + content?: string; + timestamp: number; + permissionRequest?: { + requestId: string; + options: AcpPermissionOption[]; + resolved: boolean; + selectedOptionId?: string; + }; +} + +export interface AcpFlowPlanEvent { + kind: 'plan'; + steps: AcpPlanStep[]; + progress: number; + timestamp: number; +} + +/** @deprecated Permission data is now embedded in AcpFlowToolCallEvent.permissionRequest */ +export interface AcpFlowPermissionRequestEvent { + kind: 'permission_request'; + requestId: string; + toolCallTitle: string; + options: AcpPermissionOption[]; + resolved?: boolean; + selectedOptionId?: string; + timestamp: number; +} + +export interface AcpFlowElicitationEvent { + kind: 'elicitation'; + requestId: string; + message: string; + schema?: AcpElicitationSchema; + resolved?: boolean; + timestamp: number; +} + +export interface AcpElicitationSchema { + title?: string; + description?: string; + properties?: Record; + required?: string[]; +} + +export interface AcpElicitationProperty { + type: 'string' | 'number' | 'integer' | 'boolean' | 'array'; + description?: string; + default?: unknown; + enum?: string[]; +} + +export interface AcpFlowStatusChangeEvent { + kind: 'status_change'; + from: AcpSessionStatus; + to: AcpSessionStatus; + timestamp: number; +} + +export interface AcpFlowCostUpdateEvent { + kind: 'cost_update'; + cost: AcpCost; + timestamp: number; +} + +export interface AcpSessionCreateOptions { + sandboxName: string; + prompt: string; + agentId?: string; +} + +export interface AcpUserResponse { + sessionId: string; + requestId: string; + type: 'permission' | 'elicitation'; + data: AcpPermissionResponseData | AcpElicitationResponseData; +} + +export interface AcpPermissionResponseData { + optionId: string; +} + +export interface AcpElicitationResponseData { + outcome: 'accept' | 'decline' | 'cancel'; + values?: Record; +} diff --git a/packages/api/src/navigation-page.ts b/packages/api/src/navigation-page.ts index d461a65c6e..caa8f7c9f3 100644 --- a/packages/api/src/navigation-page.ts +++ b/packages/api/src/navigation-page.ts @@ -75,4 +75,7 @@ export enum NavigationPage { PROJECTS = 'projects', PROJECT_DETAILS = 'project-details', PROJECT_CREATE = 'project-create', + ACP_SESSIONS = 'acp-sessions', + ACP_SESSION_CREATE = 'acp-session-create', + ACP_SESSION_DETAILS = 'acp-session-details', } diff --git a/packages/api/src/navigation-request.ts b/packages/api/src/navigation-request.ts index 8738d857a8..ad3b060064 100644 --- a/packages/api/src/navigation-request.ts +++ b/packages/api/src/navigation-request.ts @@ -99,6 +99,11 @@ export interface NavigationParameters { id: string; }; [NavigationPage.PROJECT_CREATE]: never; + [NavigationPage.ACP_SESSIONS]: never; + [NavigationPage.ACP_SESSION_CREATE]: never; + [NavigationPage.ACP_SESSION_DETAILS]: { + id: string; + }; } // the parameters property is optional when the NavigationParameters say it is diff --git a/packages/main/src/plugin/acp/acp-debug.spec.ts b/packages/main/src/plugin/acp/acp-debug.spec.ts new file mode 100644 index 0000000000..c767347c13 --- /dev/null +++ b/packages/main/src/plugin/acp/acp-debug.spec.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { createAcpDebug } from './acp-debug.js'; + +beforeEach(() => { + vi.resetAllMocks(); + delete process.env['DEBUG']; +}); + +describe('createAcpDebug', () => { + test('suppresses output when DEBUG is unset', () => { + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const debug = createAcpDebug('pty'); + debug('hello'); + expect(spy).not.toHaveBeenCalled(); + }); + + test('suppresses output when DEBUG is empty', () => { + process.env['DEBUG'] = ''; + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const debug = createAcpDebug('pty'); + debug('hello'); + expect(spy).not.toHaveBeenCalled(); + }); + + test('enables all namespaces with DEBUG=acp', () => { + process.env['DEBUG'] = 'acp'; + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const debugPty = createAcpDebug('pty'); + const debugProtocol = createAcpDebug('protocol'); + debugPty('pty msg'); + debugProtocol('protocol msg'); + expect(spy).toHaveBeenCalledTimes(2); + }); + + test('enables all namespaces with DEBUG=acp:*', () => { + process.env['DEBUG'] = 'acp:*'; + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const debugPty = createAcpDebug('pty'); + const debugLifecycle = createAcpDebug('lifecycle'); + debugPty('msg1'); + debugLifecycle('msg2'); + expect(spy).toHaveBeenCalledTimes(2); + }); + + test('enables only matching namespace with DEBUG=acp:pty', () => { + process.env['DEBUG'] = 'acp:pty'; + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const debugPty = createAcpDebug('pty'); + const debugProtocol = createAcpDebug('protocol'); + debugPty('should print'); + debugProtocol('should not print'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('[ACP:pty'), 'should print'); + }); + + test('supports comma-separated patterns', () => { + process.env['DEBUG'] = 'acp:pty,acp:lifecycle'; + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const debugPty = createAcpDebug('pty'); + const debugProtocol = createAcpDebug('protocol'); + const debugLifecycle = createAcpDebug('lifecycle'); + debugPty('a'); + debugProtocol('b'); + debugLifecycle('c'); + expect(spy).toHaveBeenCalledTimes(2); + }); + + test('prefixes output with namespace and timestamp', () => { + process.env['DEBUG'] = 'acp'; + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const debug = createAcpDebug('pty'); + debug('test message'); + expect(spy).toHaveBeenCalledTimes(1); + const prefix = spy.mock.calls[0]![0] as string; + expect(prefix).toMatch(/^\[ACP:pty \+\d+\.\d+s\]$/); + }); + + test('passes through multiple arguments', () => { + process.env['DEBUG'] = 'acp'; + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const debug = createAcpDebug('protocol'); + debug('init', { version: 1 }, 42); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('[ACP:protocol'), 'init', { version: 1 }, 42); + }); + + test('responds to DEBUG changes at call time', () => { + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const debug = createAcpDebug('pty'); + + debug('before'); + expect(spy).not.toHaveBeenCalled(); + + process.env['DEBUG'] = 'acp'; + debug('after'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('ignores unrelated DEBUG values', () => { + process.env['DEBUG'] = 'express:*,http'; + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const debug = createAcpDebug('pty'); + debug('hello'); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/main/src/plugin/acp/acp-debug.ts b/packages/main/src/plugin/acp/acp-debug.ts new file mode 100644 index 0000000000..ce18461b56 --- /dev/null +++ b/packages/main/src/plugin/acp/acp-debug.ts @@ -0,0 +1,22 @@ +const startTime = performance.now(); + +function isEnabled(namespace: string): boolean { + const debug = process.env['DEBUG'] ?? ''; + if (!debug) return false; + + const patterns = debug.split(',').map(p => p.trim()); + const full = `acp:${namespace}`; + + return patterns.some(pattern => { + if (pattern === 'acp' || pattern === 'acp:*') return true; + return pattern === full; + }); +} + +export function createAcpDebug(namespace: string): (...args: unknown[]) => void { + return (...args: unknown[]): void => { + if (!isEnabled(namespace)) return; + const elapsed = ((performance.now() - startTime) / 1000).toFixed(3); + console.debug(`[ACP:${namespace} +${elapsed}s]`, ...args); + }; +} diff --git a/packages/main/src/plugin/acp/acp-ipc-handler.ts b/packages/main/src/plugin/acp/acp-ipc-handler.ts new file mode 100644 index 0000000000..16f38379e4 --- /dev/null +++ b/packages/main/src/plugin/acp/acp-ipc-handler.ts @@ -0,0 +1,121 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { IpcMainInvokeEvent } from 'electron/main'; +import { inject, injectable } from 'inversify'; + +import { AcpSessionManager } from '/@/plugin/acp/acp-session-manager.js'; +import { IPCHandle } from '/@/plugin/api.js'; +import { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js'; +import type { + AcpAttachment, + AcpFlowEvent, + AcpSessionCreateOptions, + AcpSessionInfo, + AcpUserResponse, +} from '/@api/acp-session-info.js'; + +@injectable() +export class AcpIPCHandler { + constructor( + @inject(IPCHandle) + private readonly ipcHandle: IPCHandle, + @inject(AcpSessionManager) + private readonly sessionManager: AcpSessionManager, + @inject(OpenshellCli) + private readonly openshellCli: OpenshellCli, + ) {} + + init(): void { + this.ipcHandle('acp:createSession', this.createSession.bind(this)); + this.ipcHandle('acp:listSessions', this.listSessions.bind(this)); + this.ipcHandle('acp:getSessionEvents', this.getSessionEvents.bind(this)); + this.ipcHandle('acp:respondToRequest', this.respondToRequest.bind(this)); + this.ipcHandle('acp:sendFollowUp', this.sendFollowUp.bind(this)); + this.ipcHandle('acp:stopPrompt', this.stopPrompt.bind(this)); + this.ipcHandle('acp:deleteSession', this.deleteSession.bind(this)); + this.ipcHandle('acp:cancelSession', this.cancelSession.bind(this)); + this.ipcHandle('acp:isOpenshellAvailable', this.isOpenshellAvailable.bind(this)); + this.ipcHandle('acp:setSessionModel', this.setSessionModel.bind(this)); + this.ipcHandle('acp:setSessionMode', this.setSessionMode.bind(this)); + this.ipcHandle('acp:setSessionConfigOption', this.setSessionConfigOption.bind(this)); + } + + protected async createSession(_: IpcMainInvokeEvent, options: AcpSessionCreateOptions): Promise { + return this.sessionManager.createSession(options); + } + + protected listSessions(_: IpcMainInvokeEvent): AcpSessionInfo[] { + return this.sessionManager.listSessions(); + } + + protected getSessionEvents(_: IpcMainInvokeEvent, sessionId: string): AcpFlowEvent[] { + return this.sessionManager.getSessionEvents(sessionId); + } + + protected respondToRequest(_: IpcMainInvokeEvent, response: AcpUserResponse): void { + this.sessionManager.respondToRequest(response); + } + + protected async sendFollowUp( + _: IpcMainInvokeEvent, + sessionId: string, + prompt: string, + attachments?: AcpAttachment[], + ): Promise { + return this.sessionManager.sendFollowUp(sessionId, prompt, attachments); + } + + protected async stopPrompt(_: IpcMainInvokeEvent, sessionId: string): Promise { + return this.sessionManager.stopPrompt(sessionId); + } + + protected async deleteSession(_: IpcMainInvokeEvent, sessionId: string): Promise { + return this.sessionManager.deleteSession(sessionId); + } + + protected cancelSession(_: IpcMainInvokeEvent, sessionId: string): void { + this.sessionManager.cancelSession(sessionId); + } + + protected async isOpenshellAvailable(_: IpcMainInvokeEvent): Promise { + try { + this.openshellCli.getCliPath(); + return true; + } catch { + return false; + } + } + + protected async setSessionModel(_: IpcMainInvokeEvent, sessionId: string, modelId: string): Promise { + return this.sessionManager.setSessionModel(sessionId, modelId); + } + + protected async setSessionMode(_: IpcMainInvokeEvent, sessionId: string, modeId: string): Promise { + return this.sessionManager.setSessionMode(sessionId, modeId); + } + + protected async setSessionConfigOption( + _: IpcMainInvokeEvent, + sessionId: string, + configId: string, + value: string | boolean, + ): Promise { + return this.sessionManager.setSessionConfigOption(sessionId, configId, value); + } +} diff --git a/packages/main/src/plugin/acp/acp-session-manager.spec.ts b/packages/main/src/plugin/acp/acp-session-manager.spec.ts new file mode 100644 index 0000000000..386b5f2796 --- /dev/null +++ b/packages/main/src/plugin/acp/acp-session-manager.spec.ts @@ -0,0 +1,142 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { AgentRegistry } from '/@/plugin/agent-registry.js'; +import type { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js'; +import type { AcpSessionCreateOptions } from '/@api/acp-session-info.js'; +import type { AgentInfo } from '/@api/agent-info.js'; +import type { ApiSenderType } from '/@api/api-sender/api-sender-type.js'; +import { AGENT_LABEL, type SandboxInfo } from '/@api/openshell-gateway-info.js'; + +import { AcpSessionManager } from './acp-session-manager.js'; + +const apiSender: ApiSenderType = { + send: vi.fn(), + receive: vi.fn(), +}; + +const openshellCli: OpenshellCli = { + getCliPath: vi.fn().mockReturnValue('/usr/bin/openshell'), + listSandboxes: vi.fn(), +} as unknown as OpenshellCli; + +const agentRegistry: AgentRegistry = { + getAgent: vi.fn(), +} as unknown as AgentRegistry; + +function createSandbox(overrides?: Partial): SandboxInfo { + return { + id: 'sandbox-1', + name: 'test-sandbox', + phase: 'Ready', + ...overrides, + }; +} + +function createAgentInfo(overrides?: Partial): AgentInfo { + return { + id: 'openclaw', + name: 'OpenClaw', + description: 'Test agent', + command: 'openclaw', + acp: { args: ['acp'] }, + destinationSkillsFolder: '/skills', + ...overrides, + }; +} + +describe('AcpSessionManager', () => { + let manager: AcpSessionManager; + + beforeEach(() => { + vi.resetAllMocks(); + manager = new AcpSessionManager(apiSender, openshellCli, agentRegistry); + }); + + describe('resolveAgentCommand', () => { + test('resolves agent from options.agentId', async () => { + const agent = createAgentInfo(); + vi.mocked(agentRegistry.getAgent).mockResolvedValue(agent); + + const options: AcpSessionCreateOptions = { sandboxName: 'sb', prompt: 'hello', agentId: 'openclaw' }; + const sandbox = createSandbox(); + + const result = await manager.resolveAgentCommand(options, sandbox); + + expect(agentRegistry.getAgent).toHaveBeenCalledWith('openclaw'); + expect(result.agentInfo).toBe(agent); + expect(result.command).toEqual(['openclaw', 'acp']); + }); + + test('resolves agent from sandbox kaiden.agent label', async () => { + const agent = createAgentInfo({ id: 'copilot', command: 'copilot', acp: { args: ['--acp'] } }); + vi.mocked(agentRegistry.getAgent).mockResolvedValue(agent); + + const options: AcpSessionCreateOptions = { sandboxName: 'sb', prompt: 'hello' }; + const sandbox = createSandbox({ labels: { [AGENT_LABEL]: 'copilot' } }); + + const result = await manager.resolveAgentCommand(options, sandbox); + + expect(agentRegistry.getAgent).toHaveBeenCalledWith('copilot'); + expect(result.command).toEqual(['copilot', '--acp']); + }); + + test('options.agentId takes priority over sandbox label', async () => { + const agent = createAgentInfo({ id: 'openclaw', command: 'openclaw' }); + vi.mocked(agentRegistry.getAgent).mockResolvedValue(agent); + + const options: AcpSessionCreateOptions = { sandboxName: 'sb', prompt: 'hello', agentId: 'openclaw' }; + const sandbox = createSandbox({ labels: { [AGENT_LABEL]: 'copilot' } }); + + const result = await manager.resolveAgentCommand(options, sandbox); + + expect(agentRegistry.getAgent).toHaveBeenCalledWith('openclaw'); + expect(result.agentInfo.id).toBe('openclaw'); + }); + + test('throws when no agent specified and no sandbox label', async () => { + const options: AcpSessionCreateOptions = { sandboxName: 'sb', prompt: 'hello' }; + const sandbox = createSandbox(); + + await expect(manager.resolveAgentCommand(options, sandbox)).rejects.toThrow('No agent specified'); + }); + + test('throws when agent not found in registry', async () => { + vi.mocked(agentRegistry.getAgent).mockResolvedValue(undefined); + + const options: AcpSessionCreateOptions = { sandboxName: 'sb', prompt: 'hello', agentId: 'unknown' }; + const sandbox = createSandbox(); + + await expect(manager.resolveAgentCommand(options, sandbox)).rejects.toThrow('Agent "unknown" not found'); + }); + + test('throws when agent does not support ACP', async () => { + const agent = createAgentInfo({ id: 'claude', name: 'Claude Code', acp: undefined }); + vi.mocked(agentRegistry.getAgent).mockResolvedValue(agent); + + const options: AcpSessionCreateOptions = { sandboxName: 'sb', prompt: 'hello', agentId: 'claude' }; + const sandbox = createSandbox(); + + await expect(manager.resolveAgentCommand(options, sandbox)).rejects.toThrow( + 'Agent "Claude Code" does not support ACP', + ); + }); + }); +}); diff --git a/packages/main/src/plugin/acp/acp-session-manager.ts b/packages/main/src/plugin/acp/acp-session-manager.ts new file mode 100644 index 0000000000..f0b467c44e --- /dev/null +++ b/packages/main/src/plugin/acp/acp-session-manager.ts @@ -0,0 +1,1091 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import * as acp from '@agentclientprotocol/sdk'; +import { inject, injectable, preDestroy } from 'inversify'; +import type { IPty } from 'node-pty'; +import { spawn as ptySpawn } from 'node-pty'; + +import { AgentRegistry } from '/@/plugin/agent-registry.js'; +import { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js'; +import type { + AcpAttachment, + AcpElicitationResponseData, + AcpFlowEvent, + AcpPermissionResponseData, + AcpSessionConfigOption, + AcpSessionCreateOptions, + AcpSessionInfo, + AcpSessionStatus, + AcpUserResponse, +} from '/@api/acp-session-info.js'; +import type { AgentInfo } from '/@api/agent-info.js'; +import { ApiSenderType } from '/@api/api-sender/api-sender-type.js'; +import type { SandboxInfo } from '/@api/openshell-gateway-info.js'; +import { AGENT_LABEL } from '/@api/openshell-gateway-info.js'; + +import { createAcpDebug } from './acp-debug.js'; + +const MAX_STDERR_LINES = 100; +const PTY_COLS = 999_999; + +const debugPty = createAcpDebug('pty'); +const debugProtocol = createAcpDebug('protocol'); +const debugLifecycle = createAcpDebug('lifecycle'); + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (err: Error) => void; +} + +interface AcpSession { + info: AcpSessionInfo; + ptyProcess: IPty; + connection: acp.ClientSideConnection; + acpSessionId?: string; + events: AcpFlowEvent[]; + pendingRequests: Map; + stderrLines: string[]; + messageTurn: number; + connectionClosed: boolean; + agentCommand: string[]; +} + +@injectable() +export class AcpSessionManager { + private sessions = new Map(); + + constructor( + @inject(ApiSenderType) private readonly apiSender: ApiSenderType, + @inject(OpenshellCli) private readonly openshellCli: OpenshellCli, + @inject(AgentRegistry) private readonly agentRegistry: AgentRegistry, + ) {} + + async resolveAgentCommand( + options: AcpSessionCreateOptions, + sandbox: SandboxInfo, + ): Promise<{ agentInfo: AgentInfo; command: string[] }> { + const agentId = options.agentId ?? sandbox.labels?.[AGENT_LABEL]; + if (!agentId) { + throw new Error('No agent specified. Select an agent or use a sandbox created by a workspace.'); + } + + const agentInfo = await this.agentRegistry.getAgent(agentId); + if (!agentInfo) { + throw new Error(`Agent "${agentId}" not found in the agent registry`); + } + if (!agentInfo.acp) { + throw new Error(`Agent "${agentInfo.name}" does not support ACP`); + } + + const command = [agentInfo.command, ...agentInfo.acp.args]; + return { agentInfo, command }; + } + + private ptyToStreams( + pty: IPty, + label: string, + stderrLines: string[], + ): { input: WritableStream; output: ReadableStream } { + const encoder = new TextEncoder(); + const sentMessages = new Set(); + + const input = new WritableStream({ + write(chunk): void { + const text = new TextDecoder().decode(chunk); + for (const line of text.split('\n').filter(l => l.trim())) { + debugPty(`${label} >>> ${line.slice(0, 200)}`); + try { + sentMessages.add(JSON.stringify(JSON.parse(line.trim()))); + } catch { + // not JSON, skip echo tracking + } + } + pty.write(text.replace(/\n/g, '\r')); + }, + }); + + let outputController: ReadableStreamDefaultController; + const output = new ReadableStream({ + start(controller): void { + outputController = controller; + }, + }); + + let buffer = ''; + let jsonAssembly = ''; + pty.onData((data: string) => { + buffer += data; + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const raw of lines) { + const cleaned = raw.replace(/\r/g, '').trim(); + if (!cleaned) continue; + + if (jsonAssembly) { + jsonAssembly += cleaned; + } else if (cleaned.startsWith('{')) { + jsonAssembly = cleaned; + } else { + console.error(`[ACP ${label}] stderr: ${cleaned}`); + if (stderrLines.length >= MAX_STDERR_LINES) { + stderrLines.shift(); + } + stderrLines.push(cleaned); + continue; + } + + try { + JSON.parse(jsonAssembly); + if (sentMessages.delete(JSON.stringify(JSON.parse(jsonAssembly)))) { + jsonAssembly = ''; + continue; + } + debugPty(`${label} <<< ${jsonAssembly.slice(0, 200)}`); + outputController.enqueue(encoder.encode(jsonAssembly + '\n')); + jsonAssembly = ''; + } catch { + // incomplete JSON, keep assembling + } + } + }); + + pty.onExit(() => { + try { + outputController.close(); + } catch { + // already closed + } + }); + + return { input, output }; + } + + async createSession(options: AcpSessionCreateOptions): Promise { + const sandboxes = await this.openshellCli.listSandboxes(); + const sandbox = sandboxes.find(s => s.name === options.sandboxName); + if (!sandbox) { + throw new Error(`Sandbox "${options.sandboxName}" not found`); + } + if (sandbox.phase !== 'Ready') { + throw new Error(`Sandbox "${sandbox.name}" is not ready (phase: ${sandbox.phase})`); + } + + const { agentInfo, command } = await this.resolveAgentCommand(options, sandbox); + + const sessionId = randomUUID(); + const openshellPath = this.openshellCli.getCliPath(); + + const spawnArgs = ['sandbox', 'exec', '-n', sandbox.name, '--tty', '--', ...command]; + debugPty(`${sandbox.name} spawning: ${openshellPath} ${spawnArgs.join(' ')}`); + + const ptyProcess = ptySpawn(openshellPath, spawnArgs, { + name: 'xterm-256color', + cols: PTY_COLS, + env: { ...(process.env as Record), OPENCODE_ENABLE_QUESTION_TOOL: '1' }, + }); + + const info: AcpSessionInfo = { + id: sessionId, + sandboxName: sandbox.name, + sandboxId: sandbox.id, + prompt: options.prompt, + status: 'idle', + createdAt: Date.now(), + updatedAt: Date.now(), + agentId: agentInfo.id, + agentName: agentInfo.name, + }; + + const events: AcpFlowEvent[] = []; + const pendingRequests = new Map(); + const stderrLines: string[] = []; + + events.push({ + kind: 'prompt', + text: options.prompt, + timestamp: Date.now(), + }); + + const { input, output } = this.ptyToStreams(ptyProcess, sandbox.name, stderrLines); + const stream = acp.ndJsonStream(input, output); + + const clientImpl = this.createClientImpl(sessionId); + const connection = new acp.ClientSideConnection((_agent: acp.Agent) => clientImpl, stream); + + const session: AcpSession = { + info, + ptyProcess, + connection, + events, + pendingRequests, + stderrLines, + messageTurn: 0, + connectionClosed: false, + agentCommand: command, + }; + + this.sessions.set(sessionId, session); + + ptyProcess.onExit(({ exitCode }) => { + debugPty(`${sandbox.name} process exited with code ${exitCode}`); + const s = this.sessions.get(sessionId); + if (s) { + s.connectionClosed = true; + if (s.info.status !== 'completed' && s.info.status !== 'cancelled') { + this.updateSessionStatus(sessionId, exitCode === 0 ? 'completed' : 'error'); + if (exitCode !== 0) { + const stderrMsg = s.stderrLines.join(' ').trim(); + s.info.error = stderrMsg || `Process exited with code ${exitCode}`; + } + } + } + }); + + this.startAcpSession(sessionId, options.prompt).catch((err: unknown) => { + console.error(`[ACP ${sandbox.name}] session start failed:`, err); + this.updateSessionStatus(sessionId, 'error'); + const s = this.sessions.get(sessionId); + if (s) { + const stderrMsg = s.stderrLines.join(' ').trim(); + s.info.error = stderrMsg || (err instanceof Error ? err.message : String(err)); + } + }); + + this.apiSender.send('acp-session-update'); + return info; + } + + private async startAcpSession(sessionId: string, prompt: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session "${sessionId}" not found`); + } + + debugProtocol('initializing connection...'); + const initResult = await session.connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + // let the agent read/write files through the agent + fs: { readTextFile: false, writeTextFile: false }, + }, + }); + debugProtocol(`initialized: protocol v${initResult.protocolVersion}`); + + debugProtocol('creating new session...'); + const newSession = await session.connection.newSession({ + cwd: '.', + mcpServers: [], + }); + debugProtocol(`session created: ${newSession.sessionId}, keys: ${Object.keys(newSession).join(',')}`); + + session.acpSessionId = newSession.sessionId; + if (newSession.modes) { + session.info.availableModes = newSession.modes.availableModes.map(m => ({ + modeId: m.id, + name: m.name, + description: m.description ?? undefined, + })); + session.info.currentModeId = newSession.modes.currentModeId; + } + if (newSession.configOptions) { + session.info.configOptions = this.mapConfigOptions(newSession.configOptions); + debugProtocol(`configOptions: ${newSession.configOptions.length} options received`); + } + this.extractModels(session, newSession); + this.updateSessionStatus(sessionId, 'running'); + + debugProtocol(`sending prompt: ${prompt}`); + const result = await session.connection.prompt({ + sessionId: newSession.sessionId, + prompt: [{ type: 'text', text: prompt }], + }); + debugProtocol(`prompt completed: stopReason=${result.stopReason}`); + + if (result.stopReason === 'end_turn' || result.stopReason === 'cancelled') { + this.updateSessionStatus(sessionId, 'completed'); + } + } + + private createClientImpl(sessionId: string): acp.Client { + return { + sessionUpdate: async (params: acp.SessionNotification): Promise => { + this.handleSessionUpdate(sessionId, params); + }, + requestPermission: async (params: acp.RequestPermissionRequest): Promise => { + return this.handlePermissionRequest(sessionId, params); + }, + readTextFile: async (_params: acp.ReadTextFileRequest): Promise => { + return { content: '' }; + }, + writeTextFile: async (_params: acp.WriteTextFileRequest): Promise => { + return {}; + }, + }; + } + + private handleSessionUpdate(sessionId: string, params: acp.SessionNotification): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + const update = params.update; + debugProtocol(`sessionUpdate: ${update.sessionUpdate}`, JSON.stringify(update).slice(0, 200)); + let flowEvent: AcpFlowEvent | undefined; + + switch (update.sessionUpdate) { + case 'agent_message_chunk': { + if (update.content.type === 'text') { + const currentTurn = session.messageTurn; + const existing = session.events.findLast( + (e): e is Extract => + e.kind === 'agent_message' && e.messageId === (update.messageId ?? undefined) && e.turn === currentTurn, + ); + if (existing) { + existing.text += update.content.text; + this.emitEvent(sessionId, existing); + return; + } + flowEvent = { + kind: 'agent_message', + text: update.content.text, + messageId: update.messageId ?? undefined, + turn: currentTurn, + timestamp: Date.now(), + }; + } else if ((update.content as { type: string }).type === 'thinking') { + const thinkingContent = update.content as unknown as { type: 'thinking'; thinking: string }; + const existing = session.events.findLast( + (e): e is Extract => + e.kind === 'thinking' && e.messageId === (update.messageId ?? undefined), + ); + if (existing) { + existing.text += thinkingContent.thinking; + this.emitEvent(sessionId, existing); + return; + } + flowEvent = { + kind: 'thinking', + text: thinkingContent.thinking, + messageId: update.messageId ?? undefined, + timestamp: Date.now(), + }; + } + break; + } + case 'tool_call': { + const toolStatus = update.status; + const toolMeta = (update as unknown as { _meta?: { claudeCode?: { toolName?: string } } })._meta; + const existingToolCall = session.events.find( + (e): e is Extract => + e.kind === 'tool_call' && e.toolCallId === update.toolCallId, + ); + if (existingToolCall) { + existingToolCall.title = update.title; + if (toolStatus) { + existingToolCall.status = + toolStatus === 'completed' ? 'completed' : toolStatus === 'failed' ? 'error' : 'running'; + } + if (toolMeta?.claudeCode?.toolName) existingToolCall.toolName = toolMeta.claudeCode.toolName; + this.emitEvent(sessionId, existingToolCall); + return; + } + flowEvent = { + kind: 'tool_call', + toolCallId: update.toolCallId, + title: update.title, + toolName: toolMeta?.claudeCode?.toolName, + status: toolStatus === 'completed' ? 'completed' : toolStatus === 'failed' ? 'error' : 'running', + timestamp: Date.now(), + }; + break; + } + case 'tool_call_update': { + const existing = session.events.find( + (e): e is Extract => + e.kind === 'tool_call' && e.toolCallId === update.toolCallId, + ); + if (existing) { + const updatedStatus = update.status; + if (updatedStatus) { + existing.status = + updatedStatus === 'completed' ? 'completed' : updatedStatus === 'failed' ? 'error' : 'running'; + } + if (updatedStatus === 'completed' || updatedStatus === 'failed') { + session.messageTurn++; + } + if (update.title) { + existing.title = update.title; + } + const rawUpdate = update as unknown as { + _meta?: { claudeCode?: { toolName?: string } }; + rawInput?: { command?: string; description?: string }; + content?: { type: string; content: { type: string; text: string } }[]; + }; + if (rawUpdate._meta?.claudeCode?.toolName && !existing.toolName) { + existing.toolName = rawUpdate._meta.claudeCode.toolName; + } + if (rawUpdate.rawInput?.command) { + existing.command = rawUpdate.rawInput.command; + } + if (rawUpdate.rawInput?.description) { + existing.description = rawUpdate.rawInput.description; + } + if (rawUpdate.content) { + existing.content = rawUpdate.content + .filter(c => c.content?.type === 'text') + .map(c => c.content.text) + .join('\n'); + } + this.emitEvent(sessionId, existing); + return; + } + break; + } + case 'plan': { + flowEvent = { + kind: 'plan', + steps: update.entries.map(entry => ({ + title: entry.content, + state: + entry.status === 'completed' + ? ('done' as const) + : entry.status === 'in_progress' + ? ('running' as const) + : ('queued' as const), + })), + progress: 0, + timestamp: Date.now(), + }; + const plan = flowEvent as Extract; + const doneCount = plan.steps.filter(s => s.state === 'done').length; + plan.progress = plan.steps.length > 0 ? Math.round((doneCount / plan.steps.length) * 100) : 0; + break; + } + case 'usage_update': { + session.info.contextUsed = update.used; + session.info.contextSize = update.size; + if (update.cost) { + flowEvent = { + kind: 'cost_update', + cost: { + inputTokens: 0, + outputTokens: 0, + totalCost: update.cost.amount, + currency: update.cost.currency, + }, + timestamp: Date.now(), + }; + session.info.cost = (flowEvent as Extract).cost; + } + session.info.updatedAt = Date.now(); + break; + } + case 'current_mode_update': { + const modeUpdate = update as unknown as { currentModeId: string }; + session.info.currentModeId = modeUpdate.currentModeId; + session.info.updatedAt = Date.now(); + this.apiSender.send('acp-session-update'); + return; + } + case 'config_option_update': { + const configUpdate = update as unknown as { configOptions: acp.SessionConfigOption[] }; + session.info.configOptions = this.mapConfigOptions(configUpdate.configOptions); + session.info.updatedAt = Date.now(); + this.apiSender.send('acp-session-update'); + return; + } + case 'available_commands_update': { + const cmds = ( + update as unknown as { + availableCommands: Array<{ name: string; description: string; input?: { hint: string } | null }>; + } + ).availableCommands; + session.info.availableCommands = cmds.map(c => ({ + name: c.name, + description: c.description, + inputHint: c.input?.hint, + })); + session.info.updatedAt = Date.now(); + this.apiSender.send('acp-session-update'); + return; + } + default: { + const rawUpdate = update as { + sessionUpdate: string; + content?: { type: string; text: string }; + messageId?: string | null; + }; + if (rawUpdate.sessionUpdate === 'agent_thought_chunk' && rawUpdate.content?.type === 'text') { + const existing = session.events.findLast( + (e): e is Extract => e.kind === 'thinking', + ); + if (existing) { + existing.text += rawUpdate.content.text; + this.emitEvent(sessionId, existing); + return; + } + flowEvent = { + kind: 'thinking', + text: rawUpdate.content.text, + messageId: rawUpdate.messageId ?? undefined, + timestamp: Date.now(), + }; + } + break; + } + } + + if (flowEvent) { + session.events.push(flowEvent); + session.info.updatedAt = Date.now(); + this.emitEvent(sessionId, flowEvent); + } + } + + private async handlePermissionRequest( + sessionId: string, + params: acp.RequestPermissionRequest, + ): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session "${sessionId}" not found`); + } + + const requestId = randomUUID(); + const previousStatus = session.info.status; + this.updateSessionStatus(sessionId, 'waiting_input'); + + const toolCallId = params.toolCall.toolCallId; + const permissionData = { + requestId, + options: params.options.map(opt => ({ + name: opt.name, + kind: opt.kind, + optionId: opt.optionId, + })), + resolved: false, + }; + + let toolCallEvent = session.events.find( + (e): e is Extract => e.kind === 'tool_call' && e.toolCallId === toolCallId, + ); + + if (toolCallEvent) { + toolCallEvent.permissionRequest = permissionData; + this.emitEvent(sessionId, toolCallEvent); + } else { + toolCallEvent = { + kind: 'tool_call', + toolCallId, + title: params.toolCall.title ?? 'Permission required', + status: 'running', + timestamp: Date.now(), + permissionRequest: permissionData, + }; + session.events.push(toolCallEvent); + this.emitEvent(sessionId, toolCallEvent); + } + + const targetEvent = toolCallEvent; + + return new Promise((resolve, reject) => { + session.pendingRequests.set(requestId, { + resolve: (value: unknown) => { + if (targetEvent.permissionRequest) { + targetEvent.permissionRequest.resolved = true; + targetEvent.permissionRequest.selectedOptionId = (value as AcpPermissionResponseData).optionId; + this.emitEvent(sessionId, targetEvent); + } + this.updateSessionStatus(sessionId, previousStatus === 'idle' ? 'running' : previousStatus); + resolve({ + outcome: { + outcome: 'selected', + optionId: (value as AcpPermissionResponseData).optionId, + }, + }); + }, + reject, + }); + }); + } + + respondToRequest(response: AcpUserResponse): void { + const session = this.sessions.get(response.sessionId); + if (!session) { + throw new Error(`Session "${response.sessionId}" not found`); + } + + const pending = session.pendingRequests.get(response.requestId); + if (!pending) { + throw new Error(`No pending request "${response.requestId}" in session "${response.sessionId}"`); + } + + session.pendingRequests.delete(response.requestId); + + if (response.type === 'permission') { + pending.resolve(response.data as AcpPermissionResponseData); + } else if (response.type === 'elicitation') { + pending.resolve(response.data as AcpElicitationResponseData); + } + } + + private async reconnectSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session "${sessionId}" not found`); + } + + debugLifecycle(`${session.info.sandboxName} reconnecting...`); + + const openshellPath = this.openshellCli.getCliPath(); + const reconnectArgs = ['sandbox', 'exec', '-n', session.info.sandboxName, '--tty', '--', ...session.agentCommand]; + debugPty(`${session.info.sandboxName} spawning: ${openshellPath} ${reconnectArgs.join(' ')}`); + + const ptyProcess = ptySpawn(openshellPath, reconnectArgs, { + name: 'xterm-256color', + cols: PTY_COLS, + env: { ...(process.env as Record), OPENCODE_ENABLE_QUESTION_TOOL: '1' }, + }); + + session.stderrLines.length = 0; + + const { input, output } = this.ptyToStreams(ptyProcess, session.info.sandboxName, session.stderrLines); + const stream = acp.ndJsonStream(input, output); + const clientImpl = this.createClientImpl(sessionId); + const connection = new acp.ClientSideConnection((_agent: acp.Agent) => clientImpl, stream); + + session.ptyProcess = ptyProcess; + session.connection = connection; + session.connectionClosed = false; + + ptyProcess.onExit(({ exitCode }) => { + debugPty(`${session.info.sandboxName} process exited with code ${exitCode}`); + const s = this.sessions.get(sessionId); + if (s) { + s.connectionClosed = true; + if (s.info.status !== 'completed' && s.info.status !== 'cancelled') { + this.updateSessionStatus(sessionId, exitCode === 0 ? 'completed' : 'error'); + if (exitCode !== 0) { + const stderrMsg = s.stderrLines.join(' ').trim(); + s.info.error = stderrMsg || `Process exited with code ${exitCode}`; + } + } + } + }); + + const initResult = await connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + // let the agent read/write files through the agent + fs: { readTextFile: false, writeTextFile: false }, + }, + }); + debugProtocol(`${session.info.sandboxName} reconnected: protocol v${initResult.protocolVersion}`); + + const newSession = await connection.newSession({ + cwd: '.', + mcpServers: [], + }); + debugProtocol(`${session.info.sandboxName} new session: ${newSession.sessionId}`); + session.acpSessionId = newSession.sessionId; + if (newSession.modes) { + session.info.availableModes = newSession.modes.availableModes.map(m => ({ + modeId: m.id, + name: m.name, + description: m.description ?? undefined, + })); + session.info.currentModeId = newSession.modes.currentModeId; + } + if (newSession.configOptions) { + session.info.configOptions = this.mapConfigOptions(newSession.configOptions); + } + this.extractModels(session, newSession); + } + + async sendFollowUp(sessionId: string, prompt: string, attachments?: AcpAttachment[]): Promise { + const session = this.sessions.get(sessionId); + if (!session?.acpSessionId) { + throw new Error(`Session "${sessionId}" not found or not initialized`); + } + + session.messageTurn++; + session.events.push({ + kind: 'prompt', + text: prompt, + attachments: attachments?.map(a => ({ fileName: a.fileName, mimeType: a.mimeType })), + timestamp: Date.now(), + }); + this.emitEvent(sessionId, session.events[session.events.length - 1]!); + this.updateSessionStatus(sessionId, 'running'); + + if (session.connectionClosed) { + await this.reconnectSession(sessionId); + } + + const contentBlocks = await this.buildContentBlocks(prompt, attachments); + + try { + const result = await session.connection.prompt({ + sessionId: session.acpSessionId, + prompt: contentBlocks, + }); + + if (result.stopReason === 'end_turn' || result.stopReason === 'cancelled') { + this.updateSessionStatus(sessionId, 'completed'); + } + } catch (err: unknown) { + if (err instanceof Error && err.message.toLowerCase().includes('connection closed')) { + debugLifecycle(`${session.info.sandboxName} connection died during prompt, reconnecting...`); + await this.reconnectSession(sessionId); + const result = await session.connection.prompt({ + sessionId: session.acpSessionId!, + prompt: contentBlocks, + }); + if (result.stopReason === 'end_turn' || result.stopReason === 'cancelled') { + this.updateSessionStatus(sessionId, 'completed'); + } + return; + } + console.error(`[ACP ${session.info.sandboxName}] follow-up failed:`, err); + this.updateSessionStatus(sessionId, 'error'); + const stderrMsg = session.stderrLines.join(' ').trim(); + session.info.error = stderrMsg || (err instanceof Error ? err.message : String(err)); + throw err; + } + } + + private async buildContentBlocks(text: string, attachments?: AcpAttachment[]): Promise { + const blocks: acp.ContentBlock[] = []; + + if (attachments) { + for (const attachment of attachments) { + const data = await readFile(attachment.filePath); + const isImage = attachment.mimeType.startsWith('image/'); + + if (isImage) { + blocks.push({ + type: 'image', + data: data.toString('base64'), + mimeType: attachment.mimeType, + }); + } else { + const isText = + attachment.mimeType.startsWith('text/') || + AcpSessionManager.TEXT_EXTENSIONS.has(extname(attachment.filePath).toLowerCase()); + const uri = pathToFileURL(attachment.filePath).href; + + if (isText) { + blocks.push({ + type: 'resource', + resource: { uri, text: data.toString('utf-8'), mimeType: attachment.mimeType }, + }); + } else { + blocks.push({ + type: 'resource', + resource: { uri, blob: data.toString('base64'), mimeType: attachment.mimeType }, + }); + } + } + } + } + + blocks.push({ type: 'text', text }); + return blocks; + } + + private static readonly TEXT_EXTENSIONS = new Set([ + '.txt', + '.md', + '.json', + '.yaml', + '.yml', + '.xml', + '.csv', + '.tsv', + '.js', + '.ts', + '.jsx', + '.tsx', + '.py', + '.rb', + '.go', + '.rs', + '.java', + '.c', + '.cpp', + '.h', + '.hpp', + '.cs', + '.swift', + '.kt', + '.sh', + '.bash', + '.html', + '.css', + '.scss', + '.less', + '.sql', + '.toml', + '.ini', + '.cfg', + '.env', + '.log', + '.svelte', + '.vue', + ]); + + private extractModels(session: AcpSession, newSessionResponse: unknown): void { + const raw = newSessionResponse as { + models?: { + availableModels?: Array<{ modelId?: string; id?: string; name: string; description?: string | null }>; + currentModelId?: string; + }; + }; + if (raw.models) { + session.info.availableModels = raw.models.availableModels?.map(m => ({ + modelId: m.modelId ?? m.id ?? m.name, + name: m.name, + description: m.description ?? undefined, + })); + session.info.currentModelId = raw.models.currentModelId; + debugProtocol( + `models: ${session.info.availableModels?.length ?? 0} available, current=${session.info.currentModelId}`, + ); + } + } + + async setSessionModel(sessionId: string, modelId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session?.acpSessionId) { + throw new Error(`Session "${sessionId}" not found or not initialized`); + } + + const conn = ( + session.connection as unknown as { + connection: { sendRequest: (method: string, params: unknown) => Promise }; + } + ).connection; + await conn.sendRequest('session/set_model', { + sessionId: session.acpSessionId, + modelId, + }); + + session.info.currentModelId = modelId; + session.info.updatedAt = Date.now(); + this.apiSender.send('acp-session-update'); + } + + async setSessionMode(sessionId: string, modeId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session?.acpSessionId) { + throw new Error(`Session "${sessionId}" not found or not initialized`); + } + + await session.connection.setSessionMode({ + sessionId: session.acpSessionId, + modeId, + }); + + session.info.currentModeId = modeId; + session.info.updatedAt = Date.now(); + this.apiSender.send('acp-session-update'); + } + + async setSessionConfigOption(sessionId: string, configId: string, value: string | boolean): Promise { + const session = this.sessions.get(sessionId); + if (!session?.acpSessionId) { + throw new Error(`Session "${sessionId}" not found or not initialized`); + } + + const params = + typeof value === 'boolean' + ? { sessionId: session.acpSessionId, configId, type: 'boolean' as const, value } + : { sessionId: session.acpSessionId, configId, value }; + + const response = await session.connection.setSessionConfigOption(params); + session.info.configOptions = this.mapConfigOptions(response.configOptions); + session.info.updatedAt = Date.now(); + this.apiSender.send('acp-session-update'); + } + + private mapConfigOptions(sdkOptions: acp.SessionConfigOption[]): AcpSessionConfigOption[] { + return sdkOptions.map(opt => { + const base = { + id: opt.id, + name: opt.name, + category: opt.category ?? undefined, + description: opt.description ?? undefined, + type: opt.type, + }; + if (opt.type === 'select') { + const isGrouped = opt.options.length > 0 && 'group' in opt.options[0]!; + const mappedOptions = isGrouped + ? (opt.options as acp.SessionConfigSelectGroup[]).map(g => ({ + group: g.group, + name: g.name, + options: g.options.map(o => ({ + value: o.value, + name: o.name, + description: o.description ?? undefined, + })), + })) + : (opt.options as acp.SessionConfigSelectOption[]).map(o => ({ + value: o.value, + name: o.name, + description: o.description ?? undefined, + })); + return { + ...base, + type: opt.type, + currentValue: opt.currentValue, + options: mappedOptions, + }; + } + return { ...base, type: opt.type, currentValue: opt.currentValue }; + }); + } + + listSessions(): AcpSessionInfo[] { + return Array.from(this.sessions.values()).map(s => ({ ...s.info })); + } + + getSessionEvents(sessionId: string): AcpFlowEvent[] { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session "${sessionId}" not found`); + } + return [...session.events]; + } + + async stopPrompt(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session?.acpSessionId) { + throw new Error(`Session "${sessionId}" not found or not initialized`); + } + + await session.connection.cancel({ sessionId: session.acpSessionId }); + } + + cancelSession(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session "${sessionId}" not found`); + } + + for (const [, pending] of session.pendingRequests) { + pending.reject(new Error('Session cancelled')); + } + session.pendingRequests.clear(); + + if (session.acpSessionId) { + session.connection.cancel({ sessionId: session.acpSessionId }).catch(() => {}); + } + + this.killPtyProcess(session); + this.updateSessionStatus(sessionId, 'cancelled'); + } + + async deleteSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session "${sessionId}" not found`); + } + + for (const [, pending] of session.pendingRequests) { + pending.reject(new Error('Session deleted')); + } + session.pendingRequests.clear(); + + if (session.acpSessionId && !session.connectionClosed) { + try { + await session.connection.closeSession({ sessionId: session.acpSessionId }); + } catch { + // best effort + } + } + + this.killPtyProcess(session); + + this.sessions.delete(sessionId); + this.apiSender.send('acp-session-update'); + } + + private killPtyProcess(session: AcpSession): void { + try { + session.ptyProcess.write('\x03'); + session.ptyProcess.write('\x04'); + } catch { + // stream may be closed + } + setTimeout(() => { + try { + session.ptyProcess.kill('SIGKILL'); + } catch { + // already dead + } + }, 1000); + } + + private updateSessionStatus(sessionId: string, status: AcpSessionStatus): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + const previousStatus = session.info.status; + session.info.status = status; + session.info.updatedAt = Date.now(); + + if (status === 'completed' || status === 'cancelled' || status === 'error') { + for (const event of session.events) { + if (event.kind === 'tool_call' && event.status === 'running') { + event.status = 'completed'; + } + } + } + + if (previousStatus !== status) { + session.events.push({ + kind: 'status_change', + from: previousStatus, + to: status, + timestamp: Date.now(), + }); + } + + this.apiSender.send('acp-session-update'); + } + + private emitEvent(_sessionId: string, _event: AcpFlowEvent): void { + this.apiSender.send('acp-session-update'); + } + + @preDestroy() + dispose(): void { + for (const [, session] of this.sessions) { + for (const [, pending] of session.pendingRequests) { + pending.reject(new Error('Manager disposing')); + } + this.killPtyProcess(session); + } + this.sessions.clear(); + } +} diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index 9498d13217..94c106e7a3 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -52,6 +52,8 @@ import type { IpcMainInvokeEvent } from 'electron/main'; import { Container } from 'inversify'; import { lookup } from 'mime-types'; +import { AcpIPCHandler } from '/@/plugin/acp/acp-ipc-handler.js'; +import { AcpSessionManager } from '/@/plugin/acp/acp-session-manager.js'; import { AgentRegistry } from '/@/plugin/agent-registry.js'; import { AgentWorkspaceManager } from '/@/plugin/agent-workspace/agent-workspace-manager.js'; import { IPCHandle, IPCMainOn, WebContentsType } from '/@/plugin/api.js'; @@ -596,6 +598,8 @@ export class PluginSystem { container.bind(OpenshellImageBuilder).toSelf().inSingletonScope(); container.bind(AgentWorkspaceManager).toSelf().inSingletonScope(); container.bind(OpenshellSecretAdapter).toSelf().inSingletonScope(); + container.bind(AcpSessionManager).toSelf().inSingletonScope(); + container.bind(AcpIPCHandler).toSelf().inSingletonScope(); container.bind(SecretManager).toSelf().inSingletonScope(); container.bind(FlowManager).toSelf().inSingletonScope(); container.bind(SkillManager).toSelf().inSingletonScope(); @@ -925,6 +929,9 @@ export class PluginSystem { const mcpIPCHandler = container.get(MCPIPCHandler); mcpIPCHandler.init(); + const acpIPCHandler = container.get(AcpIPCHandler); + acpIPCHandler.init(); + await schedulerRegistry.init(); await this.setupSecurityRestrictionsOnLinks(messageBox); diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 1c37eeb69e..4454e25023 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -44,6 +44,13 @@ import type * as containerDesktopAPI from '@openkaiden/api'; import type { DynamicToolUIPart, UIMessageChunk } from 'ai'; import { contextBridge, ipcRenderer } from 'electron'; +import type { + AcpAttachment, + AcpFlowEvent, + AcpSessionCreateOptions, + AcpSessionInfo, + AcpUserResponse, +} from '/@api/acp-session-info'; import type { AgentInfo } from '/@api/agent-info'; import type { AgentWorkspaceConfiguration, @@ -449,6 +456,64 @@ export function initExposure(): void { } }); + // ACP Sessions + contextBridge.exposeInMainWorld( + 'createAcpSession', + async (options: AcpSessionCreateOptions): Promise => { + return ipcInvoke('acp:createSession', options); + }, + ); + + contextBridge.exposeInMainWorld('listAcpSessions', async (): Promise => { + return ipcInvoke('acp:listSessions'); + }); + + contextBridge.exposeInMainWorld('getAcpSessionEvents', async (sessionId: string): Promise => { + return ipcInvoke('acp:getSessionEvents', sessionId); + }); + + contextBridge.exposeInMainWorld('respondToAcpRequest', async (response: AcpUserResponse): Promise => { + return ipcInvoke('acp:respondToRequest', response); + }); + + contextBridge.exposeInMainWorld( + 'sendAcpFollowUp', + async (sessionId: string, prompt: string, attachments?: AcpAttachment[]): Promise => { + return ipcInvoke('acp:sendFollowUp', sessionId, prompt, attachments); + }, + ); + + contextBridge.exposeInMainWorld('stopAcpPrompt', async (sessionId: string): Promise => { + return ipcInvoke('acp:stopPrompt', sessionId); + }); + + contextBridge.exposeInMainWorld('deleteAcpSession', async (sessionId: string): Promise => { + return ipcInvoke('acp:deleteSession', sessionId); + }); + + contextBridge.exposeInMainWorld('cancelAcpSession', async (sessionId: string): Promise => { + return ipcInvoke('acp:cancelSession', sessionId); + }); + + contextBridge.exposeInMainWorld('isOpenshellAvailable', async (): Promise => { + return ipcInvoke('acp:isOpenshellAvailable'); + }); + + contextBridge.exposeInMainWorld('setAcpSessionModel', async (sessionId: string, modelId: string): Promise => { + return ipcInvoke('acp:setSessionModel', sessionId, modelId); + }); + + contextBridge.exposeInMainWorld('setAcpSessionMode', async (sessionId: string, modeId: string): Promise => { + return ipcInvoke('acp:setSessionMode', sessionId, modeId); + }); + + contextBridge.exposeInMainWorld( + 'setAcpSessionConfigOption', + async (sessionId: string, configId: string, value: string | boolean): Promise => { + return ipcInvoke('acp:setSessionConfigOption', sessionId, configId, value); + }, + ); + contextBridge.exposeInMainWorld('createSecret', async (options: SecretCreateOptions): Promise => { return ipcInvoke('secret-manager:create', options); }); diff --git a/packages/renderer/src/App.svelte b/packages/renderer/src/App.svelte index 0fd80447d4..b3e37c29c2 100644 --- a/packages/renderer/src/App.svelte +++ b/packages/renderer/src/App.svelte @@ -6,6 +6,9 @@ import { tablePersistence } from '@podman-desktop/ui-svelte'; import { onDestroy } from 'svelte'; import { router } from 'tinro'; +import AcpSessionDetail from '/@/lib/acp-sessions/AcpSessionDetail.svelte'; +import AcpSessionLayout from '/@/lib/acp-sessions/AcpSessionLayout.svelte'; +import AcpSessionList from '/@/lib/acp-sessions/AcpSessionList.svelte'; import AgentWorkspaceCreate from '/@/lib/agent-workspaces/AgentWorkspaceCreate.svelte'; import AgentWorkspaceDetails from '/@/lib/agent-workspaces/AgentWorkspaceDetails.svelte'; import AgentWorkspaceList from '/@/lib/agent-workspaces/AgentWorkspaceList.svelte'; @@ -258,6 +261,24 @@ tablePersistence.storage = new PodmanDesktopStoragePersist(); {/if} + + + + + + + + + + + + + + + + + + diff --git a/packages/renderer/src/lib/acp-sessions/AcpAtMentionCompletion.spec.ts b/packages/renderer/src/lib/acp-sessions/AcpAtMentionCompletion.spec.ts new file mode 100644 index 0000000000..a10a14cd28 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/AcpAtMentionCompletion.spec.ts @@ -0,0 +1,67 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; + +import { render, screen } from '@testing-library/svelte'; +import { expect, test, vi } from 'vitest'; + +import AcpAtMentionCompletion from './AcpAtMentionCompletion.svelte'; + +test('renders attach file option', () => { + render(AcpAtMentionCompletion, { query: '', onselect: vi.fn(), oncancel: vi.fn() }); + + expect(screen.getByText('Attach file…')).toBeInTheDocument(); +}); + +test('hides when query contains whitespace', () => { + render(AcpAtMentionCompletion, { query: 'some file', onselect: vi.fn(), oncancel: vi.fn() }); + + expect(screen.queryByText('Attach file…')).not.toBeInTheDocument(); +}); + +test('handleKey returns true for Enter', () => { + const onselect = vi.fn(); + const { component } = render(AcpAtMentionCompletion, { query: '', onselect, oncancel: vi.fn() }); + + const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }); + const handled = (component as unknown as { handleKey: (e: KeyboardEvent) => boolean }).handleKey(event); + + expect(handled).toBe(true); + expect(onselect).toHaveBeenCalled(); +}); + +test('handleKey returns true for Escape', () => { + const oncancel = vi.fn(); + const { component } = render(AcpAtMentionCompletion, { query: '', onselect: vi.fn(), oncancel }); + + const event = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }); + const handled = (component as unknown as { handleKey: (e: KeyboardEvent) => boolean }).handleKey(event); + + expect(handled).toBe(true); + expect(oncancel).toHaveBeenCalled(); +}); + +test('handleKey returns false for other keys', () => { + const { component } = render(AcpAtMentionCompletion, { query: '', onselect: vi.fn(), oncancel: vi.fn() }); + + const event = new KeyboardEvent('keydown', { key: 'a', cancelable: true }); + const handled = (component as unknown as { handleKey: (e: KeyboardEvent) => boolean }).handleKey(event); + + expect(handled).toBe(false); +}); diff --git a/packages/renderer/src/lib/acp-sessions/AcpAtMentionCompletion.svelte b/packages/renderer/src/lib/acp-sessions/AcpAtMentionCompletion.svelte new file mode 100644 index 0000000000..626ef26862 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/AcpAtMentionCompletion.svelte @@ -0,0 +1,43 @@ + + +{#if visible} +
+ +
+{/if} diff --git a/packages/renderer/src/lib/acp-sessions/AcpNoSandboxEmptyScreen.spec.ts b/packages/renderer/src/lib/acp-sessions/AcpNoSandboxEmptyScreen.spec.ts new file mode 100644 index 0000000000..31021afaef --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/AcpNoSandboxEmptyScreen.spec.ts @@ -0,0 +1,33 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; + +import { render, screen } from '@testing-library/svelte'; +import { expect, test } from 'vitest'; + +import AcpNoSandboxEmptyScreen from './AcpNoSandboxEmptyScreen.svelte'; + +test('renders title and message', () => { + render(AcpNoSandboxEmptyScreen); + + expect(screen.getByText('No ready sandboxes')).toBeInTheDocument(); + expect( + screen.getByText('A ready sandbox is required to create an agent session. Create a workspace first.'), + ).toBeInTheDocument(); +}); diff --git a/packages/renderer/src/lib/acp-sessions/AcpNoSandboxEmptyScreen.svelte b/packages/renderer/src/lib/acp-sessions/AcpNoSandboxEmptyScreen.svelte new file mode 100644 index 0000000000..c566cbf7b9 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/AcpNoSandboxEmptyScreen.svelte @@ -0,0 +1,10 @@ + + + diff --git a/packages/renderer/src/lib/acp-sessions/AcpSessionCreate.spec.ts b/packages/renderer/src/lib/acp-sessions/AcpSessionCreate.spec.ts new file mode 100644 index 0000000000..b88c4bc606 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/AcpSessionCreate.spec.ts @@ -0,0 +1,105 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; + +import { render, screen } from '@testing-library/svelte'; +import { writable } from 'svelte/store'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import * as agentsStore from '/@/stores/agents'; +import type { SandboxInfoWithGateway } from '/@/stores/openshell-sandboxes'; +import * as openshellSandboxesStore from '/@/stores/openshell-sandboxes'; +import type { AgentInfo } from '/@api/agent-info'; +import { AGENT_LABEL } from '/@api/openshell-gateway-info'; + +import AcpSessionCreate from './AcpSessionCreate.svelte'; + +vi.mock(import('/@/stores/openshell-sandboxes')); +vi.mock(import('/@/stores/agents')); +vi.mock(import('tinro')); + +const ACP_AGENT: AgentInfo = { + id: 'openclaw', + name: 'OpenClaw', + description: 'An ACP agent', + command: 'openclaw', + acp: { args: ['acp'] }, + destinationSkillsFolder: '/skills', +}; + +const NON_ACP_AGENT: AgentInfo = { + id: 'claude', + name: 'Claude Code', + description: 'No ACP support', + command: 'claude', + destinationSkillsFolder: '/skills', +}; + +const SANDBOX_WITH_LABEL: SandboxInfoWithGateway = { + id: 'sb-1', + name: 'labeled-sandbox', + phase: 'Ready', + labels: { [AGENT_LABEL]: 'openclaw' }, + gatewayName: 'test-gateway', +}; + +const SANDBOX_WITHOUT_LABEL: SandboxInfoWithGateway = { + id: 'sb-2', + name: 'plain-sandbox', + phase: 'Ready', + gatewayName: 'test-gateway', +}; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +test('shows agent dropdown when sandbox has no kaiden.agent label', () => { + vi.mocked(openshellSandboxesStore).allOpenshellSandboxes = writable([ + SANDBOX_WITHOUT_LABEL, + ]); + vi.mocked(agentsStore).agentInfos = writable([ACP_AGENT, NON_ACP_AGENT]); + + render(AcpSessionCreate, { onclose: vi.fn() }); + + const agentDropdown = screen.getByLabelText('Agent'); + expect(agentDropdown).toBeInTheDocument(); +}); + +test('shows agent name from label when sandbox has kaiden.agent label', () => { + vi.mocked(openshellSandboxesStore).allOpenshellSandboxes = writable([SANDBOX_WITH_LABEL]); + vi.mocked(agentsStore).agentInfos = writable([ACP_AGENT, NON_ACP_AGENT]); + + render(AcpSessionCreate, { onclose: vi.fn() }); + + expect(screen.getByText('OpenClaw')).toBeInTheDocument(); + expect(screen.getByText('(set by workspace)')).toBeInTheDocument(); +}); + +test('only shows ACP-capable agents in dropdown', () => { + vi.mocked(openshellSandboxesStore).allOpenshellSandboxes = writable([ + SANDBOX_WITHOUT_LABEL, + ]); + vi.mocked(agentsStore).agentInfos = writable([ACP_AGENT, NON_ACP_AGENT]); + + render(AcpSessionCreate, { onclose: vi.fn() }); + + expect(screen.queryByText('OpenClaw')).toBeInTheDocument(); + expect(screen.queryByText('Claude Code')).not.toBeInTheDocument(); +}); diff --git a/packages/renderer/src/lib/acp-sessions/AcpSessionCreate.svelte b/packages/renderer/src/lib/acp-sessions/AcpSessionCreate.svelte new file mode 100644 index 0000000000..49e15d1521 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/AcpSessionCreate.svelte @@ -0,0 +1,131 @@ + + + + {#snippet content()} +
+

+ Select a sandbox and describe what you'd like the agent to do. +

+ +
+ + {#if readySandboxes.length === 0} +

+ No ready sandboxes available. Create a sandbox first. +

+ {:else} + ({ label: s.name, value: s.name }))} + /> + {/if} +
+ +
+ + {#if needsAgentSelection} + {#if acpAgents.length === 0} +

+ No ACP-capable agents available. +

+ {:else} + ({ label: a.name, value: a.id }))} + /> + {/if} + {:else} +

+ {acpAgents.find(a => a.id === sandboxAgentId)?.name ?? sandboxAgentId} + (set by workspace) +

+ {/if} +
+ +
+ + +
+ + +
+
+ + {#if hasModels} +
+ + +
+ {/if} + {#if hasModes} +
+ + +
+ {/if} + {#each selectConfigOptions as configOpt (configOpt.id)} +
+ + +
+ {/each} +
+
+ {#if isRunning} + + {:else} + + {/if} +
+
+
+ + +{/if} diff --git a/packages/renderer/src/lib/acp-sessions/AcpSessionEmptyScreen.svelte b/packages/renderer/src/lib/acp-sessions/AcpSessionEmptyScreen.svelte new file mode 100644 index 0000000000..0a11bf460e --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/AcpSessionEmptyScreen.svelte @@ -0,0 +1,18 @@ + + + diff --git a/packages/renderer/src/lib/acp-sessions/AcpSessionLayout.svelte b/packages/renderer/src/lib/acp-sessions/AcpSessionLayout.svelte new file mode 100644 index 0000000000..d85c6c7d60 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/AcpSessionLayout.svelte @@ -0,0 +1,155 @@ + + +
+ +
+
+ Sessions +
+
+ {#if needsInputSessions.length > 0} +
+ Needs your input {needsInputSessions.length} +
+ {#each needsInputSessions as s (s.id)} +
+ + +
+ {/each} + {/if} + + {#if runningSessions.length > 0} +
+ Running {runningSessions.length} +
+ {#each runningSessions as s (s.id)} +
+ + +
+ {/each} + {/if} + + {#if completedSessions.length > 0} +
+ Completed {completedSessions.length} +
+ {#each completedSessions as s (s.id)} +
+ + +
+ {/each} + {/if} +
+
+ + +
+ {@render children()} +
+
+ +{#if showCreateDialog && hasReadySandboxes} + { showCreateDialog = false; }} /> +{/if} diff --git a/packages/renderer/src/lib/acp-sessions/AcpSessionList.svelte b/packages/renderer/src/lib/acp-sessions/AcpSessionList.svelte new file mode 100644 index 0000000000..70196cf231 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/AcpSessionList.svelte @@ -0,0 +1,146 @@ + + + + {#snippet additionalActions()} + + {/snippet} + + {#snippet content()} +
+
+ +
+ +
+ {#if filteredSessions.length === 0} + {#if searchTerm} + + {:else if !hasReadySandboxes} + + {:else} + { showCreateDialog = true; }} /> + {/if} + {:else if !hasMultipleGroups} +
+ + + {:else} +
+ {#if needsInputSessions.length > 0} +
Needs your input
+
+
+ + {/if} + {#if runningSessions.length > 0} +
Running
+
+
+ + {/if} + {#if completedSessions.length > 0} +
Completed
+
+
+ + {/if} + + {/if} + + + {/snippet} + + +{#if showCreateDialog && hasReadySandboxes} + { showCreateDialog = false; }} /> +{/if} diff --git a/packages/renderer/src/lib/acp-sessions/AcpSlashCommandCompletion.svelte b/packages/renderer/src/lib/acp-sessions/AcpSlashCommandCompletion.svelte new file mode 100644 index 0000000000..6c43c2981a --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/AcpSlashCommandCompletion.svelte @@ -0,0 +1,82 @@ + + +{#if filtered.length > 0} +
+ {#each filtered as cmd, i (cmd.name)} + + {/each} +
+{/if} diff --git a/packages/renderer/src/lib/acp-sessions/columns/AcpSessionName.svelte b/packages/renderer/src/lib/acp-sessions/columns/AcpSessionName.svelte new file mode 100644 index 0000000000..2392af16d1 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/columns/AcpSessionName.svelte @@ -0,0 +1,27 @@ + + +
+ +
diff --git a/packages/renderer/src/lib/acp-sessions/columns/AcpSessionStatusBadge.svelte b/packages/renderer/src/lib/acp-sessions/columns/AcpSessionStatusBadge.svelte new file mode 100644 index 0000000000..7c7a02ef60 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/columns/AcpSessionStatusBadge.svelte @@ -0,0 +1,32 @@ + + +
+ + {STATUS_LABELS[object.status]} +
diff --git a/packages/renderer/src/lib/acp-sessions/columns/AcpSessionWorkspace.svelte b/packages/renderer/src/lib/acp-sessions/columns/AcpSessionWorkspace.svelte new file mode 100644 index 0000000000..bad5209215 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/columns/AcpSessionWorkspace.svelte @@ -0,0 +1,11 @@ + + +{object.sandboxName} diff --git a/packages/renderer/src/lib/acp-sessions/flow/AcpFlowAgentMessage.svelte b/packages/renderer/src/lib/acp-sessions/flow/AcpFlowAgentMessage.svelte new file mode 100644 index 0000000000..ce660b8e1c --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/flow/AcpFlowAgentMessage.svelte @@ -0,0 +1,14 @@ + + +
+ +
diff --git a/packages/renderer/src/lib/acp-sessions/flow/AcpFlowPlan.svelte b/packages/renderer/src/lib/acp-sessions/flow/AcpFlowPlan.svelte new file mode 100644 index 0000000000..94a71d8293 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/flow/AcpFlowPlan.svelte @@ -0,0 +1,58 @@ + + +
+
+ Plan + {event.progress}% +
+ +
+
+
+ +
+ {#each event.steps as step (step.title)} +
+ + {step.title} +
+ {/each} +
+
diff --git a/packages/renderer/src/lib/acp-sessions/flow/AcpFlowPrompt.svelte b/packages/renderer/src/lib/acp-sessions/flow/AcpFlowPrompt.svelte new file mode 100644 index 0000000000..3cf90f7c1a --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/flow/AcpFlowPrompt.svelte @@ -0,0 +1,26 @@ + + +
+ {#if event.attachments?.length} +
+ {#each event.attachments as attachment (attachment.fileName)} + + + {attachment.fileName} + + {/each} +
+ {/if} + {event.text} +
diff --git a/packages/renderer/src/lib/acp-sessions/flow/AcpFlowThinking.svelte b/packages/renderer/src/lib/acp-sessions/flow/AcpFlowThinking.svelte new file mode 100644 index 0000000000..131e9d2649 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/flow/AcpFlowThinking.svelte @@ -0,0 +1,29 @@ + + +
+ + {#if expanded} +
+ +
+ {/if} +
diff --git a/packages/renderer/src/lib/acp-sessions/flow/AcpFlowToolCall.svelte b/packages/renderer/src/lib/acp-sessions/flow/AcpFlowToolCall.svelte new file mode 100644 index 0000000000..bad5cc93d3 --- /dev/null +++ b/packages/renderer/src/lib/acp-sessions/flow/AcpFlowToolCall.svelte @@ -0,0 +1,147 @@ + + +
+ +
+ + {#if event.toolName} + + {event.toolName} + + {/if} + + {event.description ?? event.title} + +
+ + + {#if event.command} +
+ {event.command} +
+ {/if} + + + {#if event.permissionRequest} +
+ {#if event.permissionRequest.resolved && selectedOption} +
+ {#if selectedOption.kind === 'deny'} + + Denied + {:else} + + {selectedOption.name} + {/if} +
+ {:else} +
+ {#each event.permissionRequest.options as option (option.optionId)} + + {/each} +
+ {/if} +
+ {/if} + + + {#if showOutput && event.content} +
+ {#if isShortOutput} +
+ +
+ {:else} + + {#if outputExpanded} +
+ +
+ {:else} + + {/if} + {/if} +
+ {/if} +
diff --git a/packages/renderer/src/navigation.ts b/packages/renderer/src/navigation.ts index f73b5646bf..dc82d053d9 100644 --- a/packages/renderer/src/navigation.ts +++ b/packages/renderer/src/navigation.ts @@ -221,5 +221,14 @@ export const handleNavigation = (request: InferredNavigationRequest = writable([]); + +let readyToUpdate = false; + +export async function checkForUpdate(eventName: string): Promise { + if ('extensions-already-started' === eventName) { + readyToUpdate = true; + } + return readyToUpdate; +} + +const listSessions = async (): Promise => { + return window.listAcpSessions(); +}; + +export const acpSessionsEventStore = new EventStore( + 'acp-sessions', + acpSessions, + checkForUpdate, + ['acp-session-update'], + ['extensions-already-started'], + listSessions, +); +acpSessionsEventStore.setup(); diff --git a/packages/renderer/src/stores/navigation/navigation-registry-acp-sessions.svelte.ts b/packages/renderer/src/stores/navigation/navigation-registry-acp-sessions.svelte.ts new file mode 100644 index 0000000000..8085ffc20d --- /dev/null +++ b/packages/renderer/src/stores/navigation/navigation-registry-acp-sessions.svelte.ts @@ -0,0 +1,49 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { faRobot } from '@fortawesome/free-solid-svg-icons/faRobot'; +import { get } from 'svelte/store'; + +import { acpSessions } from '/@/stores/acp-sessions.svelte'; + +import type { NavigationRegistryEntry } from './navigation-registry'; + +export function createNavigationAcpSessionsEntry(): NavigationRegistryEntry { + const registry: NavigationRegistryEntry = { + name: 'Agents', + icon: { faIcon: { definition: faRobot, size: 'lg' } }, + link: '/acp-sessions', + tooltip: 'Agent Sessions', + type: 'entry', + hidden: true, + get counter() { + return get(acpSessions).filter(s => s.status === 'waiting_input').length; + }, + }; + + window + .isOpenshellAvailable() + ?.then(available => { + registry.hidden = !available; + }) + ?.catch(() => { + registry.hidden = true; + }); + + return registry; +} diff --git a/packages/renderer/src/stores/navigation/navigation-registry.ts b/packages/renderer/src/stores/navigation/navigation-registry.ts index eb46070190..dc2136e1d1 100644 --- a/packages/renderer/src/stores/navigation/navigation-registry.ts +++ b/packages/renderer/src/stores/navigation/navigation-registry.ts @@ -24,6 +24,7 @@ import type { IconSize } from 'svelte-fa'; import { configurationProperties } from '/@/stores/configurationProperties'; import { EventStore } from '/@/stores/event-store'; +import { createNavigationAcpSessionsEntry } from './navigation-registry-acp-sessions.svelte'; import { createNavigationAgentWorkspacesEntry } from './navigation-registry-agent-workspaces.svelte'; import { createNavigationCodingAgentsEntry } from './navigation-registry-coding-agents.svelte'; import { createNavigationExtensionEntry, createNavigationExtensionGroup } from './navigation-registry-extension.svelte'; @@ -65,6 +66,7 @@ let hiddenItems: string[] = []; let values: NavigationRegistryEntry[] = []; let initialized = false; const init = (): void => { + values.push(createNavigationAcpSessionsEntry()); values.push(createNavigationAgentWorkspacesEntry()); values.push(createNavigationProjectsEntry()); values.push(createNavigationCodingAgentsEntry());