feat(skill): ACP integration for external coding agents

This commit is contained in:
matevip 2026-05-01 09:49:22 +08:00
parent f0991f543f
commit 020a87ee7e
14 changed files with 1332 additions and 0 deletions

View File

@ -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.
*
* <p>Implements just enough of the JSON-RPC 2.0 framing to:
* <ol>
* <li>Spawn the agent process ({@code command} + {@code args}).</li>
* <li>Send {@code initialize} and capture {@code agentCapabilities}
* / {@code protocolVersion}.</li>
* <li>Optionally open a {@code session/new} handshake.</li>
* <li>Tear the process down cleanly.</li>
* </ol>
*
* <p>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.
*
* <p>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.
*
* <p>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<Long, CompletableFuture<JsonNode>> 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<String> args,
Map<String, String> envOverrides,
String cwd)
throws IOException {
if (command == null || command.isBlank()) {
throw new IllegalArgumentException("ACP command is required");
}
java.util.List<String> cmdline = new java.util.ArrayList<>();
cmdline.add(command);
if (args != null) cmdline.addAll(args);
ProcessBuilder pb = new ProcessBuilder(cmdline);
Map<String, String> 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<JsonNode> 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<Long, CompletableFuture<JsonNode>> 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<JsonNode> 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<String, String> emptyEnv() {
return new HashMap<>();
}
}

View File

@ -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.
*
* <p>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<AcpEndpointEntity>> list() {
return R.ok(service.list());
}
@Operation(summary = "Get ACP endpoint by id")
@GetMapping("/{id}")
public R<AcpEndpointEntity> get(@PathVariable Long id) {
return R.ok(service.get(id));
}
@Operation(summary = "Create a custom ACP endpoint")
@PostMapping
public R<AcpEndpointEntity> create(@RequestBody AcpEndpointEntity body) {
return R.ok(service.create(body));
}
@Operation(summary = "Update an ACP endpoint")
@PutMapping("/{id}")
public R<AcpEndpointEntity> 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<Void> delete(@PathVariable Long id) {
service.delete(id);
return R.ok();
}
@Operation(summary = "Enable / disable an ACP endpoint")
@PutMapping("/{id}/toggle")
public R<AcpEndpointEntity> 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<Map<String, Object>> test(@PathVariable Long id) {
AcpEndpointEntity endpoint = service.get(id);
return R.ok(tester.testEndpoint(endpoint));
}
}

View File

@ -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.
*
* <p>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;
}

View File

@ -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<AcpEndpointEntity> {
}

View File

@ -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.
*
* <p>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<String, Object> testEndpoint(AcpEndpointEntity endpoint) {
long started = System.currentTimeMillis();
Map<String, Object> result = new LinkedHashMap<>();
result.put("name", endpoint.getName());
result.put("command", endpoint.getCommand());
List<String> args = endpointService.parseArgs(endpoint);
Map<String, String> 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<String, Object> persistAndReturn(AcpEndpointEntity endpoint,
Map<String, Object> 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;
}
}

View File

@ -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}.
*
* <p>Keeps three guarantees:
* <ol>
* <li>Builtin rows ({@code builtin=true}) cannot be hard-deleted
* the user can only disable them. Mirrors {@code SkillService}.</li>
* <li>Names are unique; {@code create} validates against the live
* (non-deleted) set.</li>
* <li>{@code argsJson} / {@code envJson} round-trip through Jackson
* so the controller can hand structured data to the UI without
* leaking string-encoded JSON.</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AcpEndpointService {
private final AcpEndpointMapper mapper;
private final ObjectMapper objectMapper;
public List<AcpEndpointEntity> list() {
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
.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<AcpEndpointEntity>()
.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<String> parseArgs(AcpEndpointEntity ep) {
return parseStringList(ep.getArgsJson());
}
public Map<String, String> parseEnv(AcpEndpointEntity ep) {
if (ep.getEnvJson() == null || ep.getEnvJson().isBlank()) return Map.of();
try {
return objectMapper.readValue(ep.getEnvJson(),
new TypeReference<Map<String, String>>() {});
} catch (Exception e) {
log.warn("Failed to parse env_json for ACP endpoint '{}': {}",
ep.getName(), e.getMessage());
return Map.of();
}
}
private List<String> parseStringList(String json) {
if (json == null || json.isBlank()) return Collections.emptyList();
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("Failed to parse args_json: {}", e.getMessage());
return Collections.emptyList();
}
}
}

View File

