diff --git a/mateclaw-server/src/main/java/vip/mate/acp/client/AcpStdioClient.java b/mateclaw-server/src/main/java/vip/mate/acp/client/AcpStdioClient.java new file mode 100644 index 00000000..ddd02f3e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/acp/client/AcpStdioClient.java @@ -0,0 +1,289 @@ +package vip.mate.acp.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * RFC-090 Phase 7 — minimal Java ACP (Agent Communication Protocol) + * client over stdio. + * + *

Implements just enough of the JSON-RPC 2.0 framing to: + *

    + *
  1. Spawn the agent process ({@code command} + {@code args}).
  2. + *
  3. Send {@code initialize} and capture {@code agentCapabilities} + * / {@code protocolVersion}.
  4. + *
  5. Optionally open a {@code session/new} handshake.
  6. + *
  7. Tear the process down cleanly.
  8. + *
+ * + *

This is intentionally a one-shot connection tester (RFC §10.2 Q3 + * recommended starting order: codex → claude → opencode → qwen). Full + * bidirectional session prompting / streaming / permission requests is + * a future increment — that needs a proper async bus and ties into the + * agent graph layer. + * + *

Why not the official {@code acp} Python SDK: MateClaw runs on the + * JVM. The protocol is JSON-RPC 2.0 line-delimited over stdio (per the + * QwenPaw reference at {@code C:/codes/QwenPaw}); the surface we need + * for "test connection" is small enough to implement directly. + * + *

