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
index 788b7b49..f75ce8be 100644
--- a/mateclaw-server/src/main/java/vip/mate/acp/client/AcpStdioClient.java
+++ b/mateclaw-server/src/main/java/vip/mate/acp/client/AcpStdioClient.java
@@ -166,11 +166,23 @@ public class AcpStdioClient implements AutoCloseable {
* 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.
+ *
+ *
The {@code cwd} parameter is always written into the request
+ * body. Zed's ACP Zod schema (used by {@code @zed-industries/claude-
+ * agent-acp} and the codex variant) marks {@code cwd} as a required
+ * string and returns {@code -32602 Invalid params} when it's
+ * missing. If the caller passes null/blank we substitute the JVM
+ * working directory — a workspace-aware default lives in
+ * {@code AcpRuntimeSupport#resolveCwd}, but this fallback ensures
+ * the protocol never sees {@code undefined} regardless of caller.
*/
public JsonNode newSession(String cwd, long timeoutMillis)
throws IOException, InterruptedException {
ObjectNode params = mapper.createObjectNode();
- if (cwd != null && !cwd.isBlank()) params.put("cwd", cwd);
+ String safeCwd = (cwd == null || cwd.isBlank())
+ ? System.getProperty("user.dir", ".")
+ : cwd;
+ params.put("cwd", safeCwd);
params.set("mcpServers", mapper.createArrayNode());
return sendRequest("session/new", params, timeoutMillis);
}
diff --git a/mateclaw-server/src/main/java/vip/mate/acp/event/AcpEndpointChangedEvent.java b/mateclaw-server/src/main/java/vip/mate/acp/event/AcpEndpointChangedEvent.java
new file mode 100644
index 00000000..d7b380b6
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/acp/event/AcpEndpointChangedEvent.java
@@ -0,0 +1,27 @@
+package vip.mate.acp.event;
+
+/**
+ * Lifecycle event for ACP endpoint rows.
+ *
+ *
Published by {@code AcpEndpointService} whenever a row is created,
+ * updated, toggled, or deleted. Listened to by
+ * {@code AcpSkillBridge} so it can re-sync the auto-bridged virtual
+ * skill cards and their wrapper tool registrations without a full
+ * application restart.
+ *
+ *
Mirrors the {@code SkillWorkspaceEvent} pattern — a small immutable
+ * record carrying just enough context for listeners to fan out.
+ */
+public record AcpEndpointChangedEvent(Long endpointId, String name, Type type) {
+
+ public enum Type {
+ /** Row inserted. */
+ CREATED,
+ /** Row attributes updated (command/args/env/etc.). */
+ UPDATED,
+ /** {@code enabled} flag flipped. */
+ TOGGLED,
+ /** Row deleted. */
+ DELETED
+ }
+}
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
index 656b3179..3393b96d 100644
--- a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpConnectionTester.java
+++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpConnectionTester.java
@@ -31,6 +31,7 @@ public class AcpConnectionTester {
private final ObjectMapper objectMapper;
private final AcpEndpointService endpointService;
+ private final AcpRuntimeSupport runtimeSupport;
/**
* Spawn the configured agent, exchange initialize + session/new,
@@ -46,11 +47,16 @@ public class AcpConnectionTester {
List args = endpointService.parseArgs(endpoint);
Map env = endpointService.parseEnv(endpoint);
result.put("args", args);
+ // Same as AcpDelegationService — Zed's ACP server requires a
+ // non-blank cwd at session/new, so the connection test must
+ // also default it. The "Test" button used to fail at session/new
+ // with -32602 even when the CLI itself was healthy.
+ String resolvedCwd = runtimeSupport.resolveCwd(endpoint, null);
AcpStdioClient client;
try {
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
- args, env, /* cwd */ null);
+ args, env, resolvedCwd);
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Spawn failed: " + e.getMessage(), started);
@@ -85,15 +91,18 @@ public class AcpConnectionTester {
// 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);
+ JsonNode sessionResp = autoClose.newSession(resolvedCwd, 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());
+ // but flag in the message — translated when it smells
+ // like an auth error so the test page UI shows actionable
+ // text instead of raw JSON-RPC.
+ String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
+ result.put("sessionWarning", authHint != null ? authHint : e.getMessage());
}
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
diff --git a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java
index 02d591e0..78d1c13e 100644
--- a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java
+++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java
@@ -57,6 +57,7 @@ public class AcpDelegationService {
private final ObjectMapper objectMapper;
private final AcpEndpointService endpointService;
+ private final AcpRuntimeSupport runtimeSupport;
/**
* Run a one-shot ACP prompt against {@code endpointName}. Returns
@@ -88,12 +89,16 @@ public class AcpDelegationService {
List args = endpointService.parseArgs(endpoint);
Map env = endpointService.parseEnv(endpoint);
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
+ // Always resolve cwd to a real directory: Zed's ACP Zod schema
+ // marks cwd as a required string and rejects {@code undefined}
+ // with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
+ String resolvedCwd = runtimeSupport.resolveCwd(endpoint, cwdHint);
StringBuilder accumulator = new StringBuilder();
AcpStdioClient client;
try {
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
- args, env, cwdHint);
+ args, env, resolvedCwd);
} catch (IOException e) {
throw new MateClawException("err.acp.spawn_failed",
"Failed to spawn ACP agent '" + endpointName + "': " + e.getMessage());
@@ -109,7 +114,7 @@ public class AcpDelegationService {
"ACP protocol mismatch with endpoint '" + endpointName + "'");
}
- JsonNode session = autoClose.newSession(cwdHint, SESSION_NEW_TIMEOUT_MS);
+ JsonNode session = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
String sessionId = session == null ? null : session.path("sessionId").asText("");
if (sessionId == null || sessionId.isBlank()) {
throw new MateClawException("err.acp.session_failed",
@@ -123,6 +128,15 @@ public class AcpDelegationService {
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
+ // Upstream CLIs (claude-code / codex / qwen-code) wrap their
+ // own auth failures in opaque JSON-RPC noise. Recognise the
+ // 401/403/forbidden/unauthorized fingerprints and rewrite
+ // the message into something the user can act on, with the
+ // exact env var name they need to set.
+ String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
+ if (authHint != null) {
+ throw new MateClawException("err.acp.auth_failed", authHint);
+ }
throw new MateClawException("err.acp.delegation_failed",
"ACP delegation to '" + endpointName + "' failed: " + e.getMessage());
}
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
index 12d34411..fd2e7f60 100644
--- a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java
+++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java
@@ -5,7 +5,9 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
+import vip.mate.acp.event.AcpEndpointChangedEvent;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.acp.repository.AcpEndpointMapper;
import vip.mate.exception.MateClawException;
@@ -36,6 +38,7 @@ public class AcpEndpointService {
private final AcpEndpointMapper mapper;
private final ObjectMapper objectMapper;
+ private final ApplicationEventPublisher eventPublisher;
public List list() {
return mapper.selectList(new LambdaQueryWrapper()
@@ -43,6 +46,17 @@ public class AcpEndpointService {
.orderByAsc(AcpEndpointEntity::getName));
}
+ /**
+ * Subset of {@link #list()} that returns only enabled rows.
+ * Used by {@code AcpSkillBridge} to enumerate virtual skill cards
+ * (one per enabled endpoint).
+ */
+ public List listEnabled() {
+ return mapper.selectList(new LambdaQueryWrapper()
+ .eq(AcpEndpointEntity::getEnabled, true)
+ .orderByAsc(AcpEndpointEntity::getName));
+ }
+
public AcpEndpointEntity get(Long id) {
AcpEndpointEntity ep = mapper.selectById(id);
if (ep == null) throw new MateClawException("err.acp.endpoint_not_found",
@@ -80,6 +94,7 @@ public class AcpEndpointService {
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
mapper.insert(input);
log.info("Created ACP endpoint: {}", input.getName());
+ publish(input, AcpEndpointChangedEvent.Type.CREATED);
return input;
}
@@ -104,6 +119,7 @@ public class AcpEndpointService {
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
}
mapper.updateById(existing);
+ publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
return existing;
}
@@ -115,15 +131,29 @@ public class AcpEndpointService {
}
mapper.deleteById(id);
log.info("Deleted ACP endpoint: {}", existing.getName());
+ publish(existing, AcpEndpointChangedEvent.Type.DELETED);
}
public AcpEndpointEntity toggle(Long id, boolean enabled) {
AcpEndpointEntity existing = get(id);
existing.setEnabled(enabled);
mapper.updateById(existing);
+ publish(existing, AcpEndpointChangedEvent.Type.TOGGLED);
return existing;
}
+ private void publish(AcpEndpointEntity ep, AcpEndpointChangedEvent.Type type) {
+ try {
+ eventPublisher.publishEvent(new AcpEndpointChangedEvent(
+ ep.getId(), ep.getName(), type));
+ } catch (Exception e) {
+ // Listener failures must not break the CRUD path. The bridge
+ // will resync on the next ApplicationReady tick anyway.
+ log.warn("Failed to publish AcpEndpointChangedEvent for '{}': {}",
+ ep.getName(), e.getMessage());
+ }
+ }
+
/** Persist a connection-test outcome on the row. */
public void recordTestResult(Long id, String status, String error) {
AcpEndpointEntity existing = mapper.selectById(id);
diff --git a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpRuntimeSupport.java b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpRuntimeSupport.java
new file mode 100644
index 00000000..53a237c6
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpRuntimeSupport.java
@@ -0,0 +1,188 @@
+package vip.mate.acp.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import vip.mate.acp.model.AcpEndpointEntity;
+import vip.mate.workspace.core.model.WorkspaceEntity;
+import vip.mate.workspace.core.service.WorkspaceService;
+
+import java.io.File;
+import java.util.Locale;
+
+/**
+ * Shared runtime helpers for ACP code paths.
+ *
+ *
Two responsibilities, both motivated by upstream ACP servers
+ * (e.g. {@code @zed-industries/claude-agent-acp}) being strict about
+ * inputs and noisy in failure modes:
+ *
+ *
+ *
{@link #resolveCwd} — pick a non-blank cwd for {@code session/new}.
+ * Zed's ACP Zod schema marks {@code cwd} as a required string and
+ * returns {@code -32602 Invalid params} when it's missing. We
+ * prefer the endpoint's bound workspace {@code base_path} (per-
+ * workspace context) and fall back to the JVM working directory
+ * only as a last resort. Never returns null/blank.
+ *
+ *
{@link #translateAuthError} — turn upstream JSON-RPC noise like
+ * {@code "API Error: 403 {...forbidden...}"} into an actionable
+ * hint that names the env var the user actually has to set in
+ * Settings ▸ ACP Endpoints (e.g. {@code ANTHROPIC_API_KEY} for
+ * claude-code, {@code OPENAI_API_KEY} for codex). Returns
+ * {@code null} when the error doesn't smell like an auth failure.
+ *
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AcpRuntimeSupport {
+
+ private final WorkspaceService workspaceService;
+
+ /**
+ * Resolution order (first non-blank wins):
+ *
+ *
Caller-provided hint (skill manifest's {@code acp.cwd},
+ * wrapper tool {@code cwd} arg, or explicit override).
+ *
Workspace {@code base_path} when the endpoint is bound to a
+ * workspace and the workspace declares one.
+ *
{@code System.getProperty("user.dir")} — the JVM working
+ * directory at server launch. Reasonable for a single-user
+ * desktop install, but exposes the server's launch dir to the
+ * upstream agent, which is why it's last.
+ *
+ */
+ public String resolveCwd(AcpEndpointEntity endpoint, String callerHint) {
+ if (callerHint != null && !callerHint.isBlank()) {
+ return callerHint;
+ }
+ if (endpoint != null && endpoint.getWorkspaceId() != null) {
+ try {
+ WorkspaceEntity ws = workspaceService.getById(endpoint.getWorkspaceId());
+ if (ws != null && ws.getBasePath() != null && !ws.getBasePath().isBlank()) {
+ File f = new File(ws.getBasePath());
+ if (f.isDirectory()) return f.getAbsolutePath();
+ }
+ } catch (Exception e) {
+ log.debug("Workspace lookup failed for ACP cwd default (id={}): {}",
+ endpoint.getWorkspaceId(), e.getMessage());
+ }
+ }
+ return System.getProperty("user.dir", ".");
+ }
+
+ /**
+ * Detect upstream auth errors and emit an actionable hint string.
+ * Returns null when the message doesn't match — caller should keep
+ * the original error as-is.
+ *
+ *
Heuristic: looks for HTTP-like 401/403 markers OR the words
+ * {@code forbidden / unauthorized / not allowed / api key / token}
+ * in the original message (case-insensitive). The patterns are loose
+ * on purpose — different ACP CLIs phrase auth errors differently
+ * and the cost of a false positive (a slightly more verbose error
+ * banner) is much smaller than a false negative (user staring at a
+ * raw JSON-RPC blob).
+ *
+ *
Special case: a claude-code endpoint returning {@code 403
+ * "Request not allowed"} is almost always the keychain-hijack
+ * scenario rather than a wrong API key. The third-party
+ * {@code @zed-industries/claude-agent-acp} package wraps
+ * {@code @anthropic-ai/claude-agent-sdk}, whose auth dispatcher
+ * checks the macOS keychain ({@code Claude Code-credentials}) /
+ * {@code ~/.claude/credentials.json} BEFORE the
+ * {@code ANTHROPIC_API_KEY} env var. So a host that's done
+ * {@code claude login} silently shadows whatever API key the user
+ * configured in the endpoint env, and Anthropic's API rejects the
+ * subscription OAuth token (first-party-only) with the very
+ * specific {@code "Request not allowed"} error string. We detect
+ * that exact combination and surface the keychain-clearing remedy
+ * instead of the generic "set ANTHROPIC_API_KEY" hint, which
+ * doesn't apply here.
+ */
+ public String translateAuthError(AcpEndpointEntity endpoint, String originalMessage) {
+ if (originalMessage == null) return null;
+ String lower = originalMessage.toLowerCase(Locale.ROOT);
+ boolean looksLikeAuth =
+ lower.contains("403")
+ || lower.contains("401")
+ || lower.contains("forbidden")
+ || lower.contains("unauthorized")
+ || lower.contains("not allowed")
+ || lower.contains("invalid api key")
+ || lower.contains("invalid token")
+ || lower.contains("authenticate");
+ if (!looksLikeAuth) return null;
+
+ String name = endpoint != null && endpoint.getName() != null ? endpoint.getName() : "(unknown)";
+ String slug = lower(name);
+ String command = endpoint != null ? lower(endpoint.getCommand()) : "";
+
+ // Keychain-hijack detection — must come before the generic env-
+ // missing branch because both would superficially match.
+ boolean keychainHijack = lower.contains("request not allowed")
+ && (slug.contains("claude") || command.contains("claude-agent-acp"));
+ if (keychainHijack) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("ACP endpoint '").append(name).append("' upstream auth failed with ");
+ sb.append("'Request not allowed' — almost always means the host CLI's OAuth ");
+ sb.append("credentials are hijacking the SDK auth path. ");
+ sb.append("The Claude Agent SDK reads ~/.claude/ / macOS keychain BEFORE the ");
+ sb.append("ANTHROPIC_API_KEY env var, so the API key you configured here is ");
+ sb.append("never sent — Anthropic rejects the subscription OAuth token because ");
+ sb.append("third-party processes aren't allowed to use it. ");
+ sb.append("To fix: ");
+ sb.append("(macOS) run `claude logout`, or `security delete-generic-password ");
+ sb.append("-s \"Claude Code-credentials\"`; ");
+ sb.append("(Linux / Windows) delete ~/.claude/credentials.json. ");
+ sb.append("Then click Test connection again. Original: ").append(originalMessage);
+ return sb.toString();
+ }
+
+ String envVar = expectedAuthEnvVar(endpoint);
+ StringBuilder sb = new StringBuilder();
+ sb.append("ACP endpoint '").append(name).append("' upstream auth failed. ");
+ sb.append("Most likely the endpoint env has no API key. ");
+ sb.append("Edit Settings ▸ ACP Endpoints → ").append(name).append(" → env, ");
+ if (envVar != null) {
+ sb.append("add `{\"").append(envVar).append("\":\"...\"}`");
+ } else {
+ sb.append("add the appropriate API key for this CLI");
+ }
+ sb.append(". Note: claude-code / codex / qwen-code refuse OAuth tokens from their host CLIs, ");
+ sb.append("so a real API key is required. Original: ").append(originalMessage);
+ return sb.toString();
+ }
+
+ /**
+ * Best-effort guess of the API key env var the upstream CLI expects.
+ * Returns null when we don't recognise the endpoint — caller emits a
+ * generic "appropriate API key" hint instead.
+ */
+ public String expectedAuthEnvVar(AcpEndpointEntity endpoint) {
+ if (endpoint == null) return null;
+ String name = lower(endpoint.getName());
+ String command = lower(endpoint.getCommand());
+ // Match by name first (slug is the stable identifier); fall back
+ // to command keywords for user-defined rows.
+ if (name.contains("claude") || command.contains("claude-agent-acp") || command.contains("anthropic")) {
+ return "ANTHROPIC_API_KEY";
+ }
+ if (name.contains("codex") || command.contains("codex") || command.contains("openai")) {
+ return "OPENAI_API_KEY";
+ }
+ if (name.contains("qwen") || command.contains("qwen") || command.contains("dashscope")) {
+ return "DASHSCOPE_API_KEY";
+ }
+ if (name.contains("gemini") || command.contains("gemini") || command.contains("google-genai")) {
+ return "GOOGLE_API_KEY";
+ }
+ // opencode multi-model — no single canonical env var.
+ return null;
+ }
+
+ private static String lower(String s) {
+ return s == null ? "" : s.toLowerCase(Locale.ROOT);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
index c9498e21..9ecf1166 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
@@ -228,25 +228,74 @@ public class AgentBindingService {
* {@link #getEffectiveToolNames} allowlist completely.
*/
private static final Set SYSTEM_LEVEL_TOOLS = Set.of(
- // Memory write/read primitives — every agent needs these
- // regardless of skill bindings, otherwise the self-evolution
- // path collapses (§11.3 / §11.4).
+ // Structured memory primitives — used by every agent regardless
+ // of skill bindings, otherwise the self-evolution path collapses
+ // (§11.3 / §11.4).
"record_lesson",
"remember",
"remember_structured",
"recall_structured",
"forget_structured",
- // Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md)
- "read_workspace_file",
- "write_workspace_file",
- "list_workspace_files",
+ // Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md /
+ // memory/YYYY-MM-DD.md). Prior versions whitelisted
+ // "read_workspace_file" / "write_workspace_file" /
+ // "list_workspace_files" — those names match no @Tool bean; the
+ // actual function names carry the "_memory" segment, so the
+ // earlier carve-out was silently dead.
+ "list_workspace_memory_files",
+ "read_workspace_memory_file",
+ "write_workspace_memory_file",
+ "edit_workspace_memory_file",
// Skill discovery / dispatch — skills are docs, not callables;
// these helpers let the LLM read SKILL.md / run scripts.
"readSkillFile",
"runSkillScript",
- // Date/time + delegate — fundamental cross-skill utilities
- "datetime",
- "delegate_agent"
+ "listSkillFiles",
+ "listAvailableSkills",
+ // Date / time — prior whitelist had a fictional "datetime"; the
+ // real DateTimeTool exposes three separate methods.
+ "getCurrentDate",
+ "getCurrentDateTime",
+ "getCurrentTime",
+ // Multi-agent delegation — prior whitelist had "delegate_agent",
+ // but DelegateAgentTool's @Tool methods are delegateToAgent /
+ // delegateParallel / listAvailableAgents. Same dead-name bug.
+ "delegateToAgent",
+ "delegateParallel",
+ "listAvailableAgents",
+ // Document / media generation — agent-wide capabilities, never
+ // declared inside any skill manifest. Pre-Phase-2b these were
+ // universally visible; the new gate silently strips them whenever
+ // any skill is bound, breaking "generate a Word doc / image /
+ // song / video" intents on agents that happen to have a skill
+ // on. Regression observed 2026-05-01: a Code Reviewer agent with
+ // skills bound dropped renderDocx and fell back to dumping the
+ // markdown body for the user to copy.
+ "renderDocx",
+ "renderDocxFromFile",
+ "renderDocxFromFiles",
+ "image_generate",
+ "music_generate",
+ "video_generate",
+ // Universal capabilities the global system prompts (SOUL.md /
+ // AGENTS.md / "Web Search Capability" / "File Reading Guidelines")
+ // explicitly tell the LLM exist. Pre-Phase-2b they were globally
+ // available; the new gate silently hid them on any agent with
+ // skills bound, so the prompt promises a tool the registry then
+ // refuses ("Tool not found: search"). Observed 2026-05-01 on the
+ // Code Reviewer agent — the model called search → got
+ // not-found → gave up before ever reaching renderDocx.
+ "search",
+ "browser_use",
+ "read_file",
+ "write_file",
+ "edit_file",
+ "execute_shell_command",
+ "detect_file_type",
+ "extract_document_text",
+ "extract_pdf_text",
+ "extract_docx_text",
+ "readMateClawDoc"
);
private ResolvedSkill findResolvedSkillById(Long skillId) {
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java b/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java
new file mode 100644
index 00000000..35ce9eeb
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java
@@ -0,0 +1,495 @@
+package vip.mate.skill.acp;
+
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+import vip.mate.acp.event.AcpEndpointChangedEvent;
+import vip.mate.acp.model.AcpEndpointEntity;
+import vip.mate.acp.service.AcpDelegationService;
+import vip.mate.acp.service.AcpEndpointService;
+import vip.mate.skill.knowledge.SkillScopedToolCallback;
+import vip.mate.skill.manifest.SkillManifest;
+import vip.mate.skill.model.SkillEntity;
+import vip.mate.skill.runtime.model.ResolvedSkill;
+import vip.mate.tool.ToolRegistry;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * RFC-090 §3.2 / §14.4 (parallel) — ACP endpoint → virtual skill bridge.
+ *
+ *
Mirrors {@link vip.mate.skill.mcp.McpSkillBridge}: every enabled
+ * row in {@code mate_acp_endpoint} is automatically surfaced as a
+ * virtual {@link SkillEntity} + {@link ResolvedSkill}, and a wrapper
+ * tool ({@code acp__prompt}) is registered with
+ * {@link ToolRegistry} so any agent can delegate to that endpoint
+ * without manual binding.
+ *
+ *
This solves the "ACP configured as skill cannot be called"
+ * usability bug (matches the inspiration from QwenPaw's
+ * {@code delegate_external_agent} pattern, but keeps MateClaw's
+ * skill-card affordance for endpoint discovery): the user manages
+ * endpoints in Settings ▸ ACP Endpoints, and a card automatically
+ * appears on the Skills page — no per-endpoint SKILL.md authoring
+ * required.
+ *
+ *
Lifecycle:
+ *
+ *
{@link ApplicationReadyEvent} — initial wrapper registration
+ * for all enabled endpoints.
{@code list/list-status} entry points rebuild the virtual
+ * SkillEntity / ResolvedSkill snapshots on demand so the Skills
+ * page always shows current state.
+ *
+ *
+ *
ID namespace: virtual skill ids use a high sentinel
+ * {@link #VIRTUAL_ID_BASE} different from the MCP bridge's, so the two
+ * id spaces never collide and a callsite can dispatch on which bridge
+ * owns an id without coordination.
+ */
+@Slf4j
+@Service
+public class AcpSkillBridge {
+
+ /**
+ * High sentinel for ACP virtual id space. Distinct from
+ * {@code McpSkillBridge.VIRTUAL_ID_BASE} (9e18) so the two virtual
+ * spaces are partitionable by simple range checks.
+ */
+ public static final long VIRTUAL_ID_BASE = 8_000_000_000_000_000_000L;
+ /** Upper bound, exclusive — anything in [BASE, BASE + 1e17) is ours. */
+ public static final long VIRTUAL_ID_BOUND = VIRTUAL_ID_BASE + 100_000_000_000_000_000L;
+
+ private final AcpEndpointService endpointService;
+ private final AcpDelegationService delegationService;
+ private final ObjectMapper objectMapper;
+ private final ToolRegistry toolRegistry;
+
+ /**
+ * endpointId → set of registered wrapper tool names. Keeps the tool
+ * registry synced with the live endpoint set: when an endpoint is
+ * toggled off/deleted, we know exactly which tools to remove.
+ */
+ private final ConcurrentHashMap> registeredWrappers = new ConcurrentHashMap<>();
+
+ @Autowired
+ public AcpSkillBridge(AcpEndpointService endpointService,
+ AcpDelegationService delegationService,
+ ObjectMapper objectMapper,
+ @Lazy ToolRegistry toolRegistry) {
+ this.endpointService = endpointService;
+ this.delegationService = delegationService;
+ this.objectMapper = objectMapper;
+ this.toolRegistry = toolRegistry;
+ }
+
+ public static boolean isVirtualAcpSkillId(Long id) {
+ return id != null && id >= VIRTUAL_ID_BASE && id < VIRTUAL_ID_BOUND;
+ }
+
+ public static Long extractEndpointId(Long virtualId) {
+ if (!isVirtualAcpSkillId(virtualId)) return null;
+ return virtualId - VIRTUAL_ID_BASE;
+ }
+
+ public static long virtualIdFor(AcpEndpointEntity endpoint) {
+ return VIRTUAL_ID_BASE + endpoint.getId();
+ }
+
+ @PostConstruct
+ public void init() {
+ log.info("AcpSkillBridge initialized (virtual ID range: {}+endpointId)", VIRTUAL_ID_BASE);
+ }
+
+ /**
+ * Initial registration: enumerate enabled endpoints once the
+ * application is fully bootstrapped. {@link ApplicationReadyEvent}
+ * is preferred over {@code @PostConstruct} because the database
+ * bootstrap (Flyway + seed data) finishes only after the context
+ * comes up, and the endpoint table may be empty before that.
+ */
+ @EventListener(ApplicationReadyEvent.class)
+ public void onReady() {
+ try {
+ int registered = 0;
+ for (AcpEndpointEntity ep : endpointService.listEnabled()) {
+ registerWrappers(ep);
+ registered++;
+ }
+ if (registered > 0) {
+ log.info("AcpSkillBridge: registered wrappers for {} enabled endpoint(s)", registered);
+ }
+ } catch (Exception e) {
+ log.warn("AcpSkillBridge initial registration failed: {}", e.getMessage());
+ }
+ }
+
+ /**
+ * Resync wrapper registrations on every endpoint lifecycle event.
+ * Disabled / deleted endpoints have their wrappers removed; enabled
+ * endpoints are re-registered (idempotent — we deregister-then-add
+ * to avoid double registration when the user re-enables a row).
+ */
+ @EventListener(AcpEndpointChangedEvent.class)
+ public void onEndpointChanged(AcpEndpointChangedEvent event) {
+ Long endpointId = event.endpointId();
+ if (endpointId == null) return;
+ try {
+ switch (event.type()) {
+ case DELETED -> deregisterWrappers(endpointId);
+ case CREATED, UPDATED, TOGGLED -> {
+ AcpEndpointEntity ep = safeGet(endpointId);
+ if (ep == null || !Boolean.TRUE.equals(ep.getEnabled())) {
+ deregisterWrappers(endpointId);
+ } else {
+ // Idempotent: drop stale set, then build fresh —
+ // covers args/env edits where the wrapper itself
+ // doesn't change but we still want a clean slate.
+ deregisterWrappers(endpointId);
+ registerWrappers(ep);
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.warn("AcpSkillBridge resync for endpoint '{}' ({}) failed: {}",
+ event.name(), event.type(), e.getMessage());
+ }
+ }
+
+ /**
+ * Snapshot every enabled endpoint as a virtual {@link SkillEntity}.
+ * Used by the Skills list endpoint; rows are non-persistent and
+ * regenerated on each call.
+ */
+ public List listAcpDerivedSkillEntities() {
+ return safeListEnabled().stream().map(this::endpointToEntity).toList();
+ }
+
+ /**
+ * Snapshot every enabled endpoint as a virtual {@link ResolvedSkill}
+ * with synthesized manifest. Status reflects the last connection
+ * test on the row: OK → READY, ERROR / unknown → SETUP_NEEDED.
+ */
+ public List listAcpDerivedResolvedSkills() {
+ return safeListEnabled().stream().map(this::endpointToResolved).toList();
+ }
+
+ /**
+ * Lookup a single virtual ResolvedSkill by virtual id. Used by the
+ * Skill detail drawer's reverse lookup path.
+ */
+ public ResolvedSkill findResolvedById(Long virtualId) {
+ Long endpointId = extractEndpointId(virtualId);
+ if (endpointId == null) return null;
+ AcpEndpointEntity ep = safeGet(endpointId);
+ return ep != null && Boolean.TRUE.equals(ep.getEnabled()) ? endpointToResolved(ep) : null;
+ }
+
+ public SkillEntity findEntityById(Long virtualId) {
+ Long endpointId = extractEndpointId(virtualId);
+ if (endpointId == null) return null;
+ AcpEndpointEntity ep = safeGet(endpointId);
+ return ep != null && Boolean.TRUE.equals(ep.getEnabled()) ? endpointToEntity(ep) : null;
+ }
+
+ // ==================== Tool registration ====================
+
+ private void registerWrappers(AcpEndpointEntity ep) {
+ if (ep == null || !Boolean.TRUE.equals(ep.getEnabled())) return;
+ String slug = slugify(ep.getName());
+ if (slug.isEmpty()) {
+ log.warn("ACP endpoint id={} has blank name; cannot register wrapper", ep.getId());
+ return;
+ }
+ String toolName = "acp_" + slug + "_prompt";
+ String desc = String.format(
+ "Delegate a prompt to the '%s' ACP coding agent. " +
+ "Send a single-string instruction; receive the agent's final reply.%s",
+ ep.getName(),
+ ep.getDescription() == null || ep.getDescription().isBlank()
+ ? ""
+ : " — " + ep.getDescription());
+ // cwd stays optional in the schema so the LLM doesn't have to
+ // invent a path. The server defaults to the endpoint's bound
+ // workspace base_path when omitted (see AcpRuntimeSupport).
+ String schema = "{"
+ + "\"type\":\"object\","
+ + "\"properties\":{"
+ + "\"prompt\":{\"type\":\"string\",\"description\":\"the instruction or question to send\"},"
+ + "\"cwd\":{\"type\":\"string\",\"description\":\"optional working directory; "
+ + "defaults to the endpoint's workspace base path when omitted\"}"
+ + "},"
+ + "\"required\":[\"prompt\"]"
+ + "}";
+
+ final String endpointName = ep.getName();
+ final Long endpointId = ep.getId();
+
+ SkillScopedToolCallback callback = new SkillScopedToolCallback(toolName, desc, schema, input -> {
+ try {
+ JsonNode args = input == null || input.isBlank()
+ ? objectMapper.createObjectNode()
+ : objectMapper.readTree(input);
+ String userPrompt = args.path("prompt").asText("").trim();
+ if (userPrompt.isEmpty()) return errorJson("prompt is required");
+ String cwdHint = args.path("cwd").asText("");
+ String reply = delegationService.prompt(endpointName, userPrompt,
+ cwdHint == null || cwdHint.isBlank() ? null : cwdHint);
+ JSONObject resp = new JSONObject()
+ .set("endpoint", endpointName)
+ .set("reply", reply);
+ return JSONUtil.toJsonStr(resp);
+ } catch (Exception e) {
+ log.warn("ACP wrapper '{}' failed: {}", toolName, e.getMessage());
+ return errorJson(e.getMessage() == null ? "delegation failed" : e.getMessage());
+ }
+ });
+
+ // Availability supplier: re-check each agent tool-set build so
+ // a toggle-off without a deregister call still hides the tool.
+ toolRegistry.registerPluginTool(callback, () -> {
+ AcpEndpointEntity live = safeGet(endpointId);
+ return live != null && Boolean.TRUE.equals(live.getEnabled());
+ });
+ registeredWrappers.computeIfAbsent(endpointId, k -> ConcurrentHashMap.newKeySet()).add(toolName);
+ log.info("AcpSkillBridge: registered wrapper '{}' for endpoint '{}'", toolName, endpointName);
+ }
+
+ private void deregisterWrappers(Long endpointId) {
+ Set names = registeredWrappers.remove(endpointId);
+ if (names == null || names.isEmpty()) return;
+ for (String name : names) {
+ try {
+ toolRegistry.unregisterPluginTool(name);
+ } catch (Exception e) {
+ log.debug("Unregister ACP wrapper '{}' failed: {}", name, e.getMessage());
+ }
+ }
+ log.info("AcpSkillBridge: deregistered {} wrapper(s) for endpoint id={}", names.size(), endpointId);
+ }
+
+ // ==================== Synthesis ====================
+
+ private SkillEntity endpointToEntity(AcpEndpointEntity ep) {
+ SkillEntity s = new SkillEntity();
+ s.setId(virtualIdFor(ep));
+ s.setName(slugify(ep.getName()));
+ s.setNameEn(displayName(ep));
+ s.setNameZh(ep.getDescription() != null && !ep.getDescription().isBlank()
+ ? displayName(ep) : null);
+ s.setDescription(buildDescription(ep));
+ s.setSkillType("acp");
+ s.setIcon(iconFor(ep));
+ s.setVersion("1.0.0");
+ s.setAuthor("acp-bridge");
+ s.setEnabled(Boolean.TRUE.equals(ep.getEnabled()));
+ s.setBuiltin(Boolean.TRUE.equals(ep.getBuiltin()));
+ s.setTags("acp");
+ s.setSecurityScanStatus("PASSED"); // ACP endpoints are user-configured external CLIs, not skill scripts
+ s.setConfigJson(buildConfigJson(ep));
+ s.setManifestJson(serializeManifest(buildManifest(ep)));
+ return s;
+ }
+
+ private ResolvedSkill endpointToResolved(AcpEndpointEntity ep) {
+ SkillManifest manifest = buildManifest(ep);
+ boolean ok = "OK".equalsIgnoreCase(nullSafe(ep.getLastStatus()));
+ boolean errored = "ERROR".equalsIgnoreCase(nullSafe(ep.getLastStatus()))
+ || (ep.getLastError() != null && !ep.getLastError().isBlank());
+ // "Unknown" (untested) endpoints are treated as READY so the user
+ // can call them without an explicit Test click — unlike MCP, an
+ // ACP CLI is spawned per call so an untested-but-installed CLI
+ // works fine on first invocation. ERROR keeps SETUP_NEEDED.
+ boolean ready = !errored;
+
+ Map featureStatuses = new LinkedHashMap<>();
+ featureStatuses.put("default", ready ? "READY" : "SETUP_NEEDED");
+ Set active = new LinkedHashSet<>();
+ if (ready) active.add("default");
+
+ List missing = new ArrayList<>();
+ if (!ready) {
+ missing.add("acp:" + ep.getName() + " (status: " + nullSafe(ep.getLastStatus()) + ")");
+ }
+
+ String summary = ok
+ ? "ACP endpoint '" + ep.getName() + "' tested OK"
+ : (errored
+ ? "ACP endpoint '" + ep.getName() + "' last test failed: " + nullSafe(ep.getLastError())
+ : "ACP endpoint '" + ep.getName() + "' not yet tested — calls will spawn the CLI on demand");
+
+ return ResolvedSkill.builder()
+ .id(virtualIdFor(ep))
+ .name(slugify(ep.getName()))
+ .description(buildDescription(ep))
+ .content("") // no SKILL.md
+ .source("acp")
+ .skillDir(null)
+ .configuredSkillDir(null)
+ .runtimeAvailable(ready)
+ .resolutionError(ready ? null : nullSafe(ep.getLastError()))
+ .references(Map.of())
+ .scripts(Map.of())
+ .enabled(Boolean.TRUE.equals(ep.getEnabled()))
+ .icon(iconFor(ep))
+ .builtin(Boolean.TRUE.equals(ep.getBuiltin()))
+ .securityBlocked(false)
+ .securitySummary("ACP-derived skill (external CLI; not subject to SKILL.md scanning)")
+ .dependencyReady(ready)
+ .missingDependencies(missing)
+ .dependencySummary(summary)
+ .manifest(manifest)
+ .featureStatuses(featureStatuses)
+ .activeFeatures(active)
+ .build();
+ }
+
+ /**
+ * Synthesize a §10.2 minimal manifest from the live endpoint row.
+ * The wrapper tool name {@code acp__prompt} is the single
+ * advertised tool, surfaced via {@code allowedTools} + the default
+ * feature so {@code ResolvedSkill.getEffectiveAllowedTools()} picks
+ * it up the same way as a hand-authored skill manifest.
+ */
+ private SkillManifest buildManifest(AcpEndpointEntity ep) {
+ String slug = slugify(ep.getName());
+ String toolName = "acp_" + slug + "_prompt";
+ List tools = List.of(toolName);
+
+ SkillManifest.FeatureDef defaultFeature = SkillManifest.FeatureDef.builder()
+ .id("default")
+ .label(displayName(ep))
+ .requires(List.of("acp:" + ep.getName()))
+ .platforms(List.of())
+ .tools(tools)
+ .build();
+
+ SkillManifest.RequirementDef acpRequirement = SkillManifest.RequirementDef.builder()
+ .key("acp:" + ep.getName())
+ .type("acp")
+ .check(ep.getName())
+ .description("ACP endpoint '" + ep.getName() + "' must be enabled and reachable. "
+ + "Configure in Settings ▸ ACP Endpoints.")
+ .build();
+
+ SkillManifest.AcpBinding binding = SkillManifest.AcpBinding.builder()
+ .endpoint(ep.getName())
+ .resolvedEndpointId(ep.getId())
+ .build();
+
+ return SkillManifest.builder()
+ .id(slug)
+ .name(slug)
+ .description(buildDescription(ep))
+ .icon(iconFor(ep))
+ .version("1.0.0")
+ .author("acp-bridge")
+ .type("acp")
+ .category("system")
+ .allowedTools(tools)
+ .requires(List.of(acpRequirement))
+ .features(List.of(defaultFeature))
+ .acp(binding)
+ .selfEvolution(SkillManifest.SelfEvolution.builder()
+ // Bridged ACP cards don't author LESSONS.md — the
+ // upstream agent owns its own self-evolution.
+ .lessonsEnabled(false)
+ .lessonsMaxEntries(0)
+ .memoryWritesAllowed(true)
+ .build())
+ .extras(Map.of("acpEndpointId", ep.getId()))
+ .build();
+ }
+
+ // ==================== Helpers ====================
+
+ private List safeListEnabled() {
+ try {
+ return endpointService.listEnabled();
+ } catch (Exception e) {
+ log.warn("AcpSkillBridge could not list enabled endpoints: {}", e.getMessage());
+ return Collections.emptyList();
+ }
+ }
+
+ private AcpEndpointEntity safeGet(Long id) {
+ try {
+ return endpointService.get(id);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private String slugify(String raw) {
+ if (raw == null) return "";
+ return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-");
+ }
+
+ private String displayName(AcpEndpointEntity ep) {
+ if (ep.getDisplayName() != null && !ep.getDisplayName().isBlank()) return ep.getDisplayName();
+ return ep.getName() != null ? ep.getName() : "acp-" + ep.getId();
+ }
+
+ private String buildDescription(AcpEndpointEntity ep) {
+ if (ep.getDescription() != null && !ep.getDescription().isBlank()) return ep.getDescription();
+ return "ACP coding agent '" + ep.getName() + "' — delegate prompts via the "
+ + "auto-registered tool. Configure in Settings ▸ ACP Endpoints.";
+ }
+
+ private String iconFor(AcpEndpointEntity ep) {
+ // Light heuristic — match the most popular ACP runners by name.
+ String n = nullSafe(ep.getName()).toLowerCase(Locale.ROOT);
+ if (n.contains("claude")) return "🟠";
+ if (n.contains("codex") || n.contains("openai")) return "⚪";
+ if (n.contains("qwen")) return "🔵";
+ if (n.contains("gemini") || n.contains("google")) return "🟡";
+ if (n.contains("opencode")) return "🟢";
+ return "🤝";
+ }
+
+ private String buildConfigJson(AcpEndpointEntity ep) {
+ try {
+ return objectMapper.writeValueAsString(Map.of(
+ "acpEndpointId", ep.getId(),
+ "command", nullSafe(ep.getCommand()),
+ "trusted", Boolean.TRUE.equals(ep.getTrusted()),
+ "source", Map.of("type", "acp")));
+ } catch (Exception e) {
+ return "{}";
+ }
+ }
+
+ private String serializeManifest(SkillManifest manifest) {
+ try {
+ return objectMapper.writeValueAsString(manifest);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static String nullSafe(String s) {
+ return s == null ? "" : s;
+ }
+
+ private static String errorJson(String msg) {
+ return JSONUtil.createObj().set("error", msg).toString();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java
index 73733b90..35703d9a 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java
@@ -51,6 +51,7 @@ public class SkillController {
private final AgentService agentService;
private final AgentBindingService agentBindingService;
private final vip.mate.skill.mcp.McpSkillBridge mcpSkillBridge;
+ private final vip.mate.skill.acp.AcpSkillBridge acpSkillBridge;
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
@GetMapping
@@ -93,6 +94,29 @@ public class SkillController {
// Bridge failure must not break the Skills page.
}
}
+ // RFC-090 §3.2 (parallel) — same auto-bridge for ACP endpoints.
+ if (page == 1 && (skillType == null || skillType.isBlank() || "acp".equalsIgnoreCase(skillType))) {
+ try {
+ List acpSkills = acpSkillBridge.listAcpDerivedSkillEntities();
+ if (!acpSkills.isEmpty()) {
+ String kw = keyword == null ? "" : keyword.trim().toLowerCase();
+ List filtered = acpSkills.stream()
+ .filter(s -> kw.isEmpty()
+ || (s.getName() != null && s.getName().toLowerCase().contains(kw))
+ || (s.getDescription() != null && s.getDescription().toLowerCase().contains(kw)))
+ .filter(s -> enabled == null || enabled.equals(s.getEnabled()))
+ .toList();
+ if (!filtered.isEmpty()) {
+ java.util.List merged = new java.util.ArrayList<>(filtered);
+ merged.addAll(dbPage.getRecords());
+ dbPage.setRecords(merged);
+ dbPage.setTotal(dbPage.getTotal() + filtered.size());
+ }
+ }
+ } catch (Exception e) {
+ // Bridge failure must not break the Skills page.
+ }
+ }
return R.ok(dbPage);
}
@@ -112,6 +136,15 @@ public class SkillController {
} catch (Exception ignored) {
// Bridge failure must not break the badge fetch.
}
+ try {
+ long virtualAcp = acpSkillBridge.listAcpDerivedSkillEntities().size();
+ if (virtualAcp > 0) {
+ result.merge("acp", virtualAcp, Long::sum);
+ result.merge("all", virtualAcp, Long::sum);
+ }
+ } catch (Exception ignored) {
+ // Same defensive stance as the MCP bridge above.
+ }
return R.ok(result);
}
@@ -152,6 +185,11 @@ public class SkillController {
.map(R::ok)
.orElse(R.fail("MCP-derived skill not found: " + id));
}
+ // RFC-090 §3.2 (parallel) — same path for ACP-derived virtual skills.
+ if (vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(id)) {
+ SkillEntity ent = acpSkillBridge.findEntityById(id);
+ return ent != null ? R.ok(ent) : R.fail("ACP-derived skill not found: " + id);
+ }
return R.ok(skillService.getSkill(id));
}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
index 24775ba6..7178deda 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
@@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
+import vip.mate.skill.acp.AcpSkillBridge;
import vip.mate.skill.lessons.SkillLessonsService;
import vip.mate.skill.manifest.SkillManifest;
import vip.mate.skill.mcp.McpSkillBridge;
@@ -47,16 +48,23 @@ public class SkillRuntimeService {
* which boots later in the lifecycle.
*/
private final McpSkillBridge mcpSkillBridge;
+ /**
+ * RFC-090 §3.2 (parallel) — ACP-endpoint → virtual-skill bridge.
+ * Same {@code @Lazy} treatment as the MCP bridge.
+ */
+ private final AcpSkillBridge acpSkillBridge;
@Autowired
public SkillRuntimeService(SkillService skillService,
SkillPackageResolver packageResolver,
@Lazy SkillLessonsService lessonsService,
- @Lazy McpSkillBridge mcpSkillBridge) {
+ @Lazy McpSkillBridge mcpSkillBridge,
+ @Lazy AcpSkillBridge acpSkillBridge) {
this.skillService = skillService;
this.packageResolver = packageResolver;
this.lessonsService = lessonsService;
this.mcpSkillBridge = mcpSkillBridge;
+ this.acpSkillBridge = acpSkillBridge;
}
// 缓存已解析的 active skills(5分钟过期)
@@ -144,6 +152,14 @@ public class SkillRuntimeService {
} catch (Exception e) {
log.warn("MCP skill bridge active merge failed: {}", e.getMessage());
}
+ try {
+ // RFC-090 §3.2 (parallel) — ACP-derived virtual skills.
+ for (ResolvedSkill virt : acpSkillBridge.listAcpDerivedResolvedSkills()) {
+ if (passesActiveGate(virt)) resolved.add(virt);
+ }
+ } catch (Exception e) {
+ log.warn("ACP skill bridge active merge failed: {}", e.getMessage());
+ }
activeSkillsCache.put(CACHE_KEY, resolved);
log.info("Refreshed active skills: {} enabled", resolved.size());
@@ -169,6 +185,11 @@ public class SkillRuntimeService {
} catch (Exception e) {
log.warn("MCP skill bridge merge failed: {}", e.getMessage());
}
+ try {
+ resolved.addAll(acpSkillBridge.listAcpDerivedResolvedSkills());
+ } catch (Exception e) {
+ log.warn("ACP skill bridge merge failed: {}", e.getMessage());
+ }
return resolved;
}
diff --git a/mateclaw-ui/src/composables/useMarkdownRenderer.ts b/mateclaw-ui/src/composables/useMarkdownRenderer.ts
index 142ca5f0..a1bfc94d 100644
--- a/mateclaw-ui/src/composables/useMarkdownRenderer.ts
+++ b/mateclaw-ui/src/composables/useMarkdownRenderer.ts
@@ -258,9 +258,18 @@ const customRenderer = {
// Dangerous scheme — render the inner content as plain content (no anchor).
return innerHtml
}
+ // Defense against LLMs hallucinating a host on tool-returned download URLs.
+ // /api/v1/files/generated/ is always same-origin; multiple models have
+ // been observed prepending bogus schemes/hosts (https://localhost:8080,
+ // https://ai-tools-system.com, …) when echoing the URL back, breaking the
+ // download. Strip any prepended scheme://host so the link works regardless
+ // of what the model wrote.
+ const hostStripped = /^https?:\/\/[^/]+(\/api\/v1\/files\/generated\/.+)$/i.exec(href)
+ const safeHref = hostStripped ? hostStripped[1] : href
+
let extra = ''
try {
- const url = new URL(href, typeof window !== 'undefined' ? window.location.href : 'http://localhost/')
+ const url = new URL(safeHref, typeof window !== 'undefined' ? window.location.href : 'http://localhost/')
if (typeof window !== 'undefined' && url.origin !== window.location.origin) {
extra = ' target="_blank" rel="noopener noreferrer"'
}
@@ -268,7 +277,7 @@ const customRenderer = {
// Malformed URL — treat as same-origin (relative link path).
}
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''
- return `${innerHtml}`
+ return `${innerHtml}`
},
}
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index 6773df81..ec2d22e2 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -1149,10 +1149,28 @@ export default {
description: 'Description',
command: 'Command',
args: 'Args (JSON array)',
- env: 'Env (JSON object)',
+ env: 'Environment variables',
toolParseMode: 'Tool parse mode',
enabled: 'Enabled',
},
+ env: {
+ formMode: 'Edit as JSON',
+ jsonMode: 'Edit as form',
+ suggested: 'Suggested',
+ addRow: 'Add variable',
+ keyPlaceholder: 'KEY_NAME',
+ valuePlaceholder: 'value (e.g. sk-…)',
+ show: 'Show',
+ hide: 'Hide',
+ remove: 'Remove',
+ hints: {
+ anthropic: 'Anthropic API key (sk-ant-…). Get one at https://console.anthropic.com/settings/keys',
+ openai: 'OpenAI API key (sk-…). Get one at https://platform.openai.com/api-keys',
+ dashscope: 'Aliyun DashScope key — same value as MateClaw .env DASHSCOPE_API_KEY',
+ google: 'Google AI Studio API key. Get one at https://aistudio.google.com/apikey',
+ claudeOauth: 'Heads up: claude-code OAuth login (the one stored in ~/.claude/) does NOT work here, and worse — the Claude Agent SDK reads it BEFORE this env var, silently shadowing your API key. If you have ever run `claude login` on this host, clear the keychain first: (macOS) `claude logout` or `security delete-generic-password -s "Claude Code-credentials"`; (Linux/Windows) delete ~/.claude/credentials.json. Then put a real API key (sk-ant-…) above. Or switch to qwen-code / opencode to reuse a CLI login.',
+ },
+ },
modal: {
newTitle: 'Add ACP Endpoint',
editTitle: 'Edit ACP Endpoint',
@@ -2000,6 +2018,7 @@ export default {
all: 'All',
builtin: 'Built-in',
mcp: 'MCP',
+ acp: 'ACP',
dynamic: 'Dynamic',
},
search: {
@@ -2073,6 +2092,7 @@ export default {
types: {
builtin: 'Built-in',
mcp: 'MCP',
+ acp: 'ACP',
dynamic: 'Dynamic',
},
source: {
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index 288c61d5..dd04f824 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -277,7 +277,7 @@ export default {
mcpConnections: 'MCP 连接',
toolsCatalog: '工具目录',
activity: '活动记录',
- acpEndpoints: 'ACP Endpoints',
+ acpEndpoints: 'ACP 端点',
settingsGroup: '设置',
agents: '智能体',
security: '安全',
@@ -1121,23 +1121,23 @@ export default {
},
acp: {
kicker: '外部 Agent',
- title: 'ACP Endpoints',
+ title: 'ACP 端点',
desc: '通过 stdio 委派给外部编码 Agent(codex / claude-code / opencode / qwen-code)。安装对应 CLI 后开启该入口。',
- addEndpoint: '新增 Endpoint',
+ addEndpoint: '新增端点',
builtin: '内置',
test: '测试',
testing: '测试中…',
statusUnknown: '未测试',
- empty: '暂无 ACP endpoint。',
- loadFailed: '加载 ACP endpoint 列表失败',
- saveFailed: '保存 endpoint 失败',
+ empty: '暂无 ACP 端点。',
+ loadFailed: '加载 ACP 端点列表失败',
+ saveFailed: '保存端点失败',
deleteTitle: '确认删除',
- deleteConfirm: '确认删除 ACP endpoint "{name}"?内置 endpoint 不可删除。',
- deleteFailed: '删除 endpoint 失败',
- toggleFailed: '切换 endpoint 状态失败',
+ deleteConfirm: '确认删除 ACP 端点 "{name}"?内置端点不可删除。',
+ deleteFailed: '删除端点失败',
+ toggleFailed: '切换端点状态失败',
invalidJson: 'JSON 格式错误',
columns: {
- name: 'Endpoint',
+ name: '端点',
command: '命令',
status: '最近一次测试',
enabled: '启用',
@@ -1149,13 +1149,31 @@ export default {
description: '描述',
command: '命令',
args: '参数 (JSON 数组)',
- env: '环境变量 (JSON 对象)',
+ env: '环境变量',
toolParseMode: 'Tool 解析模式',
enabled: '启用',
},
+ env: {
+ formMode: '编辑 JSON',
+ jsonMode: '回到表单',
+ suggested: '推荐',
+ addRow: '添加变量',
+ keyPlaceholder: 'KEY_NAME',
+ valuePlaceholder: '值(如 sk-…)',
+ show: '显示',
+ hide: '隐藏',
+ remove: '移除',
+ hints: {
+ anthropic: 'Anthropic API key(sk-ant-…)。申请: https://console.anthropic.com/settings/keys',
+ openai: 'OpenAI API key(sk-…)。申请: https://platform.openai.com/api-keys',
+ dashscope: '阿里云 DashScope key — 跟 MateClaw .env 里的 DASHSCOPE_API_KEY 同一个值',
+ google: 'Google AI Studio API key。申请: https://aistudio.google.com/apikey',
+ claudeOauth: '注意: claude-code 的 OAuth 登录(~/.claude/ 里那个)在这里不能用——更糟的是 Claude Agent SDK 会"优先读 OAuth、再读 API key",导致你下面填的 key 被静默忽略。如果你这台机器上跑过 `claude login`,先清钥匙串: (macOS) `claude logout` 或 `security delete-generic-password -s "Claude Code-credentials"`;(Linux/Windows) 删 ~/.claude/credentials.json。然后再回来填真正的 API key(sk-ant-…)。或者改用 qwen-code / opencode 复用各自 CLI 的本地登录态。',
+ },
+ },
modal: {
- newTitle: '新增 ACP Endpoint',
- editTitle: '编辑 ACP Endpoint',
+ newTitle: '新增 ACP 端点',
+ editTitle: '编辑 ACP 端点',
},
},
mcp: {
@@ -2002,6 +2020,7 @@ export default {
all: '全部',
builtin: '内置',
mcp: 'MCP',
+ acp: 'ACP',
dynamic: '动态',
},
search: {
@@ -2075,6 +2094,7 @@ export default {
types: {
builtin: '内置',
mcp: 'MCP',
+ acp: 'ACP',
dynamic: '动态',
},
source: {
diff --git a/mateclaw-ui/src/views/AcpEndpoints.vue b/mateclaw-ui/src/views/AcpEndpoints.vue
index 0c75dcb9..6ff67cbb 100644
--- a/mateclaw-ui/src/views/AcpEndpoints.vue
+++ b/mateclaw-ui/src/views/AcpEndpoints.vue
@@ -131,8 +131,81 @@