@ -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);

View File

@ -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();

View File

@ -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'),

View File

@ -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',

View File

@ -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 委派给外部编码 Agentcodex / 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 连接',

View File

@ -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',

View File

@ -0,0 +1,384 @@
<template>
<div class="page-container">
<div class="page-shell">
<div class="page-header">
<div class="page-lead">
<div class="page-kicker">{{ t('acp.kicker') }}</div>
<h1 class="page-title">{{ t('acp.title') }}</h1>
<p class="page-desc">{{ t('acp.desc') }}</p>
</div>
<div class="header-actions">
<button class="btn-primary" @click="openCreateModal">
+ {{ t('acp.addEndpoint') }}
</button>
</div>
</div>
<!-- Table -->
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>{{ t('acp.columns.name') }}</th>
<th>{{ t('acp.columns.command') }}</th>
<th class="th-center">{{ t('acp.columns.status') }}</th>
<th class="th-center">{{ t('acp.columns.enabled') }}</th>
<th>{{ t('acp.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="ep in endpoints" :key="ep.id" class="data-row">
<td>
<div class="endpoint-info">
<div class="endpoint-name">
{{ ep.displayName || ep.name }}
<span v-if="ep.builtin" class="builtin-badge">{{ t('acp.builtin') }}</span>
</div>
<div class="endpoint-slug">{{ ep.name }}</div>
<div v-if="ep.description" class="endpoint-desc">{{ ep.description }}</div>
</div>
</td>
<td class="cell-cmd">
<code>{{ ep.command }} {{ argsPreview(ep) }}</code>
</td>
<td class="th-center">
<span class="status-badge" :class="`status-${(ep.lastStatus || 'unknown').toLowerCase()}`">
{{ ep.lastStatus || t('acp.statusUnknown') }}
</span>
<div v-if="ep.lastError" class="status-error" :title="ep.lastError">
{{ ep.lastError.slice(0, 80) }}
</div>
</td>
<td class="th-center">
<label class="toggle-switch">
<input type="checkbox" :checked="ep.enabled" @change="toggle(ep)" />
<span class="toggle-slider"></span>
</label>
</td>
<td>
<div class="row-actions">
<button class="btn-link" :disabled="testingId === ep.id" @click="testEndpoint(ep)">
{{ testingId === ep.id ? t('acp.testing') : t('acp.test') }}
</button>
<button class="btn-link" @click="openEditModal(ep)">{{ t('common.edit') }}</button>
<button v-if="!ep.builtin" class="btn-link danger" @click="removeEndpoint(ep)">{{ t('common.delete') }}</button>
</div>
</td>
</tr>
<tr v-if="endpoints.length === 0">
<td colspan="5" class="empty-row">
<div class="empty-state">
<span class="empty-icon">🔌</span>
<p>{{ t('acp.empty') }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Test result panel -->
<div v-if="lastTestResult" class="test-result mc-surface-card">
<div class="test-result-head">
<span :class="`status-badge status-${(lastTestResult.status || '').toLowerCase()}`">
{{ lastTestResult.status }}
</span>
<span class="test-result-name">{{ lastTestResult.name }}</span>
<span v-if="lastTestResult.elapsedMs != null" class="test-result-elapsed">
· {{ lastTestResult.elapsedMs }}ms
</span>
<button class="btn-link" @click="lastTestResult = null">×</button>
</div>
<pre class="test-result-pre">{{ JSON.stringify(lastTestResult, null, 2) }}</pre>
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h2>{{ editing ? t('acp.modal.editTitle') : t('acp.modal.newTitle') }}</h2>
<button class="modal-close" @click="closeModal">&times;</button>
</div>
<div class="modal-body">
<div class="form-grid">
<div class="form-group">
<label class="form-label">{{ t('acp.fields.name') }} *</label>
<input v-model="form.name" class="form-input" placeholder="my-acp-agent" :disabled="!!editing" />
</div>
<div class="form-group">
<label class="form-label">{{ t('acp.fields.displayName') }}</label>
<input v-model="form.displayName" class="form-input" placeholder="My ACP Agent" />
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('acp.fields.description') }}</label>
<input v-model="form.description" class="form-input" />
</div>
<div class="form-group">
<label class="form-label">{{ t('acp.fields.command') }} *</label>
<input v-model="form.command" class="form-input mono" placeholder="npx" />
</div>
<div class="form-group">
<label class="form-label">{{ t('acp.fields.toolParseMode') }}</label>
<select v-model="form.toolParseMode" class="form-input">
<option value="call_title">call_title</option>
<option value="call_detail">call_detail</option>
<option value="update_detail">update_detail</option>
</select>
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('acp.fields.args') }}</label>
<input v-model="form.argsJson" class="form-input mono" placeholder='["-y","@zed-industries/codex-acp"]' />
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('acp.fields.env') }}</label>
<textarea v-model="form.envJson" class="form-input form-textarea mono" rows="2" placeholder='{"OPENAI_API_KEY":"..."}'></textarea>
</div>
<div class="form-group full-width">
<label class="toggle-inline">
<input type="checkbox" v-model="form.enabled" />
{{ t('acp.fields.enabled') }}
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="closeModal">{{ t('common.cancel') }}</button>
<button class="btn-primary" :disabled="!canSave" @click="saveEndpoint">{{ t('common.save') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { acpApi } from '@/api/index'
interface AcpEndpoint {
id: number
name: string
displayName?: string
description?: string
command: string
argsJson?: string
envJson?: string
toolParseMode?: string
builtin?: boolean
trusted?: boolean
enabled?: boolean
lastStatus?: string
lastError?: string
lastTestedAt?: string
stdioBufferLimitBytes?: number
}
const { t } = useI18n()
const endpoints = ref<AcpEndpoint[]>([])
const showModal = ref(false)
const editing = ref<AcpEndpoint | null>(null)
const testingId = ref<number | null>(null)
const lastTestResult = ref<any>(null)
const defaultForm = (): any => ({
name: '',
displayName: '',
description: '',
command: '',
argsJson: '[]',
envJson: '{}',
toolParseMode: 'call_title',
enabled: false,
})
const form = reactive<any>(defaultForm())
const canSave = computed(() => !!form.name && !!form.command)
onMounted(loadEndpoints)
async function loadEndpoints() {
try {
const res: any = await acpApi.list()
endpoints.value = res?.data || []
} catch (e: any) {
endpoints.value = []
ElMessage.error(typeof e === 'string' ? e : e?.message || t('acp.loadFailed'))
}
}
function argsPreview(ep: AcpEndpoint): string {
if (!ep.argsJson) return ''
try {
const parsed = JSON.parse(ep.argsJson)
if (Array.isArray(parsed)) return parsed.join(' ')
} catch { /* fall through */ }
return ep.argsJson
}
function openCreateModal() {
editing.value = null
Object.assign(form, defaultForm())
showModal.value = true
}
function openEditModal(ep: AcpEndpoint) {
editing.value = ep
Object.assign(form, defaultForm(), {
name: ep.name,
displayName: ep.displayName || '',
description: ep.description || '',
command: ep.command,
argsJson: ep.argsJson || '[]',
envJson: ep.envJson || '{}',
toolParseMode: ep.toolParseMode || 'call_title',
enabled: !!ep.enabled,
})
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
}
async function saveEndpoint() {
// Sanity-check args/env are valid JSON before sending; the server
// will tolerate empty strings, but we'd rather fail fast in UI.
try {
if (form.argsJson) JSON.parse(form.argsJson)
if (form.envJson) JSON.parse(form.envJson)
} catch (e: any) {
ElMessage.error(t('acp.invalidJson') + ': ' + (e?.message || 'parse error'))
return
}
try {
if (editing.value) {
await acpApi.update(editing.value.id, form)
} else {
await acpApi.create(form)
}
closeModal()
await loadEndpoints()
} catch (e: any) {
ElMessage.error(typeof e === 'string' ? e : e?.message || t('acp.saveFailed'))
}
}
async function removeEndpoint(ep: AcpEndpoint) {
try {
await ElMessageBox.confirm(t('acp.deleteConfirm', { name: ep.name }),
t('acp.deleteTitle'), { type: 'warning' })
} catch { return }
try {
await acpApi.delete(ep.id)
await loadEndpoints()
} catch (e: any) {
ElMessage.error(typeof e === 'string' ? e : e?.message || t('acp.deleteFailed'))
}
}
async function toggle(ep: AcpEndpoint) {
try {
await acpApi.toggle(ep.id, !ep.enabled)
await loadEndpoints()
} catch (e: any) {
ElMessage.error(typeof e === 'string' ? e : e?.message || t('acp.toggleFailed'))
}
}
async function testEndpoint(ep: AcpEndpoint) {
testingId.value = ep.id
lastTestResult.value = null
try {
const res: any = await acpApi.test(ep.id)
lastTestResult.value = res?.data || null
await loadEndpoints()
} catch (e: any) {
lastTestResult.value = {
name: ep.name,
status: 'ERROR',
error: typeof e === 'string' ? e : e?.message || 'unknown',
}
} finally {
testingId.value = null
}
}
</script>
<style scoped>
.page-container { padding: 0; height: 100%; min-height: 0; overflow: auto; }
.page-shell { display: flex; flex-direction: column; gap: 14px; padding: 22px; min-height: 100%; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.page-kicker { font-size: 11px; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 700; }
.page-title { font-size: 22px; font-weight: 700; color: var(--mc-text-primary); margin: 4px 0; }
.page-desc { color: var(--mc-text-secondary); margin: 0; font-size: 13px; }
.header-actions { display: flex; gap: 8px; }
.btn-primary { padding: 8px 14px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-secondary { padding: 8px 14px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 13px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.btn-link { background: none; border: none; color: var(--mc-primary); cursor: pointer; padding: 4px 8px; font-size: 12px; font-weight: 500; }
.btn-link:hover { color: var(--mc-primary-hover); }
.btn-link.danger { color: var(--mc-danger); }
.btn-link:disabled { color: var(--mc-text-tertiary); cursor: not-allowed; }
.table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border-light); border-radius: 14px; overflow: hidden; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th { padding: 12px 14px; text-align: left; font-size: 11px; font-weight: 700; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; background: var(--mc-bg-muted); border-bottom: 1px solid var(--mc-border); }
.data-table td { padding: 12px 14px; font-size: 13px; color: var(--mc-text-primary); border-bottom: 1px solid var(--mc-border-light); vertical-align: top; }
.data-row:hover { background: var(--mc-bg-muted); }
.th-center { text-align: center; }
.endpoint-info { display: flex; flex-direction: column; gap: 2px; }
.endpoint-name { font-weight: 600; display: flex; align-items: center; gap: 6px; }
.builtin-badge { padding: 1px 6px; background: rgba(34, 197, 94, 0.12); color: #16a34a; border-radius: 999px; font-size: 10px; font-weight: 700; text-transform: uppercase; }
.endpoint-slug { font-size: 11px; color: var(--mc-text-tertiary); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.endpoint-desc { font-size: 12px; color: var(--mc-text-secondary); margin-top: 4px; }
.cell-cmd code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; background: var(--mc-bg-sunken); padding: 2px 6px; border-radius: 4px; color: var(--mc-text-primary); display: inline-block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
.status-badge { padding: 2px 10px; border-radius: 999px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.status-ok { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
.status-error { background: var(--mc-danger-bg); color: var(--mc-danger); }
.status-unknown { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
td .status-error { background: none; color: var(--mc-text-tertiary); font-size: 11px; padding: 4px 0 0; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-actions { display: flex; gap: 4px; }
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: 0.2s; }
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
.empty-row { padding: 40px !important; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--mc-text-tertiary); }
.empty-icon { font-size: 32px; }
.test-result { padding: 14px; }
.test-result-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.test-result-name { font-weight: 600; }
.test-result-elapsed { color: var(--mc-text-tertiary); font-size: 12px; }
.test-result-pre { background: var(--mc-bg-sunken); padding: 12px; border-radius: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 1.5; max-height: 360px; overflow: auto; white-space: pre-wrap; word-break: break-word; margin: 0; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
.modal { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; width: 100%; max-width: 640px; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--mc-border-light); }
.modal-header h2 { font-size: 17px; font-weight: 600; margin: 0; }
.modal-close { width: 30px; height: 30px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); font-size: 22px; line-height: 1; border-radius: 6px; }
.modal-close:hover { background: var(--mc-bg-sunken); }
.modal-body { flex: 1; overflow-y: auto; padding: 18px 22px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group.full-width { grid-column: 1 / -1; }
.form-label { font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); }
.form-input { padding: 8px 10px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 13px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); }
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1); }
.form-input.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.form-textarea { resize: vertical; }
.toggle-inline { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 22px; border-top: 1px solid var(--mc-border-light); }
</style>

View File

@ -169,6 +169,13 @@ const sections = computed(() => [
label: t('nav.toolsCatalog'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>',
},
// RFC-090 Phase 7: ACP endpoints
{
id: 'acp',
path: '/settings/acp',
label: t('nav.acpEndpoints'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
},
{
id: 'token-usage',
path: '/settings/token-usage',