Each {@link AcpStdioClient} instance owns one Process. Use + * try-with-resources or call {@link #close()} explicitly. + */ +@Slf4j +public class AcpStdioClient implements AutoCloseable { + + /** ACP protocol version we advertise (matches QwenPaw v1 + Zed agents). */ + public static final int PROTOCOL_VERSION = 1; + + private final ObjectMapper mapper; + private final Process process; + private final Writer stdin; + private final BufferedReader stdout; + private final Thread readerThread; + private final AtomicLong nextRequestId = new AtomicLong(1); + private final Map> pending = new ConcurrentHashMap<>(); + private volatile boolean closed = false; + + private AcpStdioClient(ObjectMapper mapper, Process process) { + this.mapper = mapper; + this.process = process; + this.stdin = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8); + this.stdout = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + this.readerThread = new Thread(this::readLoop, "acp-stdio-reader"); + this.readerThread.setDaemon(true); + this.readerThread.start(); + } + + /** + * Spawn the configured agent process. Caller is responsible for + * closing the returned client; failure to do so leaks a child + * process. + */ + public static AcpStdioClient spawn(ObjectMapper mapper, + String command, + List args, + Map envOverrides, + String cwd) + throws IOException { + if (command == null || command.isBlank()) { + throw new IllegalArgumentException("ACP command is required"); + } + java.util.List cmdline = new java.util.ArrayList<>(); + cmdline.add(command); + if (args != null) cmdline.addAll(args); + ProcessBuilder pb = new ProcessBuilder(cmdline); + Map env = pb.environment(); + if (envOverrides != null) env.putAll(envOverrides); + if (cwd != null && !cwd.isBlank()) { + pb.directory(new java.io.File(cwd)); + } + // Keep stderr separate from stdout so we don't poison JSON-RPC + // framing when the child agent writes a banner / log line. + pb.redirectErrorStream(false); + Process proc = pb.start(); + // Drain stderr in the background — many CLIs print diagnostics + // there (e.g. Zed agents print version on startup). + Thread errDrain = new Thread(() -> drainStream(proc.getErrorStream()), "acp-stdio-stderr"); + errDrain.setDaemon(true); + errDrain.start(); + return new AcpStdioClient(mapper, proc); + } + + private static void drainStream(java.io.InputStream in) { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + if (log.isDebugEnabled()) log.debug("[acp-stderr] {}", line); + } + } catch (IOException ignore) { + // Process exited; nothing to do. + } + } + + /** + * Send {@code initialize} and wait for the response. Returns the + * response payload's {@code result} object, or throws on protocol + * mismatch / timeout. + */ + public JsonNode initialize(long timeoutMillis) throws IOException, InterruptedException { + ObjectNode params = mapper.createObjectNode(); + params.put("protocolVersion", PROTOCOL_VERSION); + // ClientCapabilities — we don't yet implement any client-side + // optional features. Send an empty object so strict agents + // don't reject the request. + params.set("clientCapabilities", mapper.createObjectNode()); + ObjectNode info = mapper.createObjectNode(); + info.put("name", "mateclaw-acp-client"); + info.put("version", "1.0.0"); + params.set("clientInfo", info); + return sendRequest("initialize", params, timeoutMillis); + } + + /** + * Send {@code session/new} — establishes a session for prompting. + * For the connection-test path we don't actually prompt, just + * verify the server accepts the handshake. + */ + public JsonNode newSession(String cwd, long timeoutMillis) + throws IOException, InterruptedException { + ObjectNode params = mapper.createObjectNode(); + if (cwd != null && !cwd.isBlank()) params.put("cwd", cwd); + params.set("mcpServers", mapper.createArrayNode()); + return sendRequest("session/new", params, timeoutMillis); + } + + /** + * Lower-level request helper. Synchronously awaits the response + * matching the request id. Server-pushed requests (e.g. permission + * prompts) are dropped — the test-only connection path doesn't need + * to handle them. + */ + public JsonNode sendRequest(String method, JsonNode params, long timeoutMillis) + throws IOException, InterruptedException { + if (closed) throw new IOException("ACP client is closed"); + long id = nextRequestId.getAndIncrement(); + CompletableFuture future = new CompletableFuture<>(); + pending.put(id, future); + + ObjectNode envelope = mapper.createObjectNode(); + envelope.put("jsonrpc", "2.0"); + envelope.put("id", id); + envelope.put("method", method); + envelope.set("params", params); + + synchronized (stdin) { + stdin.write(mapper.writeValueAsString(envelope)); + stdin.write('\n'); + stdin.flush(); + } + + try { + return future.get(timeoutMillis, TimeUnit.MILLISECONDS); + } catch (java.util.concurrent.ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof IOException io) throw io; + throw new IOException("ACP request failed: " + (cause != null ? cause.getMessage() : "unknown")); + } catch (java.util.concurrent.TimeoutException e) { + pending.remove(id); + throw new IOException("ACP request timed out after " + timeoutMillis + "ms"); + } + } + + private void readLoop() { + try { + String line; + while (!closed && (line = stdout.readLine()) != null) { + if (line.isEmpty()) continue; + try { + JsonNode msg = mapper.readTree(line); + routeMessage(msg); + } catch (Exception e) { + log.warn("ACP malformed line, skipping: {}", e.getMessage()); + } + } + } catch (IOException e) { + if (!closed) { + log.debug("ACP stdio reader closed: {}", e.getMessage()); + } + } finally { + // If the process exited mid-await, fail every pending future. + for (Map.Entry> entry : pending.entrySet()) { + entry.getValue().completeExceptionally( + new IOException("ACP process exited before responding")); + } + pending.clear(); + } + } + + private void routeMessage(JsonNode msg) { + JsonNode idNode = msg.get("id"); + if (idNode != null && idNode.isNumber()) { + long id = idNode.asLong(); + CompletableFuture future = pending.remove(id); + if (future != null) { + JsonNode error = msg.get("error"); + if (error != null && !error.isNull()) { + future.completeExceptionally( + new IOException("ACP error: " + error.toString())); + } else { + future.complete(msg.get("result")); + } + return; + } + } + // Server-initiated request or notification — for the test path + // we only need to politely decline. A future bidirectional + // session loop will dispatch these to handlers. + if (msg.has("method") && msg.has("id")) { + // Reply with a "method not implemented" so the agent doesn't hang. + try { + ObjectNode reply = mapper.createObjectNode(); + reply.put("jsonrpc", "2.0"); + reply.set("id", msg.get("id")); + ObjectNode error = mapper.createObjectNode(); + error.put("code", -32601); + error.put("message", "Method not implemented in test client: " + + msg.path("method").asText("")); + reply.set("error", error); + synchronized (stdin) { + stdin.write(mapper.writeValueAsString(reply)); + stdin.write('\n'); + stdin.flush(); + } + } catch (IOException e) { + log.debug("ACP failed to reply to server-initiated request: {}", e.getMessage()); + } + } + } + + @Override + public void close() { + closed = true; + try { + stdin.close(); + } catch (IOException ignore) { + /* best effort */ + } + try { + // Give the agent ~1s to exit gracefully after EOF on stdin. + if (!process.waitFor(1, TimeUnit.SECONDS)) { + process.destroy(); + if (!process.waitFor(1, TimeUnit.SECONDS)) { + process.destroyForcibly(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + try { + stdout.close(); + } catch (IOException ignore) { + /* best effort */ + } + } + + /** Convenience for callers that just want a fresh empty env map. */ + public static Map emptyEnv() { + return new HashMap<>(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/acp/controller/AcpEndpointController.java b/mateclaw-server/src/main/java/vip/mate/acp/controller/AcpEndpointController.java new file mode 100644 index 00000000..5127c5b4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/acp/controller/AcpEndpointController.java @@ -0,0 +1,79 @@ +package vip.mate.acp.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import vip.mate.acp.model.AcpEndpointEntity; +import vip.mate.acp.service.AcpConnectionTester; +import vip.mate.acp.service.AcpEndpointService; +import vip.mate.common.result.R; + +import java.util.List; +import java.util.Map; + +/** + * RFC-090 Phase 7 — REST surface for managing ACP endpoints. + * + *

Mirrors the McpServers controller so the frontend page can be a + * close cousin of {@code McpServers.vue}. + */ +@Tag(name = "ACP Endpoints (RFC-090 Phase 7)") +@RestController +@RequestMapping("/api/v1/acp/endpoints") +@RequiredArgsConstructor +public class AcpEndpointController { + + private final AcpEndpointService service; + private final AcpConnectionTester tester; + + @Operation(summary = "List ACP endpoints") + @GetMapping + public R> list() { + return R.ok(service.list()); + } + + @Operation(summary = "Get ACP endpoint by id") + @GetMapping("/{id}") + public R get(@PathVariable Long id) { + return R.ok(service.get(id)); + } + + @Operation(summary = "Create a custom ACP endpoint") + @PostMapping + public R create(@RequestBody AcpEndpointEntity body) { + return R.ok(service.create(body)); + } + + @Operation(summary = "Update an ACP endpoint") + @PutMapping("/{id}") + public R update(@PathVariable Long id, + @RequestBody AcpEndpointEntity body) { + return R.ok(service.update(id, body)); + } + + @Operation(summary = "Delete an ACP endpoint (builtins are protected)") + @DeleteMapping("/{id}") + public R delete(@PathVariable Long id) { + service.delete(id); + return R.ok(); + } + + @Operation(summary = "Enable / disable an ACP endpoint") + @PutMapping("/{id}/toggle") + public R toggle(@PathVariable Long id, + @RequestParam boolean enabled) { + return R.ok(service.toggle(id, enabled)); + } + + /** + * Spawn the configured CLI, run {@code initialize} + {@code + * session/new}, persist the outcome, and return diagnostics. + */ + @Operation(summary = "Test ACP endpoint connection (initialize handshake)") + @PostMapping("/{id}/test") + public R> test(@PathVariable Long id) { + AcpEndpointEntity endpoint = service.get(id); + return R.ok(tester.testEndpoint(endpoint)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/acp/model/AcpEndpointEntity.java b/mateclaw-server/src/main/java/vip/mate/acp/model/AcpEndpointEntity.java new file mode 100644 index 00000000..f90cef02 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/acp/model/AcpEndpointEntity.java @@ -0,0 +1,79 @@ +package vip.mate.acp.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * RFC-090 Phase 7 — ACP (Agent Communication Protocol) endpoint registry. + * + *

Each row describes one external coding agent that MateClaw can + * delegate to over stdio (codex / claude-code / opencode / qwen-code by + * default). Bundled via Flyway V68 so the user only has to enable the + * row once the matching CLI is on their PATH. + */ +@Data +@TableName("mate_acp_endpoint") +public class AcpEndpointEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** Stable slug, lowercase. Referenced by skill manifests via {@code type: acp} + {@code endpoint:}. */ + private String name; + + private String displayName; + private String description; + + /** Process command, e.g. {@code npx} or {@code codex}. */ + private String command; + + /** + * JSON array of CLI args, e.g. {@code ["-y","@zed-industries/codex-acp"]}. + * MyBatis Plus stores it as a string; the service layer parses on read. + */ + @TableField(value = "args_json", updateStrategy = FieldStrategy.ALWAYS) + private String argsJson; + + /** JSON object of environment variables to inject (merged onto System.getenv()). */ + @TableField(value = "env_json", updateStrategy = FieldStrategy.ALWAYS) + private String envJson; + + /** + * call_title | call_detail | update_detail (mirrors QwenPaw + * {@code tool_parse_mode}). Drives how the wrapper renders ACP + * tool-call events into MateClaw's stream protocol. + */ + private String toolParseMode; + + private Boolean builtin; + /** When true, accept the agent's tool calls without re-prompting the user. */ + private Boolean trusted; + private Boolean enabled; + + /** Stdio buffer ceiling in bytes; defaults to 50 MiB. */ + private Long stdioBufferLimitBytes; + + /** UNKNOWN / OK / ERROR — last test result. */ + private String lastStatus; + + private LocalDateTime lastTestedAt; + @TableField(value = "last_error", updateStrategy = FieldStrategy.ALWAYS) + private String lastError; + + private Long workspaceId; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/acp/repository/AcpEndpointMapper.java b/mateclaw-server/src/main/java/vip/mate/acp/repository/AcpEndpointMapper.java new file mode 100644 index 00000000..0fafe86c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/acp/repository/AcpEndpointMapper.java @@ -0,0 +1,12 @@ +package vip.mate.acp.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.acp.model.AcpEndpointEntity; + +/** + * RFC-090 Phase 7 — MyBatis Plus mapper for {@link AcpEndpointEntity}. + */ +@Mapper +public interface AcpEndpointMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpConnectionTester.java b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpConnectionTester.java new file mode 100644 index 00000000..656b3179 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpConnectionTester.java @@ -0,0 +1,119 @@ +package vip.mate.acp.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.acp.client.AcpStdioClient; +import vip.mate.acp.model.AcpEndpointEntity; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * RFC-090 Phase 7 — connection tester for ACP endpoints. + * + *

Runs the {@code initialize} + {@code session/new} handshake, with + * a generous-but-bounded timeout, and persists the outcome on the row. + * The wired CLI doesn't have to be installed for the user to add a row; + * they can install it later and re-run the test. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AcpConnectionTester { + + /** Hard cap so a hung CLI doesn't block the request thread forever. */ + private static final long INITIALIZE_TIMEOUT_MS = 15_000L; + private static final long SESSION_NEW_TIMEOUT_MS = 10_000L; + + private final ObjectMapper objectMapper; + private final AcpEndpointService endpointService; + + /** + * Spawn the configured agent, exchange initialize + session/new, + * tear it down, and return a structured result. The endpoint row + * is updated with {@code last_status / last_tested_at / last_error}. + */ + public Map testEndpoint(AcpEndpointEntity endpoint) { + long started = System.currentTimeMillis(); + Map result = new LinkedHashMap<>(); + result.put("name", endpoint.getName()); + result.put("command", endpoint.getCommand()); + + List args = endpointService.parseArgs(endpoint); + Map env = endpointService.parseEnv(endpoint); + result.put("args", args); + + AcpStdioClient client; + try { + client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(), + args, env, /* cwd */ null); + } catch (Exception e) { + return persistAndReturn(endpoint, result, "ERROR", + "Spawn failed: " + e.getMessage(), started); + } + + try (AcpStdioClient autoClose = client) { + JsonNode initResp; + try { + initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS); + } catch (Exception e) { + return persistAndReturn(endpoint, result, "ERROR", + "Initialize failed: " + e.getMessage(), started); + } + if (initResp == null) { + return persistAndReturn(endpoint, result, "ERROR", + "Initialize returned no result", started); + } + int agentProtocolVersion = initResp.path("protocolVersion").asInt(-1); + result.put("protocolVersion", agentProtocolVersion); + if (agentProtocolVersion != AcpStdioClient.PROTOCOL_VERSION) { + String msg = "Protocol mismatch: agent=" + agentProtocolVersion + + ", client=" + AcpStdioClient.PROTOCOL_VERSION; + return persistAndReturn(endpoint, result, "ERROR", msg, started); + } + // Capture agent capabilities for diagnostics — the UI can + // surface this as "supports: file_system, terminal, …". + JsonNode agentCaps = initResp.path("agentCapabilities"); + if (!agentCaps.isMissingNode() && !agentCaps.isNull()) { + result.put("agentCapabilities", agentCaps); + } + + // session/new validates that the agent really stands up a + // working session, not just initialize handshake. + try { + JsonNode sessionResp = autoClose.newSession(null, SESSION_NEW_TIMEOUT_MS); + if (sessionResp != null && sessionResp.has("sessionId")) { + result.put("sessionId", sessionResp.path("sessionId").asText("")); + } + } catch (Exception e) { + // session/new may fail for legitimate reasons (e.g. agent + // requires auth flow first). Still report OK on initialize + // but flag in the message. + result.put("sessionWarning", e.getMessage()); + } + } catch (Exception e) { + return persistAndReturn(endpoint, result, "ERROR", + "Connection test crashed: " + e.getMessage(), started); + } + + long elapsed = System.currentTimeMillis() - started; + result.put("elapsedMs", elapsed); + return persistAndReturn(endpoint, result, "OK", null, started); + } + + private Map persistAndReturn(AcpEndpointEntity endpoint, + Map result, + String status, + String error, + long started) { + endpointService.recordTestResult(endpoint.getId(), status, error); + result.put("status", status); + if (error != null) result.put("error", error); + result.putIfAbsent("elapsedMs", System.currentTimeMillis() - started); + return result; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java new file mode 100644 index 00000000..12d34411 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java @@ -0,0 +1,162 @@ +package vip.mate.acp.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.acp.model.AcpEndpointEntity; +import vip.mate.acp.repository.AcpEndpointMapper; +import vip.mate.exception.MateClawException; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * RFC-090 Phase 7 — CRUD layer for {@link AcpEndpointEntity}. + * + *

Keeps three guarantees: + *

    + *
  1. Builtin rows ({@code builtin=true}) cannot be hard-deleted — + * the user can only disable them. Mirrors {@code SkillService}.
  2. + *
  3. Names are unique; {@code create} validates against the live + * (non-deleted) set.
  4. + *
  5. {@code argsJson} / {@code envJson} round-trip through Jackson + * so the controller can hand structured data to the UI without + * leaking string-encoded JSON.
  6. + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AcpEndpointService { + + private final AcpEndpointMapper mapper; + private final ObjectMapper objectMapper; + + public List list() { + return mapper.selectList(new LambdaQueryWrapper() + .orderByDesc(AcpEndpointEntity::getBuiltin) + .orderByAsc(AcpEndpointEntity::getName)); + } + + public AcpEndpointEntity get(Long id) { + AcpEndpointEntity ep = mapper.selectById(id); + if (ep == null) throw new MateClawException("err.acp.endpoint_not_found", + "ACP endpoint not found: " + id); + return ep; + } + + public AcpEndpointEntity findByName(String name) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(AcpEndpointEntity::getName, name)); + } + + public AcpEndpointEntity create(AcpEndpointEntity input) { + if (input.getName() == null || input.getName().isBlank()) { + throw new MateClawException("err.acp.name_required", "ACP endpoint name is required"); + } + if (input.getCommand() == null || input.getCommand().isBlank()) { + throw new MateClawException("err.acp.command_required", "ACP endpoint command is required"); + } + if (findByName(input.getName()) != null) { + throw new MateClawException("err.acp.name_exists", + "ACP endpoint name already exists: " + input.getName()); + } + // User-created rows are never builtin; default-enable false so a + // misconfigured row can't auto-spawn a process at startup. + input.setBuiltin(false); + if (input.getEnabled() == null) input.setEnabled(false); + if (input.getTrusted() == null) input.setTrusted(true); + if (input.getToolParseMode() == null || input.getToolParseMode().isBlank()) { + input.setToolParseMode("call_title"); + } + if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) { + input.setStdioBufferLimitBytes(50L * 1024L * 1024L); + } + if (input.getWorkspaceId() == null) input.setWorkspaceId(1L); + mapper.insert(input); + log.info("Created ACP endpoint: {}", input.getName()); + return input; + } + + public AcpEndpointEntity update(Long id, AcpEndpointEntity patch) { + AcpEndpointEntity existing = get(id); + if (Boolean.TRUE.equals(existing.getBuiltin()) + && patch.getCommand() != null + && !patch.getCommand().equals(existing.getCommand())) { + throw new MateClawException("err.acp.builtin_command_locked", + "Builtin ACP endpoint command cannot be changed: " + existing.getName()); + } + // Allow surgical updates: only fields the caller actually set. + if (patch.getDisplayName() != null) existing.setDisplayName(patch.getDisplayName()); + if (patch.getDescription() != null) existing.setDescription(patch.getDescription()); + if (patch.getCommand() != null) existing.setCommand(patch.getCommand()); + if (patch.getArgsJson() != null) existing.setArgsJson(patch.getArgsJson()); + if (patch.getEnvJson() != null) existing.setEnvJson(patch.getEnvJson()); + if (patch.getToolParseMode() != null) existing.setToolParseMode(patch.getToolParseMode()); + if (patch.getTrusted() != null) existing.setTrusted(patch.getTrusted()); + if (patch.getEnabled() != null) existing.setEnabled(patch.getEnabled()); + if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) { + existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes()); + } + mapper.updateById(existing); + return existing; + } + + public void delete(Long id) { + AcpEndpointEntity existing = get(id); + if (Boolean.TRUE.equals(existing.getBuiltin())) { + throw new MateClawException("err.acp.builtin_readonly", + "Builtin ACP endpoint cannot be deleted: " + existing.getName()); + } + mapper.deleteById(id); + log.info("Deleted ACP endpoint: {}", existing.getName()); + } + + public AcpEndpointEntity toggle(Long id, boolean enabled) { + AcpEndpointEntity existing = get(id); + existing.setEnabled(enabled); + mapper.updateById(existing); + return existing; + } + + /** Persist a connection-test outcome on the row. */ + public void recordTestResult(Long id, String status, String error) { + AcpEndpointEntity existing = mapper.selectById(id); + if (existing == null) return; + existing.setLastStatus(status); + existing.setLastTestedAt(LocalDateTime.now()); + existing.setLastError(error); + mapper.updateById(existing); + } + + public List parseArgs(AcpEndpointEntity ep) { + return parseStringList(ep.getArgsJson()); + } + + public Map parseEnv(AcpEndpointEntity ep) { + if (ep.getEnvJson() == null || ep.getEnvJson().isBlank()) return Map.of(); + try { + return objectMapper.readValue(ep.getEnvJson(), + new TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to parse env_json for ACP endpoint '{}': {}", + ep.getName(), e.getMessage()); + return Map.of(); + } + } + + private List parseStringList(String json) { + if (json == null || json.isBlank()) return Collections.emptyList(); + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to parse args_json: {}", e.getMessage()); + return Collections.emptyList(); + } + } +} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V68__add_acp_endpoints.sql b/mateclaw-server/src/main/resources/db/migration/h2/V68__add_acp_endpoints.sql new file mode 100644 index 00000000..aac62861 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V68__add_acp_endpoints.sql @@ -0,0 +1,50 @@ +-- V68: ACP (Agent Communication Protocol) endpoint registry (RFC-090 Phase 7) +-- One row per external coding agent the user can delegate to over stdio. +-- The 4 default rows mirror QwenPaw's bundled set (codex / claude-code / opencode / qwen-code). +CREATE TABLE IF NOT EXISTS mate_acp_endpoint ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(64) NOT NULL, + display_name VARCHAR(128), + description TEXT, + -- Process command + args. args_json is a JSON array string. + command VARCHAR(256) NOT NULL, + args_json TEXT, + env_json TEXT, + -- Bookkeeping for the per-call parser. + -- One of call_title | call_detail | update_detail (matches QwenPaw tool_parse_mode). + tool_parse_mode VARCHAR(32) NOT NULL DEFAULT 'call_title', + builtin BOOLEAN NOT NULL DEFAULT FALSE, + trusted BOOLEAN NOT NULL DEFAULT TRUE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + -- Stdio buffer ceiling, default 50 MiB (mirrors QwenPaw default). + stdio_buffer_limit_bytes BIGINT NOT NULL DEFAULT 52428800, + last_status VARCHAR(32), + last_tested_at DATETIME, + last_error TEXT, + workspace_id BIGINT NOT NULL DEFAULT 1, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); + +-- Seed the 4 builtin endpoints (disabled by default — user opts in +-- once they have the matching CLI installed locally). Idempotent via +-- MERGE so reruns don't duplicate. +MERGE INTO mate_acp_endpoint + (id, name, display_name, description, command, args_json, env_json, + tool_parse_mode, builtin, trusted, enabled, + stdio_buffer_limit_bytes, workspace_id, create_time, update_time, deleted) +KEY (name) +VALUES + (9100001, 'codex', 'OpenAI Codex CLI', 'Delegate to the Codex ACP agent via npx', + 'npx', '["-y","@zed-industries/codex-acp"]', '{}', + 'call_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0), + (9100002, 'claude-code', 'Claude Code', 'Delegate to Anthropic''s Claude Code agent via npx', + 'npx', '["-y","@zed-industries/claude-agent-acp"]', '{}', + 'update_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0), + (9100003, 'opencode', 'OpenCode', 'Delegate to OpenCode ACP agent (binary on PATH)', + 'opencode', '["acp"]', '{}', + 'update_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0), + (9100004, 'qwen-code', 'Qwen Code', 'Delegate to Qwen Code ACP agent (binary on PATH)', + 'qwen', '["--acp"]', '{}', + 'call_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V68__add_acp_endpoints.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V68__add_acp_endpoints.sql new file mode 100644 index 00000000..f455de55 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V68__add_acp_endpoints.sql @@ -0,0 +1,52 @@ +-- V68: ACP (Agent Communication Protocol) endpoint registry (RFC-090 Phase 7) +-- See h2/V68 for column rationale; MySQL needs INSERT ... ON DUPLICATE KEY +-- and a unique index on name for the seed merge to be idempotent. +CREATE TABLE IF NOT EXISTS mate_acp_endpoint ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(64) NOT NULL, + display_name VARCHAR(128), + description TEXT, + command VARCHAR(256) NOT NULL, + args_json TEXT, + env_json TEXT, + tool_parse_mode VARCHAR(32) NOT NULL DEFAULT 'call_title', + builtin BOOLEAN NOT NULL DEFAULT FALSE, + trusted BOOLEAN NOT NULL DEFAULT TRUE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + stdio_buffer_limit_bytes BIGINT NOT NULL DEFAULT 52428800, + last_status VARCHAR(32), + last_tested_at DATETIME, + last_error TEXT, + workspace_id BIGINT NOT NULL DEFAULT 1, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_acp_endpoint_name (name) +); + +INSERT INTO mate_acp_endpoint + (id, name, display_name, description, command, args_json, env_json, + tool_parse_mode, builtin, trusted, enabled, + stdio_buffer_limit_bytes, workspace_id, create_time, update_time, deleted) +VALUES + (9100001, 'codex', 'OpenAI Codex CLI', 'Delegate to the Codex ACP agent via npx', + 'npx', '["-y","@zed-industries/codex-acp"]', '{}', + 'call_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0), + (9100002, 'claude-code', 'Claude Code', 'Delegate to Anthropic\'s Claude Code agent via npx', + 'npx', '["-y","@zed-industries/claude-agent-acp"]', '{}', + 'update_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0), + (9100003, 'opencode', 'OpenCode', 'Delegate to OpenCode ACP agent (binary on PATH)', + 'opencode', '["acp"]', '{}', + 'update_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0), + (9100004, 'qwen-code', 'Qwen Code', 'Delegate to Qwen Code ACP agent (binary on PATH)', + 'qwen', '["--acp"]', '{}', + 'call_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), + description = VALUES(description), + command = VALUES(command), + args_json = VALUES(args_json), + tool_parse_mode = VALUES(tool_parse_mode), + builtin = VALUES(builtin), + trusted = VALUES(trusted), + update_time = NOW(); diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 94db8b4a..1b6c0bd2 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -176,6 +176,18 @@ export const skillApi = { clearLessons: (id: string | number) => http.post(`/skills/${id}/lessons/clear`), } +// ==================== ACP Endpoints (RFC-090 Phase 7) ==================== +export const acpApi = { + list: () => http.get('/acp/endpoints'), + get: (id: number | string) => http.get(`/acp/endpoints/${id}`), + create: (data: any) => http.post('/acp/endpoints', data), + update: (id: number | string, data: any) => http.put(`/acp/endpoints/${id}`, data), + delete: (id: number | string) => http.delete(`/acp/endpoints/${id}`), + toggle: (id: number | string, enabled: boolean) => + http.put(`/acp/endpoints/${id}/toggle?enabled=${enabled}`), + test: (id: number | string) => http.post(`/acp/endpoints/${id}/test`), +} + // ==================== Skill Templates (RFC-091) ==================== export const skillTemplateApi = { list: () => http.get('/skill-templates'), diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index cea8c1e6..7e48e350 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -248,6 +248,7 @@ export default { mcpConnections: 'MCP Connections', toolsCatalog: 'Tools (Catalog)', activity: 'Activity', + acpEndpoints: 'ACP Endpoints', settingsGroup: 'Settings', agents: 'Agents', security: 'Security', @@ -1049,6 +1050,45 @@ export default { loadFailed: 'Failed to load token usage', noData: 'No token usage data', }, + acp: { + kicker: 'External Agents', + title: 'ACP Endpoints', + desc: 'Delegate prompts to external coding agents (codex / claude-code / opencode / qwen-code) over stdio. Enable an endpoint after installing its CLI.', + addEndpoint: 'Add Endpoint', + builtin: 'Builtin', + test: 'Test', + testing: 'Testing...', + statusUnknown: 'untested', + empty: 'No ACP endpoints yet.', + loadFailed: 'Failed to load ACP endpoints', + saveFailed: 'Failed to save endpoint', + deleteTitle: 'Confirm Delete', + deleteConfirm: 'Delete ACP endpoint "{name}"? Builtin endpoints are protected.', + deleteFailed: 'Failed to delete endpoint', + toggleFailed: 'Failed to toggle endpoint', + invalidJson: 'Invalid JSON', + columns: { + name: 'Endpoint', + command: 'Command', + status: 'Last Test', + enabled: 'Enabled', + actions: 'Actions', + }, + fields: { + name: 'Name (slug)', + displayName: 'Display name', + description: 'Description', + command: 'Command', + args: 'Args (JSON array)', + env: 'Env (JSON object)', + toolParseMode: 'Tool parse mode', + enabled: 'Enabled', + }, + modal: { + newTitle: 'Add ACP Endpoint', + editTitle: 'Edit ACP Endpoint', + }, + }, mcp: { kicker: 'Capability Bridge', title: 'MCP Connections', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index beb135c3..6c43b2b3 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -248,6 +248,7 @@ export default { mcpConnections: 'MCP 连接', toolsCatalog: '工具目录', activity: '活动记录', + acpEndpoints: 'ACP Endpoints', settingsGroup: '设置', agents: '智能体', security: '安全', @@ -1049,6 +1050,45 @@ export default { loadFailed: '加载 Token 统计失败', noData: '暂无 Token 使用数据', }, + acp: { + kicker: '外部 Agent', + title: 'ACP Endpoints', + desc: '通过 stdio 委派给外部编码 Agent(codex / claude-code / opencode / qwen-code)。安装对应 CLI 后开启该入口。', + addEndpoint: '新增 Endpoint', + builtin: '内置', + test: '测试', + testing: '测试中…', + statusUnknown: '未测试', + empty: '暂无 ACP endpoint。', + loadFailed: '加载 ACP endpoint 列表失败', + saveFailed: '保存 endpoint 失败', + deleteTitle: '确认删除', + deleteConfirm: '确认删除 ACP endpoint "{name}"?内置 endpoint 不可删除。', + deleteFailed: '删除 endpoint 失败', + toggleFailed: '切换 endpoint 状态失败', + invalidJson: 'JSON 格式错误', + columns: { + name: 'Endpoint', + command: '命令', + status: '最近一次测试', + enabled: '启用', + actions: '操作', + }, + fields: { + name: '标识 (slug)', + displayName: '显示名', + description: '描述', + command: '命令', + args: '参数 (JSON 数组)', + env: '环境变量 (JSON 对象)', + toolParseMode: 'Tool 解析模式', + enabled: '启用', + }, + modal: { + newTitle: '新增 ACP Endpoint', + editTitle: '编辑 ACP Endpoint', + }, + }, mcp: { kicker: '能力桥接', title: 'MCP 连接', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index 472e07a3..f3926550 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -170,6 +170,13 @@ const router = createRouter({ component: () => import('@/views/Tools.vue'), meta: { title: 'Settings - Tools Catalog' }, }, + // RFC-090 Phase 7: ACP endpoints (External coding agents) + { + path: 'acp', + name: 'SettingsAcpEndpoints', + component: () => import('@/views/AcpEndpoints.vue'), + meta: { title: 'Settings - ACP Endpoints' }, + }, { path: 'token-usage', name: 'SettingsTokenUsage', diff --git a/mateclaw-ui/src/views/AcpEndpoints.vue b/mateclaw-ui/src/views/AcpEndpoints.vue new file mode 100644 index 00000000..7fc40278 --- /dev/null +++ b/mateclaw-ui/src/views/AcpEndpoints.vue @@ -0,0 +1,384 @@ + + + + + diff --git a/mateclaw-ui/src/views/Settings/Layout.vue b/mateclaw-ui/src/views/Settings/Layout.vue index 8487ce74..3a764aad 100644 --- a/mateclaw-ui/src/views/Settings/Layout.vue +++ b/mateclaw-ui/src/views/Settings/Layout.vue @@ -169,6 +169,13 @@ const sections = computed(() => [ label: t('nav.toolsCatalog'), icon: '', }, + // RFC-090 Phase 7: ACP endpoints + { + id: 'acp', + path: '/settings/acp', + label: t('nav.acpEndpoints'), + icon: '', + }, { id: 'token-usage', path: '/settings/token-usage',