From 37fc06e27465a136377f983de230c6e38a9555a1 Mon Sep 17 00:00:00 2001 From: chuan Date: Mon, 22 Jun 2026 14:40:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=93=E9=80=9A=20hook=20CLI=20?= =?UTF-8?q?=E4=B8=8E=E5=91=BD=E5=90=8D=E7=AE=A1=E9=81=93=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=EF=BC=88P0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 子命令 save/notify/input/cleanup:纯互操作 IPC,不加载 Avalonia - 命名管道:瘦客户端投递请求,Host 未运行时自动拉起并重试 - 每会话状态文件 %TEMP%\claude-notify-{id}.json,保存前台窗口与 prompt - Host 单例互斥量 + 管道监听,收到请求切回 UI 线程弹窗 - stdin 改为 UTF-8 原始字节读取,修复中文乱码与读不到的问题 - 符合 Claude Code 插件规范的 .claude-plugin 与 hooks 结构 --- .claude-plugin/marketplace.json | 18 +++ .claude-plugin/plugin.json | 9 ++ Notify/App.axaml.cs | 21 +++- Notify/Cli/CliRunner.cs | 153 +++++++++++++++++++++++++ Notify/Cli/HookInput.cs | 24 ++++ Notify/Interop/Win32.cs | 10 ++ Notify/Ipc/IpcConstants.cs | 10 ++ Notify/Ipc/PipeClient.cs | 79 +++++++++++++ Notify/Ipc/PipeMessage.cs | 20 ++++ Notify/Ipc/PipeServer.cs | 58 ++++++++++ Notify/Models/StateData.cs | 13 +++ Notify/Program.cs | 34 +++++- Notify/Serialization/AppJsonContext.cs | 5 + Notify/Services/StateStore.cs | 67 +++++++++++ hooks/hooks.json | 64 +++++++++++ 15 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 Notify/Cli/CliRunner.cs create mode 100644 Notify/Cli/HookInput.cs create mode 100644 Notify/Interop/Win32.cs create mode 100644 Notify/Ipc/IpcConstants.cs create mode 100644 Notify/Ipc/PipeClient.cs create mode 100644 Notify/Ipc/PipeMessage.cs create mode 100644 Notify/Ipc/PipeServer.cs create mode 100644 Notify/Models/StateData.cs create mode 100644 Notify/Services/StateStore.cs create mode 100644 hooks/hooks.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..ab30e8f --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "name": "claude-code-notify", + "description": "Native Windows toast notifications for Claude Code", + "owner": { + "name": "chuan" + }, + "plugins": [ + { + "name": "claude-code-notify", + "description": "Native Windows toast notifications for Claude Code (Avalonia/.NET rewrite)", + "version": "0.1.0", + "source": "./", + "author": { + "name": "chuan" + } + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..48aa265 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "claude-code-notify", + "description": "Native Windows toast notifications for Claude Code (Avalonia/.NET rewrite)", + "version": "0.1.0", + "author": { + "name": "chuan" + }, + "license": "MIT" +} diff --git a/Notify/App.axaml.cs b/Notify/App.axaml.cs index d350c92..45d6c17 100644 --- a/Notify/App.axaml.cs +++ b/Notify/App.axaml.cs @@ -3,6 +3,8 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using Notify.Ipc; using Notify.Models; using Notify.Services; using Notify.ViewModels; @@ -13,6 +15,7 @@ namespace Notify; public partial class App : Application { private SettingsWindow? _settingsWindow; + private PipeServer? _pipeServer; public static new App Current => (App)Application.Current!; @@ -27,6 +30,10 @@ public partial class App : Application Settings.Load(); Toasts = new ToastManager(Settings); + // 监听命名管道,把瘦客户端投递的请求转成 toast + _pipeServer = new PipeServer(OnPipeMessage); + _pipeServer.Start(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { // 无主窗口的常驻进程:仅托盘存在,靠托盘菜单或外部请求驱动 @@ -35,13 +42,25 @@ public partial class App : Application // --demo:启动即弹一条 toast 并打开设置,便于无托盘交互地验证 if (desktop.Args is { Length: > 0 } args && System.Array.IndexOf(args, "--demo") >= 0) { - Avalonia.Threading.Dispatcher.UIThread.Post(RunDemo); + Dispatcher.UIThread.Post(RunDemo); } } base.OnFrameworkInitializationCompleted(); } + // 管道线程收到请求,切回 UI 线程弹出 toast + private void OnPipeMessage(PipeMessage message) + { + Dispatcher.UIThread.Post(() => Toasts.Show(new ToastRequest + { + Title = message.Title, + Message = message.Message, + InputMode = message.InputMode, + Sticky = message.Sticky, + })); + } + private void RunDemo() { // 普通:会自动消失 diff --git a/Notify/Cli/CliRunner.cs b/Notify/Cli/CliRunner.cs new file mode 100644 index 0000000..25b7c7d --- /dev/null +++ b/Notify/Cli/CliRunner.cs @@ -0,0 +1,153 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using Notify.Interop; +using Notify.Ipc; +using Notify.Models; +using Notify.Serialization; +using Notify.Services; + +namespace Notify.Cli; + +/// +/// 钩子子命令实现:纯互操作 / IPC,绝不加载 Avalonia,做完即退出 +/// +public static class CliRunner +{ + // UserPromptSubmit:记录前台窗口与 prompt + public static int Save() + { + var input = ReadStdin(); + if (string.IsNullOrEmpty(input?.SessionId)) + { + return 0; + } + + var hwnd = Win32.GetForegroundWindow(); + StateStore.Save(input.SessionId, new StateData + { + Hwnd = hwnd.ToInt64(), + Prompt = input.Prompt ?? "", + }); + return 0; + } + + // Stop:任务完成通知,正文取本次 prompt + public static int Notify() + { + var input = ReadStdin(); + if (string.IsNullOrEmpty(input?.SessionId)) + { + return 0; + } + + var state = StateStore.Load(input.SessionId); + var message = !string.IsNullOrWhiteSpace(state?.Prompt) ? state!.Prompt : "Task completed"; + + return SendToHost(new PipeMessage + { + SessionId = input.SessionId, + Title = "Claude Code", + Message = message, + InputMode = false, + Sticky = false, + }); + } + + // Notification / PreToolUse:需要输入,常驻显示 + public static int Input() + { + var input = ReadStdin(); + if (string.IsNullOrEmpty(input?.SessionId)) + { + return 0; + } + + // 过滤无需打扰的类型 + if (input.NotificationType is "auth_success" or "elicitation_complete" or "elicitation_response") + { + return 0; + } + + var (title, message) = Resolve(input); + + return SendToHost(new PipeMessage + { + SessionId = input.SessionId, + Title = title, + Message = message, + InputMode = true, + Sticky = true, + }); + } + + // SessionEnd:清理会话状态 + public static int Cleanup() + { + var input = ReadStdin(); + if (!string.IsNullOrEmpty(input?.SessionId)) + { + StateStore.Delete(input.SessionId); + } + + return 0; + } + + // 按 tool_name / notification_type 决定标题与正文,对齐原版语义 + private static (string Title, string Message) Resolve(HookInput input) + { + if (input.ToolName == "AskUserQuestion") + { + var msg = string.IsNullOrEmpty(input.Message) ? "Claude 在向你提问" : input.Message!; + return ("Claude is Asking", msg); + } + + if (input.ToolName == "ExitPlanMode") + { + return ("Plan Ready for Approval", "Claude 提交了一份计划,待批准"); + } + + var title = input.NotificationType switch + { + "permission_prompt" => "Permission Required", + "idle_prompt" => "Claude is Waiting", + "elicitation_dialog" => "MCP Asks", + _ => "Input Required", + }; + var message = string.IsNullOrEmpty(input.Message) ? "Claude needs your input" : input.Message!; + return (title, message); + } + + private static int SendToHost(PipeMessage message) => PipeClient.Send(message) ? 0 : 1; + + // 直接读原始字节并按 UTF-8 解码:WinExe 下 Console.In 不可靠,且其代码页 + // 会把中文解成乱码(GBK),这里绕开 + private static HookInput? ReadStdin() + { + try + { + using var stdin = Console.OpenStandardInput(); + using var ms = new MemoryStream(); + stdin.CopyTo(ms); + + var bytes = ms.ToArray(); + if (bytes.Length == 0) + { + return null; + } + + var text = Encoding.UTF8.GetString(bytes).TrimStart(''); + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + return JsonSerializer.Deserialize(text, AppJsonContext.Default.HookInput); + } + catch + { + return null; + } + } +} diff --git a/Notify/Cli/HookInput.cs b/Notify/Cli/HookInput.cs new file mode 100644 index 0000000..306a137 --- /dev/null +++ b/Notify/Cli/HookInput.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Notify.Cli; + +/// +/// Claude Code 钩子经 stdin 传入的 JSON +/// +public sealed class HookInput +{ + [JsonPropertyName("session_id")] + public string? SessionId { get; set; } + + [JsonPropertyName("prompt")] + public string? Prompt { get; set; } + + [JsonPropertyName("notification_type")] + public string? NotificationType { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("tool_name")] + public string? ToolName { get; set; } +} diff --git a/Notify/Interop/Win32.cs b/Notify/Interop/Win32.cs new file mode 100644 index 0000000..c680d32 --- /dev/null +++ b/Notify/Interop/Win32.cs @@ -0,0 +1,10 @@ +using System; +using System.Runtime.InteropServices; + +namespace Notify.Interop; + +internal static partial class Win32 +{ + [LibraryImport("user32.dll")] + internal static partial IntPtr GetForegroundWindow(); +} diff --git a/Notify/Ipc/IpcConstants.cs b/Notify/Ipc/IpcConstants.cs new file mode 100644 index 0000000..b238eb2 --- /dev/null +++ b/Notify/Ipc/IpcConstants.cs @@ -0,0 +1,10 @@ +namespace Notify.Ipc; + +internal static class IpcConstants +{ + // 瘦客户端与常驻 Host 之间的命名管道名 + public const string PipeName = "claude-code-notify"; + + // 保证 Host 单例的互斥量名(Local 级,按用户会话隔离) + public const string HostMutexName = "ClaudeCodeNotifyHost"; +} diff --git a/Notify/Ipc/PipeClient.cs b/Notify/Ipc/PipeClient.cs new file mode 100644 index 0000000..3b2a3c7 --- /dev/null +++ b/Notify/Ipc/PipeClient.cs @@ -0,0 +1,79 @@ +using System; +using System.Diagnostics; +using System.IO.Pipes; +using System.Text; +using System.Text.Json; +using System.Threading; +using Notify.Serialization; + +namespace Notify.Ipc; + +/// +/// 瘦客户端侧:把一条 PipeMessage 发给 Host,Host 不在则拉起后重试 +/// +public static class PipeClient +{ + public static bool Send(PipeMessage message) + { + var json = JsonSerializer.Serialize(message, AppJsonContext.Default.PipeMessage); + var bytes = Encoding.UTF8.GetBytes(json); + + if (TrySend(bytes, 300)) + { + return true; + } + + // Host 未运行:拉起后等待其管道就绪再重试,最多约 5 秒 + StartHost(); + for (var i = 0; i < 50; i++) + { + Thread.Sleep(100); + if (TrySend(bytes, 300)) + { + return true; + } + } + + return false; + } + + private static bool TrySend(byte[] bytes, int timeoutMs) + { + try + { + using var client = new NamedPipeClientStream(".", IpcConstants.PipeName, PipeDirection.Out); + client.Connect(timeoutMs); + client.Write(bytes, 0, bytes.Length); + client.Flush(); + return true; + } + catch + { + return false; + } + } + + private static void StartHost() + { + try + { + var exe = Environment.ProcessPath; + if (exe is null) + { + return; + } + + Process.Start(new ProcessStartInfo + { + FileName = exe, + Arguments = "host", + UseShellExecute = false, + CreateNoWindow = true, + }); + } + catch + { + // 拉起失败则发送会重试超时后放弃 + } + } +} diff --git a/Notify/Ipc/PipeMessage.cs b/Notify/Ipc/PipeMessage.cs new file mode 100644 index 0000000..e3f122b --- /dev/null +++ b/Notify/Ipc/PipeMessage.cs @@ -0,0 +1,20 @@ +namespace Notify.Ipc; + +/// +/// 瘦客户端经命名管道投递给 Host 的一条弹窗请求 +/// +public sealed class PipeMessage +{ + public string Title { get; set; } = ""; + + public string Message { get; set; } = ""; + + // true = 需要输入(青色边框) + public bool InputMode { get; set; } + + // true = 常驻,不自动消失 + public bool Sticky { get; set; } + + // 触发该通知的会话 id,便于 Host 后续按会话激活窗口 + public string? SessionId { get; set; } +} diff --git a/Notify/Ipc/PipeServer.cs b/Notify/Ipc/PipeServer.cs new file mode 100644 index 0000000..929a174 --- /dev/null +++ b/Notify/Ipc/PipeServer.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.IO.Pipes; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Notify.Serialization; + +namespace Notify.Ipc; + +/// +/// Host 侧命名管道监听:每个连接读取一条 PipeMessage 并回调 +/// +public sealed class PipeServer +{ + private readonly Action _onMessage; + + public PipeServer(Action onMessage) => _onMessage = onMessage; + + public void Start() => Task.Run(RunLoopAsync); + + private async Task RunLoopAsync() + { + while (true) + { + try + { + using var server = new NamedPipeServerStream( + IpcConstants.PipeName, + PipeDirection.In, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + + await server.WaitForConnectionAsync().ConfigureAwait(false); + + using var ms = new MemoryStream(); + await server.CopyToAsync(ms).ConfigureAwait(false); + + var json = Encoding.UTF8.GetString(ms.ToArray()); + if (string.IsNullOrWhiteSpace(json)) + { + continue; + } + + var msg = JsonSerializer.Deserialize(json, AppJsonContext.Default.PipeMessage); + if (msg is not null) + { + _onMessage(msg); + } + } + catch + { + // 单个连接出错不影响后续监听 + } + } + } +} diff --git a/Notify/Models/StateData.cs b/Notify/Models/StateData.cs new file mode 100644 index 0000000..2fe282e --- /dev/null +++ b/Notify/Models/StateData.cs @@ -0,0 +1,13 @@ +namespace Notify.Models; + +/// +/// 每会话持久化的状态,由 save 钩子写入、notify 钩子与点击激活读取 +/// +public sealed class StateData +{ + // 触发时的前台窗口句柄 + public long Hwnd { get; set; } + + // 用户当次输入的 prompt,用作"任务完成"通知的正文 + public string Prompt { get; set; } = ""; +} diff --git a/Notify/Program.cs b/Notify/Program.cs index 2fc41b3..ab6e241 100644 --- a/Notify/Program.cs +++ b/Notify/Program.cs @@ -1,11 +1,17 @@ using System; +using System.Threading; using Avalonia; using Avalonia.Controls; +using Notify.Cli; +using Notify.Ipc; namespace Notify; internal static class Program { + // 保活期间持有,确保 Host 单例 + private static Mutex? _hostMutex; + // Avalonia configuration, don't remove; also used by the visual designer. public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() @@ -14,8 +20,34 @@ internal static class Program .LogToTrace(); [STAThread] - public static void Main(string[] args) => + public static int Main(string[] args) + { + var mode = args.Length > 0 ? args[0].ToLowerInvariant() : "host"; + + // 钩子子命令:纯互操作 / IPC,不加载 Avalonia + return mode switch + { + "save" => CliRunner.Save(), + "notify" => CliRunner.Notify(), + "input" => CliRunner.Input(), + "cleanup" => CliRunner.Cleanup(), + _ => RunHost(args), + }; + } + + // 常驻 Host:加载 Avalonia,无主窗口保活,监听命名管道 + private static int RunHost(string[] args) + { + _hostMutex = new Mutex(true, IpcConstants.HostMutexName, out var created); + if (!created) + { + // 已有 Host 在跑,本进程退出 + return 0; + } + BuildAvaloniaApp() // OnExplicitShutdown = 持续保活:没有主窗口也不会退出,只有显式 Shutdown 才结束 .StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown); + return 0; + } } diff --git a/Notify/Serialization/AppJsonContext.cs b/Notify/Serialization/AppJsonContext.cs index e4df96f..585cf8a 100644 --- a/Notify/Serialization/AppJsonContext.cs +++ b/Notify/Serialization/AppJsonContext.cs @@ -1,4 +1,6 @@ using System.Text.Json.Serialization; +using Notify.Cli; +using Notify.Ipc; using Notify.Models; namespace Notify.Serialization; @@ -6,4 +8,7 @@ namespace Notify.Serialization; // System.Text.Json 源生成:为后续 NativeAOT 准备,避免反射序列化被裁剪 [JsonSourceGenerationOptions(WriteIndented = true, UseStringEnumConverter = true)] [JsonSerializable(typeof(ToastSettings))] +[JsonSerializable(typeof(StateData))] +[JsonSerializable(typeof(HookInput))] +[JsonSerializable(typeof(PipeMessage))] internal partial class AppJsonContext : JsonSerializerContext; diff --git a/Notify/Services/StateStore.cs b/Notify/Services/StateStore.cs new file mode 100644 index 0000000..51efad5 --- /dev/null +++ b/Notify/Services/StateStore.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using Notify.Models; +using Notify.Serialization; + +namespace Notify.Services; + +/// +/// 每会话状态文件的读写,位于 %TEMP%\claude-notify-{session_id}.json +/// +public static class StateStore +{ + private static string FilePath(string sessionId) => + Path.Combine(Path.GetTempPath(), $"claude-notify-{Sanitize(sessionId)}.json"); + + public static void Save(string sessionId, StateData data) + { + try + { + File.WriteAllText(FilePath(sessionId), JsonSerializer.Serialize(data, AppJsonContext.Default.StateData)); + } + catch + { + // 落盘失败不致命 + } + } + + public static StateData? Load(string sessionId) + { + try + { + var path = FilePath(sessionId); + if (File.Exists(path)) + { + return JsonSerializer.Deserialize(File.ReadAllText(path), AppJsonContext.Default.StateData); + } + } + catch + { + // 损坏或不可读则当作无状态 + } + + return null; + } + + public static void Delete(string sessionId) + { + try + { + var path = FilePath(sessionId); + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // 忽略 + } + } + + // 只保留文件名安全字符,避免 session_id 含特殊字符破坏路径 + private static string Sanitize(string s) => + new(s.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_').ToArray()); +} diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..7e97fb9 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,64 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/bin/notify.exe save", + "timeout": 5 + } + ] + } + ], + "Notification": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/bin/notify.exe input", + "timeout": 10 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "AskUserQuestion|ExitPlanMode", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/bin/notify.exe input", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/bin/notify.exe notify", + "timeout": 10 + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/bin/notify.exe cleanup", + "timeout": 5 + } + ] + } + ] + } +}