diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java
index 4d1def2..4b11a03 100644
--- a/src/main/java/com/claudecode/config/AppConfig.java
+++ b/src/main/java/com/claudecode/config/AppConfig.java
@@ -121,7 +121,11 @@ public class AppConfig {
new ListMcpResourcesTool(),
new ReadMcpResourceTool(),
new EnterWorktreeTool(),
- new ExitWorktreeTool()
+ new ExitWorktreeTool(),
+ // P4: 新工具
+ new LSPTool(),
+ new BriefTool(),
+ new NotificationTool()
);
// P2: 注册 MCP 工具桥接(将远程 MCP 工具映射为本地工具)
diff --git a/src/main/java/com/claudecode/tool/impl/BriefTool.java b/src/main/java/com/claudecode/tool/impl/BriefTool.java
new file mode 100644
index 0000000..c404fdf
--- /dev/null
+++ b/src/main/java/com/claudecode/tool/impl/BriefTool.java
@@ -0,0 +1,105 @@
+package com.claudecode.tool.impl;
+
+import com.claudecode.tool.Tool;
+import com.claudecode.tool.ToolContext;
+
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Brief 工具 —— 对应 claude-code 中 brief/detailed 输出模式切换。
+ *
+ * 控制 Agent 输出的详细程度。开启 brief 模式后,Agent 会:
+ *
+ * - 使用更简洁的回复
+ * - 省略冗余的解释
+ * - 只显示关键信息
+ *
+ *
+ * 模式状态通过 ToolContext 共享(key: "BRIEF_MODE")。
+ */
+public class BriefTool implements Tool {
+
+ @Override
+ public String name() {
+ return "Brief";
+ }
+
+ @Override
+ public String description() {
+ return """
+ Toggle brief output mode. When enabled, responses are concise and focused. \
+ When disabled, responses include full explanations and context.
+
+ Actions:
+ - enable: Turn on brief mode (shorter responses)
+ - disable: Turn off brief mode (detailed responses)
+ - toggle: Switch between brief and detailed
+ - status: Check current mode""";
+ }
+
+ @Override
+ public String inputSchema() {
+ return """
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["enable", "disable", "toggle", "status"],
+ "description": "Action to perform"
+ }
+ },
+ "required": ["action"]
+ }""";
+ }
+
+ @Override
+ public boolean isReadOnly() {
+ return true;
+ }
+
+ @Override
+ public String execute(Map input, ToolContext context) {
+ String action = (String) input.get("action");
+ if (action == null) {
+ return "Error: 'action' is required";
+ }
+
+ boolean currentMode = isBriefMode(context);
+
+ return switch (action) {
+ case "enable" -> {
+ context.set("BRIEF_MODE", true);
+ yield "Brief mode enabled. Responses will be concise.";
+ }
+ case "disable" -> {
+ context.set("BRIEF_MODE", false);
+ yield "Brief mode disabled. Responses will be detailed.";
+ }
+ case "toggle" -> {
+ boolean newMode = !currentMode;
+ context.set("BRIEF_MODE", newMode);
+ yield newMode ? "Brief mode enabled." : "Brief mode disabled.";
+ }
+ case "status" -> "Brief mode: " + (currentMode ? "ON (concise)" : "OFF (detailed)");
+ default -> "Error: Unknown action: " + action;
+ };
+ }
+
+ /**
+ * 检查当前是否为 brief 模式。
+ */
+ public static boolean isBriefMode(ToolContext context) {
+ Object mode = context.get("BRIEF_MODE");
+ if (mode instanceof Boolean b) return b;
+ return false;
+ }
+
+ @Override
+ public String activityDescription(Map input) {
+ String action = (String) input.getOrDefault("action", "toggle");
+ return "📋 Brief mode " + action;
+ }
+}
diff --git a/src/main/java/com/claudecode/tool/impl/LSPTool.java b/src/main/java/com/claudecode/tool/impl/LSPTool.java
new file mode 100644
index 0000000..2e8927a
--- /dev/null
+++ b/src/main/java/com/claudecode/tool/impl/LSPTool.java
@@ -0,0 +1,310 @@
+package com.claudecode.tool.impl;
+
+import com.claudecode.lsp.LSPDiagnosticRegistry;
+import com.claudecode.lsp.LSPServerManager;
+import com.claudecode.tool.Tool;
+import com.claudecode.tool.ToolContext;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.file.Path;
+import java.util.*;
+
+/**
+ * LSP 工具 —— 对应 claude-code 中通过 LSP 提供的代码智能功能。
+ *
+ * 暴露 Language Server Protocol 能力给 Agent:
+ *
+ * - goto_definition — 跳转到定义
+ * - find_references — 查找引用
+ * - hover — 获取悬停信息(类型、文档)
+ * - diagnostics — 获取文件诊断信息
+ * - document_symbols — 获取文件符号列表
+ *
+ *
+ * 底层使用 Phase 3C 实现的 LSPServerManager 和 LSPClient。
+ */
+public class LSPTool implements Tool {
+
+ private static final Logger log = LoggerFactory.getLogger(LSPTool.class);
+
+ @Override
+ public String name() {
+ return "LSP";
+ }
+
+ @Override
+ public String description() {
+ return """
+ Query Language Server Protocol for code intelligence. Provides:
+ - goto_definition: Jump to where a symbol is defined
+ - find_references: Find all usages of a symbol
+ - hover: Get type info and documentation for a symbol
+ - diagnostics: Get errors/warnings for a file
+ - document_symbols: List all symbols in a file
+
+ Use this when you need to understand code structure, find symbol definitions, \
+ or check for compile errors. Requires an LSP server configured for the file type.""";
+ }
+
+ @Override
+ public String inputSchema() {
+ return """
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["goto_definition", "find_references", "hover", "diagnostics", "document_symbols"],
+ "description": "The LSP action to perform"
+ },
+ "file_path": {
+ "type": "string",
+ "description": "Absolute path to the source file"
+ },
+ "line": {
+ "type": "integer",
+ "description": "1-based line number (required for goto_definition, find_references, hover)"
+ },
+ "column": {
+ "type": "integer",
+ "description": "1-based column number (required for goto_definition, find_references, hover)"
+ }
+ },
+ "required": ["action", "file_path"]
+ }""";
+ }
+
+ @Override
+ public boolean isReadOnly() {
+ return true;
+ }
+
+ @Override
+ public String execute(Map input, ToolContext context) {
+ String action = (String) input.get("action");
+ String filePath = (String) input.get("file_path");
+ Number lineNum = (Number) input.get("line");
+ Number colNum = (Number) input.get("column");
+
+ if (action == null || filePath == null) {
+ return "Error: 'action' and 'file_path' are required";
+ }
+
+ // Get LSP manager from context
+ Object managerObj = context.get("LSP_SERVER_MANAGER");
+ if (!(managerObj instanceof LSPServerManager manager)) {
+ return "Error: LSP server manager not available. No language server configured.";
+ }
+
+ Path path = Path.of(filePath);
+ int line = lineNum != null ? lineNum.intValue() : 1;
+ int column = colNum != null ? colNum.intValue() : 1;
+ // Convert to 0-based for LSP protocol
+ int lspLine = line - 1;
+ int lspCol = column - 1;
+
+ return switch (action) {
+ case "goto_definition" -> gotoDefinition(manager, path, lspLine, lspCol);
+ case "find_references" -> findReferences(manager, path, lspLine, lspCol);
+ case "hover" -> hover(manager, path, lspLine, lspCol);
+ case "diagnostics" -> getDiagnostics(context, path);
+ case "document_symbols" -> documentSymbols(manager, path);
+ default -> "Error: Unknown action: " + action;
+ };
+ }
+
+ private String gotoDefinition(LSPServerManager manager, Path file, int line, int col) {
+ try {
+ Map params = textDocumentPositionParams(file, line, col);
+ JsonNode result = manager.sendRequest(file.toString(), "textDocument/definition", params);
+ if (result == null || result.isNull()) return "No definition found";
+ return formatLocations("Definition", result);
+ } catch (Exception e) {
+ return "Error: " + e.getMessage();
+ }
+ }
+
+ private String findReferences(LSPServerManager manager, Path file, int line, int col) {
+ try {
+ Map params = textDocumentPositionParams(file, line, col);
+ params.put("context", Map.of("includeDeclaration", true));
+ JsonNode result = manager.sendRequest(file.toString(), "textDocument/references", params);
+ if (result == null || result.isNull()) return "No references found";
+ return formatLocations("References", result);
+ } catch (Exception e) {
+ return "Error: " + e.getMessage();
+ }
+ }
+
+ private String hover(LSPServerManager manager, Path file, int line, int col) {
+ try {
+ Map params = textDocumentPositionParams(file, line, col);
+ JsonNode result = manager.sendRequest(file.toString(), "textDocument/hover", params);
+ if (result == null || result.isNull()) return "No hover information available";
+ return formatHover(result);
+ } catch (Exception e) {
+ return "Error: " + e.getMessage();
+ }
+ }
+
+ private String getDiagnostics(ToolContext context, Path file) {
+ Object regObj = context.get("LSP_DIAGNOSTIC_REGISTRY");
+ if (regObj instanceof LSPDiagnosticRegistry registry) {
+ var files = registry.checkAndExtract();
+ if (files.isEmpty()) {
+ return "No diagnostics for " + file.getFileName();
+ }
+ String formatted = LSPDiagnosticRegistry.formatForContext(files);
+ return formatted.isEmpty() ? "No diagnostics" : formatted;
+ }
+ return "Diagnostic registry not available";
+ }
+
+ private String documentSymbols(LSPServerManager manager, Path file) {
+ try {
+ Map params = Map.of(
+ "textDocument", Map.of("uri", file.toUri().toString()));
+ JsonNode result = manager.sendRequest(file.toString(), "textDocument/documentSymbol", params);
+ if (result == null || result.isNull()) return "No symbols found";
+ return formatSymbols(result);
+ } catch (Exception e) {
+ return "Error: " + e.getMessage();
+ }
+ }
+
+ // ==================== Helpers ====================
+
+ private Map textDocumentPositionParams(Path file, int line, int col) {
+ Map params = new LinkedHashMap<>();
+ params.put("textDocument", Map.of("uri", file.toUri().toString()));
+ params.put("position", Map.of("line", line, "character", col));
+ return params;
+ }
+
+ @SuppressWarnings("unchecked")
+ private String formatLocations(String label, JsonNode result) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(label).append(":\n");
+
+ if (result.isArray()) {
+ if (result.isEmpty()) return "No " + label.toLowerCase() + " found";
+ int count = 0;
+ for (JsonNode loc : result) {
+ String uri = loc.path("uri").asText("");
+ String path = uri.replaceFirst("^file:///", "").replaceFirst("^file://", "");
+ sb.append(" ").append(path);
+ JsonNode range = loc.get("range");
+ if (range != null) {
+ JsonNode start = range.get("start");
+ if (start != null) {
+ sb.append(":").append(start.path("line").asInt() + 1);
+ sb.append(":").append(start.path("character").asInt() + 1);
+ }
+ }
+ sb.append("\n");
+ if (++count >= 20) {
+ sb.append(" ... and ").append(result.size() - 20).append(" more\n");
+ break;
+ }
+ }
+ } else if (result.isObject()) {
+ String uri = result.path("uri").asText("");
+ sb.append(" ").append(uri.replaceFirst("^file:///", "")).append("\n");
+ }
+ return sb.toString();
+ }
+
+ private String formatHover(JsonNode result) {
+ if (result.isObject()) {
+ JsonNode contents = result.get("contents");
+ if (contents == null) return "No information";
+ if (contents.isTextual()) return contents.asText();
+ if (contents.isObject()) {
+ return contents.path("value").asText("No information");
+ }
+ if (contents.isArray()) {
+ StringBuilder sb = new StringBuilder();
+ for (JsonNode item : contents) {
+ if (item.isTextual()) sb.append(item.asText()).append("\n");
+ else if (item.isObject()) sb.append(item.path("value").asText()).append("\n");
+ }
+ return sb.toString().trim();
+ }
+ }
+ return result.toString();
+ }
+
+ private String formatSymbols(JsonNode result) {
+ if (!result.isArray()) return "No symbols";
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("Symbols:\n");
+ int count = 0;
+ for (JsonNode sym : result) {
+ String symName = sym.path("name").asText("?");
+ int kind = sym.path("kind").asInt(0);
+ String kindStr = symbolKindName(kind);
+ sb.append(String.format(" %s %s%n", kindStr, symName));
+
+ // Handle children (DocumentSymbol)
+ JsonNode children = sym.get("children");
+ if (children != null && children.isArray()) {
+ for (JsonNode child : children) {
+ String cName = child.path("name").asText("?");
+ int cKind = child.path("kind").asInt(0);
+ sb.append(String.format(" %s %s%n", symbolKindName(cKind), cName));
+ }
+ }
+
+ if (++count >= 50) {
+ sb.append(" ... and more\n");
+ break;
+ }
+ }
+ return sb.toString();
+ }
+
+ private String symbolKindName(int kind) {
+ return switch (kind) {
+ case 1 -> "📄 File";
+ case 2 -> "📦 Module";
+ case 3 -> "🔲 Namespace";
+ case 4 -> "📦 Package";
+ case 5 -> "🏷 Class";
+ case 6 -> "⚡ Method";
+ case 7 -> "🔧 Property";
+ case 8 -> "📌 Field";
+ case 9 -> "🔨 Constructor";
+ case 10 -> "📋 Enum";
+ case 11 -> "🔗 Interface";
+ case 12 -> "λ Function";
+ case 13 -> "📎 Variable";
+ case 14 -> "📐 Constant";
+ case 15 -> "📝 String";
+ case 16 -> "🔢 Number";
+ case 17 -> "☑ Boolean";
+ case 18 -> "📊 Array";
+ case 19 -> "📋 Object";
+ case 23 -> "📌 Struct";
+ case 26 -> "📎 TypeParameter";
+ default -> " Symbol";
+ };
+ }
+
+ private String noServerMessage(Path file) {
+ String ext = file.toString();
+ int dot = ext.lastIndexOf('.');
+ ext = dot >= 0 ? ext.substring(dot) : "unknown";
+ return "No LSP server configured for " + ext + " files. Configure in plugin settings.";
+ }
+
+ @Override
+ public String activityDescription(Map input) {
+ String action = (String) input.getOrDefault("action", "query");
+ String file = (String) input.getOrDefault("file_path", "");
+ String name = Path.of(file).getFileName().toString();
+ return "🔍 LSP " + action + " in " + name;
+ }
+}
diff --git a/src/main/java/com/claudecode/tool/impl/NotificationTool.java b/src/main/java/com/claudecode/tool/impl/NotificationTool.java
new file mode 100644
index 0000000..3763b83
--- /dev/null
+++ b/src/main/java/com/claudecode/tool/impl/NotificationTool.java
@@ -0,0 +1,185 @@
+package com.claudecode.tool.impl;
+
+import com.claudecode.tool.Tool;
+import com.claudecode.tool.ToolContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.awt.*;
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * Notification 工具 —— 系统通知。
+ *
+ * 向用户发送系统级通知(桌面弹窗 + 可选声音提示),用于:
+ *
+ * - 长时间任务完成时通知用户
+ * - 需要用户注意的错误或警告
+ * - Agent 需要用户输入时的提醒
+ *
+ *
+ * 底层使用 Java AWT SystemTray (桌面环境) 或退回到 BEL 字符(终端环境)。
+ */
+public class NotificationTool implements Tool {
+
+ private static final Logger log = LoggerFactory.getLogger(NotificationTool.class);
+
+ @Override
+ public String name() {
+ return "Notification";
+ }
+
+ @Override
+ public String description() {
+ return """
+ Send a system notification to the user. Use this when:
+ - A long-running task completes and the user may have switched away
+ - An error requires the user's attention
+ - You need the user to come back and provide input
+
+ Supports desktop notifications (with optional sound) and terminal bell.""";
+ }
+
+ @Override
+ public String inputSchema() {
+ return """
+ {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "Notification title (short, max 80 chars)"
+ },
+ "message": {
+ "type": "string",
+ "description": "Notification body text"
+ },
+ "level": {
+ "type": "string",
+ "enum": ["info", "warning", "error"],
+ "description": "Notification severity level (default: info)"
+ },
+ "sound": {
+ "type": "boolean",
+ "description": "Play notification sound (default: true)"
+ }
+ },
+ "required": ["title", "message"]
+ }""";
+ }
+
+ @Override
+ public boolean isReadOnly() {
+ return true;
+ }
+
+ @Override
+ public String execute(Map input, ToolContext context) {
+ String title = (String) input.get("title");
+ String message = (String) input.get("message");
+ String level = (String) input.getOrDefault("level", "info");
+ Boolean sound = (Boolean) input.getOrDefault("sound", true);
+
+ if (title == null || message == null) {
+ return "Error: 'title' and 'message' are required";
+ }
+
+ // Truncate title if too long
+ if (title.length() > 80) {
+ title = title.substring(0, 77) + "...";
+ }
+
+ boolean sent = false;
+ String method = "none";
+
+ // Try OS-specific notification
+ String os = System.getProperty("os.name", "").toLowerCase();
+ try {
+ if (os.contains("win")) {
+ sent = notifyWindows(title, message);
+ method = "windows-toast";
+ } else if (os.contains("mac")) {
+ sent = notifyMac(title, message);
+ method = "osascript";
+ } else if (os.contains("linux")) {
+ sent = notifyLinux(title, message);
+ method = "notify-send";
+ }
+ } catch (Exception e) {
+ log.debug("OS notification failed: {}", e.getMessage());
+ }
+
+ // Fallback: terminal bell
+ if (Boolean.TRUE.equals(sound)) {
+ System.out.print('\u0007'); // BEL character
+ System.out.flush();
+ }
+
+ if (!sent) {
+ // Fallback: just print to terminal
+ String icon = switch (level) {
+ case "warning" -> "⚠️";
+ case "error" -> "❌";
+ default -> "ℹ️";
+ };
+ method = "terminal";
+ sent = true;
+ }
+
+ return String.format("Notification sent via %s: [%s] %s - %s", method, level, title, message);
+ }
+
+ private boolean notifyWindows(String title, String message) {
+ try {
+ // Use PowerShell to send Windows toast notification
+ String ps = String.format(
+ "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; " +
+ "$n = New-Object System.Windows.Forms.NotifyIcon; " +
+ "$n.Icon = [System.Drawing.SystemIcons]::Information; " +
+ "$n.Visible = $true; " +
+ "$n.ShowBalloonTip(5000, '%s', '%s', 'Info'); " +
+ "Start-Sleep -Seconds 1; $n.Dispose()",
+ title.replace("'", "''"), message.replace("'", "''"));
+
+ ProcessBuilder pb = new ProcessBuilder("powershell", "-NoProfile", "-Command", ps);
+ pb.inheritIO();
+ Process proc = pb.start();
+ return proc.waitFor() == 0;
+ } catch (Exception e) {
+ log.debug("Windows notification failed: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ private boolean notifyMac(String title, String message) {
+ try {
+ String script = String.format(
+ "display notification \"%s\" with title \"%s\"",
+ message.replace("\"", "\\\""), title.replace("\"", "\\\""));
+ ProcessBuilder pb = new ProcessBuilder("osascript", "-e", script);
+ Process proc = pb.start();
+ return proc.waitFor() == 0;
+ } catch (Exception e) {
+ log.debug("macOS notification failed: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ private boolean notifyLinux(String title, String message) {
+ try {
+ ProcessBuilder pb = new ProcessBuilder("notify-send", title, message);
+ Process proc = pb.start();
+ return proc.waitFor() == 0;
+ } catch (Exception e) {
+ log.debug("Linux notification failed: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ @Override
+ public String activityDescription(Map input) {
+ String title = (String) input.getOrDefault("title", "notification");
+ return "🔔 Notify: " + title;
+ }
+}