diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index 97b9a8b..0000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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": "1.0.0", - "source": "./", - "author": { - "name": "chuan" - } - } - ] -} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json deleted file mode 100644 index 37ae744..0000000 --- a/.claude-plugin/plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "claude-code-notify", - "description": "Native Windows toast notifications for Claude Code (Avalonia/.NET rewrite)", - "version": "1.0.0", - "author": { - "name": "chuan" - }, - "license": "MIT" -} diff --git a/Notify/App.axaml b/Notify/App.axaml index 2c78b80..224938c 100644 --- a/Notify/App.axaml +++ b/Notify/App.axaml @@ -10,8 +10,8 @@ - diff --git a/Notify/App.axaml.cs b/Notify/App.axaml.cs index 9366506..4fc1d73 100644 --- a/Notify/App.axaml.cs +++ b/Notify/App.axaml.cs @@ -81,7 +81,7 @@ public partial class App : Application private void RunDemo() { // 普通:会自动消失 - Toasts.Show(new ToastRequest { Title = "Claude Code", Message = "任务已完成 — 4 秒后自动消失" }); + Toasts.Show(new ToastRequest { Title = "Notify", Message = "任务已完成,4 秒后自动消失" }); // 常驻:InputMode 且 Sticky,不点不消失 Toasts.Show(new ToastRequest { diff --git a/Notify/Assets/claude.ico b/Notify/Assets/claude.ico deleted file mode 100644 index 5df0a49..0000000 Binary files a/Notify/Assets/claude.ico and /dev/null differ diff --git a/Notify/Assets/codex.ico b/Notify/Assets/codex.ico new file mode 100644 index 0000000..bcb2183 Binary files /dev/null and b/Notify/Assets/codex.ico differ diff --git a/Notify/Assets/codex.png b/Notify/Assets/codex.png new file mode 100644 index 0000000..ffb3acb Binary files /dev/null and b/Notify/Assets/codex.png differ diff --git a/Notify/Cli/CliRunner.cs b/Notify/Cli/CliRunner.cs index da45c02..5639fd5 100644 --- a/Notify/Cli/CliRunner.cs +++ b/Notify/Cli/CliRunner.cs @@ -15,8 +15,37 @@ namespace Notify.Cli; /// public static class CliRunner { + private const string CodexSource = "codex"; + + // Codex notify:JSON 作为命令行最后一个参数传入。 + public static int Codex(string? payload) + { + var input = Deserialize(payload); + if (input is null || (input.Type is not null && input.Type != "agent-turn-complete")) + { + return 0; + } + + var hwnd = Win32.GetForegroundWindow(); + var wtRuntimeId = WinTerminalTabs.IsWindowsTerminal(hwnd) + ? WinTerminalTabs.GetSelectedTabRuntimeId(hwnd) + : ""; + NotificationSpool.Deliver(new NotifyMessage + { + SessionId = input.ThreadId ?? input.TurnId ?? "codex", + Title = "Codex", + Message = Sanitize(input.LastAssistantMessage ?? "Task completed"), + InputMode = false, + Sticky = false, + TargetHwnd = hwnd.ToInt64(), + WtRuntimeId = wtRuntimeId, + IconPath = GetCodexIconPath(), + }); + return 0; + } + // UserPromptSubmit:记录前台窗口与 prompt - public static int Save() + public static int Save(string? source = null) { var input = ReadStdin(); if (string.IsNullOrEmpty(input?.SessionId)) @@ -31,18 +60,20 @@ public static class CliRunner ? WinTerminalTabs.GetSelectedTabRuntimeId(hwnd) : ""; + var callerExePath = ResolveIconPath(source, hwnd); + StateStore.Save(input.SessionId, new StateData { Hwnd = hwnd.ToInt64(), Prompt = input.Prompt ?? "", WtRuntimeId = wtRuntimeId, - CallerExePath = ProcessTree.FindCallerExePath(), + CallerExePath = callerExePath, }); return 0; } // Stop:任务完成通知,正文取本次 prompt - public static int Notify() + public static int Notify(string? source = null) { var input = ReadStdin(); if (string.IsNullOrEmpty(input?.SessionId)) @@ -51,24 +82,32 @@ public static class CliRunner } var state = StateStore.Load(input.SessionId); - var message = !string.IsNullOrWhiteSpace(state?.Prompt) ? state!.Prompt : "Task completed"; + var isCodex = IsCodex(source) || input.HookEventName == "Stop"; + var message = !string.IsNullOrWhiteSpace(state?.Prompt) + ? state!.Prompt + : input.HookLastAssistantMessage ?? "Task completed"; NotificationSpool.Deliver(new NotifyMessage { SessionId = input.SessionId, - Title = "Claude Code", + Title = isCodex ? "Codex" : "Notify", Message = Sanitize(message), InputMode = false, Sticky = false, TargetHwnd = state?.Hwnd ?? 0, WtRuntimeId = state?.WtRuntimeId, - IconPath = state?.CallerExePath, + IconPath = IsCodex(source) ? GetCodexIconPath() : state?.CallerExePath, }); + if (isCodex) + { + WriteHookSuccess(); + } + return 0; } // Notification / PreToolUse:需要输入,常驻显示 - public static int Input() + public static int Input(string? source = null) { var input = ReadStdin(); if (string.IsNullOrEmpty(input?.SessionId)) @@ -94,7 +133,7 @@ public static class CliRunner Sticky = true, TargetHwnd = state?.Hwnd ?? 0, WtRuntimeId = state?.WtRuntimeId, - IconPath = state?.CallerExePath, + IconPath = IsCodex(source) ? GetCodexIconPath() : state?.CallerExePath, }); return 0; } @@ -114,28 +153,53 @@ public static class CliRunner // 按 tool_name / notification_type 决定标题与正文,对齐原版语义 private static (string Title, string Message) Resolve(HookInput input) { + if (input.HookEventName == "PermissionRequest") + { + var tool = string.IsNullOrWhiteSpace(input.ToolName) ? "this action" : input.ToolName; + return ("Codex Permission Required", $"Codex needs permission for {tool}"); + } + if (input.ToolName == "AskUserQuestion") { - var msg = string.IsNullOrEmpty(input.Message) ? "Claude 在向你提问" : input.Message!; - return ("Claude is Asking", msg); + var msg = string.IsNullOrEmpty(input.Message) ? "Input is required" : input.Message!; + return ("Input Required", msg); } if (input.ToolName == "ExitPlanMode") { - return ("Plan Ready for Approval", "Claude 提交了一份计划,待批准"); + return ("Plan Ready for Approval", "A plan is ready for approval"); } var title = input.NotificationType switch { "permission_prompt" => "Permission Required", - "idle_prompt" => "Claude is Waiting", + "idle_prompt" => "Application is Waiting", "elicitation_dialog" => "MCP Asks", _ => "Input Required", }; - var message = string.IsNullOrEmpty(input.Message) ? "Claude needs your input" : input.Message!; + var message = string.IsNullOrEmpty(input.Message) ? "Input is required" : input.Message!; return (title, message); } + private static string ResolveIconPath(string? source, IntPtr hwnd) + { + if (IsCodex(source)) + { + return GetCodexIconPath(); + } + + var callerExePath = ProcessTree.FindCallerExePath(); + return string.IsNullOrEmpty(callerExePath) + ? ProcessTree.FindWindowExePath(hwnd) + : callerExePath; + } + + private static bool IsCodex(string? source) => + string.Equals(source, CodexSource, StringComparison.OrdinalIgnoreCase); + + // notify.exe embeds Assets/codex.ico, so this path provides a stable Codex icon. + private static string GetCodexIconPath() => Environment.ProcessPath ?? ""; + // 折叠换行/制表/多余空白为单行,避免撑乱 toast 布局(截断交给 toast 的省略号) private static string Sanitize(string s) { @@ -175,11 +239,42 @@ public static class CliRunner return null; } - return JsonSerializer.Deserialize(text, AppJsonContext.Default.HookInput); + return Deserialize(text); } catch { return null; } } + + private static HookInput? Deserialize(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(text.TrimStart(''), AppJsonContext.Default.HookInput); + } + catch + { + return null; + } + } + + private static void WriteHookSuccess() + { + try + { + var bytes = Encoding.UTF8.GetBytes("{}\n"); + using var stdout = Console.OpenStandardOutput(); + stdout.Write(bytes, 0, bytes.Length); + } + catch + { + // A detached/manual invocation may not have stdout. + } + } } diff --git a/Notify/Cli/HookInput.cs b/Notify/Cli/HookInput.cs index 306a137..17de97f 100644 --- a/Notify/Cli/HookInput.cs +++ b/Notify/Cli/HookInput.cs @@ -3,7 +3,7 @@ using System.Text.Json.Serialization; namespace Notify.Cli; /// -/// Claude Code 钩子经 stdin 传入的 JSON +/// 外部 hook 或 Codex notify 命令传入的 JSON /// public sealed class HookInput { @@ -21,4 +21,31 @@ public sealed class HookInput [JsonPropertyName("tool_name")] public string? ToolName { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("thread-id")] + public string? ThreadId { get; set; } + + [JsonPropertyName("turn-id")] + public string? TurnId { get; set; } + + [JsonPropertyName("cwd")] + public string? Cwd { get; set; } + + [JsonPropertyName("input-messages")] + public string[]? InputMessages { get; set; } + + [JsonPropertyName("last-assistant-message")] + public string? LastAssistantMessage { get; set; } + + [JsonPropertyName("hook_event_name")] + public string? HookEventName { get; set; } + + [JsonPropertyName("turn_id")] + public string? HookTurnId { get; set; } + + [JsonPropertyName("last_assistant_message")] + public string? HookLastAssistantMessage { get; set; } } diff --git a/Notify/Interop/ProcessTree.cs b/Notify/Interop/ProcessTree.cs index e62834d..8340bf3 100644 --- a/Notify/Interop/ProcessTree.cs +++ b/Notify/Interop/ProcessTree.cs @@ -63,6 +63,24 @@ internal static partial class ProcessTree return ""; } + public static string FindWindowExePath(IntPtr hwnd) + { + if (hwnd == IntPtr.Zero) + { + return ""; + } + + try + { + GetWindowThreadProcessId(hwnd, out var pid); + return pid == 0 ? "" : GetFullPath(pid); + } + catch + { + return ""; + } + } + private static Dictionary BuildParentMap() { var map = new Dictionary(); @@ -128,6 +146,9 @@ internal static partial class ProcessTree [LibraryImport("kernel32.dll")] private static partial uint GetCurrentProcessId(); + [LibraryImport("user32.dll")] + private static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + [LibraryImport("kernel32.dll")] private static partial IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID); diff --git a/Notify/Interop/Win32.cs b/Notify/Interop/Win32.cs index e3d1c85..bab3da2 100644 --- a/Notify/Interop/Win32.cs +++ b/Notify/Interop/Win32.cs @@ -43,6 +43,9 @@ internal static partial class Win32 [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool IsWindow(IntPtr hWnd); + [LibraryImport("user32.dll")] + internal static partial IntPtr GetAncestor(IntPtr hWnd, uint gaFlags); + [LibraryImport("user32.dll")] internal static partial void SwitchToThisWindow(IntPtr hWnd, [MarshalAs(UnmanagedType.Bool)] bool fAltTab); @@ -81,6 +84,9 @@ internal static partial class Win32 internal const uint ASFW_ANY = 0xFFFFFFFF; internal const int SW_RESTORE = 9; internal const int SW_SHOW = 5; + internal const uint GA_ROOT = 2; + internal static readonly IntPtr HWND_TOPMOST = new(-1); + internal static readonly IntPtr HWND_NOTOPMOST = new(-2); internal const uint SWP_NOSIZE = 0x0001; internal const uint SWP_NOMOVE = 0x0002; internal const uint SWP_SHOWWINDOW = 0x0040; diff --git a/Notify/Interop/WindowActivator.cs b/Notify/Interop/WindowActivator.cs index d39590e..22373f2 100644 --- a/Notify/Interop/WindowActivator.cs +++ b/Notify/Interop/WindowActivator.cs @@ -18,6 +18,19 @@ public static class WindowActivator return false; } + var root = Win32.GetAncestor(hwnd, Win32.GA_ROOT); + if (root != IntPtr.Zero) + { + hwnd = root; + } + + var foreground = Win32.GetForegroundWindow(); + var curThread = Win32.GetCurrentThreadId(); + var fgThread = Win32.GetWindowThreadProcessId(foreground, out _); + var targetThread = Win32.GetWindowThreadProcessId(hwnd, out _); + var attachedForeground = false; + var attachedTarget = false; + try { // 最小化的先还原 @@ -26,23 +39,18 @@ public static class WindowActivator Win32.ShowWindow(hwnd, Win32.SW_RESTORE); } - var foreground = Win32.GetForegroundWindow(); - var curThread = Win32.GetCurrentThreadId(); - var fgThread = Win32.GetWindowThreadProcessId(foreground, out _); - var targetThread = Win32.GetWindowThreadProcessId(hwnd, out _); - // 模拟一次 ALT 抬起,满足 Windows 的"防焦点抢占"前置条件 Win32.keybd_event(Win32.VK_MENU, 0, 0, IntPtr.Zero); Win32.keybd_event(Win32.VK_MENU, 0, Win32.KEYEVENTF_KEYUP, IntPtr.Zero); if (fgThread != curThread) { - Win32.AttachThreadInput(curThread, fgThread, true); + attachedForeground = Win32.AttachThreadInput(curThread, fgThread, true); } if (targetThread != curThread && targetThread != fgThread) { - Win32.AttachThreadInput(curThread, targetThread, true); + attachedTarget = Win32.AttachThreadInput(curThread, targetThread, true); } Win32.AllowSetForegroundWindow(Win32.ASFW_ANY); @@ -52,21 +60,33 @@ public static class WindowActivator Win32.SetForegroundWindow(hwnd); Win32.ShowWindow(hwnd, Win32.SW_SHOW); - if (targetThread != curThread && targetThread != fgThread) + if (Win32.GetForegroundWindow() != hwnd) { - Win32.AttachThreadInput(curThread, targetThread, false); + Win32.SetWindowPos(hwnd, Win32.HWND_TOPMOST, 0, 0, 0, 0, + Win32.SWP_NOMOVE | Win32.SWP_NOSIZE | Win32.SWP_SHOWWINDOW); + Win32.SetWindowPos(hwnd, Win32.HWND_NOTOPMOST, 0, 0, 0, 0, + Win32.SWP_NOMOVE | Win32.SWP_NOSIZE | Win32.SWP_SHOWWINDOW); + Win32.BringWindowToTop(hwnd); + Win32.SetForegroundWindow(hwnd); } - if (fgThread != curThread) - { - Win32.AttachThreadInput(curThread, fgThread, false); - } - - return Win32.GetForegroundWindow() == hwnd; + return Win32.GetAncestor(Win32.GetForegroundWindow(), Win32.GA_ROOT) == hwnd; } catch { return false; } + finally + { + if (attachedTarget) + { + Win32.AttachThreadInput(curThread, targetThread, false); + } + + if (attachedForeground) + { + Win32.AttachThreadInput(curThread, fgThread, false); + } + } } } diff --git a/Notify/Ipc/IpcConstants.cs b/Notify/Ipc/IpcConstants.cs index 0fafb53..cc6ca9f 100644 --- a/Notify/Ipc/IpcConstants.cs +++ b/Notify/Ipc/IpcConstants.cs @@ -3,5 +3,5 @@ namespace Notify.Ipc; internal static class IpcConstants { // 保证 Host 单例的互斥量名(Local 级,按用户会话隔离) - public const string HostMutexName = "ClaudeCodeNotifyHost"; + public const string HostMutexName = "NotifyHost"; } diff --git a/Notify/Ipc/NotificationSpool.cs b/Notify/Ipc/NotificationSpool.cs index 3409150..558804a 100644 --- a/Notify/Ipc/NotificationSpool.cs +++ b/Notify/Ipc/NotificationSpool.cs @@ -10,12 +10,12 @@ namespace Notify.Ipc; /// /// 基于落盘队列的非阻塞投递:CLI 写文件后立即返回,Host 监视目录消费 /// -/// 取代命名管道,避免 CLI 在 Host 冷启动时被阻塞而拖住 Claude Code +/// 取代命名管道,避免 CLI 在 Host 冷启动时阻塞调用方 /// public static class NotificationSpool { public static readonly string Dir = - Path.Combine(Path.GetTempPath(), "claude-notify-spool"); + Path.Combine(Path.GetTempPath(), "notify-spool"); // CLI 侧:写入一条请求,必要时拉起 Host,全程不阻塞 public static void Deliver(NotifyMessage message) @@ -64,8 +64,7 @@ public static class NotificationSpool } // 必须用 UseShellExecute=true 让 host 彻底脱离本进程的标准句柄 - // 否则常驻 host 会继承并攥住钩子的 stdout 管道,导致 Claude Code - // 等不到管道 EOF 而卡在 "running stop hook" + // 否则常驻 Host 会继承钩子的 stdout 管道,导致调用方等不到 EOF。 Process.Start(new ProcessStartInfo { FileName = exe, diff --git a/Notify/Notify.csproj b/Notify/Notify.csproj index 9570b4c..4839854 100644 --- a/Notify/Notify.csproj +++ b/Notify/Notify.csproj @@ -8,7 +8,7 @@ true app.manifest true - Assets\claude.ico + Assets\codex.ico Notify notify 1.0.0 @@ -16,7 +16,16 @@ true - + + + true + false + true + false + + + win-x64 true @@ -50,8 +59,9 @@ CoreUtils.SkiaSharp.Static 含 skia + libHarfBuzzSharp 的 .lib,CoreUtils.ANGLE.Static 含 ANGLE 两包各自的 .targets 会在 PublishAot 时自动追加 NativeLibrary,这里只补 DirectPInvoke 与系统 lib 版本对应:Avalonia 12 → SkiaSharp 3.119 + 仅 AOT 需要;框架依赖单文件用 Avalonia 自带的动态 Skia 原生库 --> - + @@ -79,4 +89,12 @@ + + + + <_SingleFileJunk Include="$(PublishDir)*.pdb" /> + + + + diff --git a/Notify/Program.cs b/Notify/Program.cs index ab6e241..e687065 100644 --- a/Notify/Program.cs +++ b/Notify/Program.cs @@ -23,14 +23,16 @@ internal static class Program public static int Main(string[] args) { var mode = args.Length > 0 ? args[0].ToLowerInvariant() : "host"; + var source = args.Length > 1 ? args[1].ToLowerInvariant() : null; // 钩子子命令:纯互操作 / IPC,不加载 Avalonia return mode switch { - "save" => CliRunner.Save(), - "notify" => CliRunner.Notify(), - "input" => CliRunner.Input(), + "save" => CliRunner.Save(source), + "notify" => CliRunner.Notify(source), + "input" => CliRunner.Input(source), "cleanup" => CliRunner.Cleanup(), + "codex" => CliRunner.Codex(args.Length > 1 ? args[^1] : null), _ => RunHost(args), }; } diff --git a/Notify/Services/SettingsService.cs b/Notify/Services/SettingsService.cs index 169f5fc..d546ef6 100644 --- a/Notify/Services/SettingsService.cs +++ b/Notify/Services/SettingsService.cs @@ -12,7 +12,7 @@ namespace Notify.Services; public sealed class SettingsService { private static readonly string Dir = - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ClaudeCodeNotify"); + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Notify"); private static readonly string FilePath = Path.Combine(Dir, "settings.json"); diff --git a/Notify/Services/StateStore.cs b/Notify/Services/StateStore.cs index 51efad5..55ab679 100644 --- a/Notify/Services/StateStore.cs +++ b/Notify/Services/StateStore.cs @@ -8,12 +8,12 @@ using Notify.Serialization; namespace Notify.Services; /// -/// 每会话状态文件的读写,位于 %TEMP%\claude-notify-{session_id}.json +/// 每会话状态文件的读写,位于 %TEMP%\notify-{session_id}.json /// public static class StateStore { private static string FilePath(string sessionId) => - Path.Combine(Path.GetTempPath(), $"claude-notify-{Sanitize(sessionId)}.json"); + Path.Combine(Path.GetTempPath(), $"notify-{Sanitize(sessionId)}.json"); public static void Save(string sessionId, StateData data) { diff --git a/Notify/Views/SettingsWindow.axaml b/Notify/Views/SettingsWindow.axaml index bb182bd..f59f7bb 100644 --- a/Notify/Views/SettingsWindow.axaml +++ b/Notify/Views/SettingsWindow.axaml @@ -9,7 +9,7 @@ SizeToContent="Height" CanResize="False" WindowStartupLocation="CenterScreen" - Icon="/Assets/claude.ico" + Icon="/Assets/codex.ico" Title="弹窗设置"> diff --git a/Notify/Views/ToastWindow.axaml b/Notify/Views/ToastWindow.axaml index 3c54e0c..a9403c5 100644 --- a/Notify/Views/ToastWindow.axaml +++ b/Notify/Views/ToastWindow.axaml @@ -29,7 +29,7 @@ x:Name="IconImage" Width="44" Height="44" VerticalAlignment="Center" - Source="/Assets/claude.ico" /> + IsVisible="False" /> 为 Claude Code 提供原生 Windows 通知:任务完成或需要你输入时弹出 toast,**点击即可跳回原终端 / 编辑器窗口**(并能切回正确的 Windows Terminal 标签页)。 +为命令行工具和 hook 客户端提供原生 Windows 通知。任务完成时弹出 toast,点击后返回原终端或编辑器窗口,并可切回对应的 Windows Terminal 标签页。 -原版 Rust 项目的 **C# / .NET 10 + Avalonia 12** 重写版,采用「CLI 子命令 + 常驻 Host」进程模型,由 Claude Code 的 hook 驱动。仅支持 Windows 10 / 11 (x64)。 +项目使用 C#、.NET 10 和 Avalonia 12,仅支持 Windows 10/11 x64。 ## 功能 @@ -10,16 +10,16 @@ - 点击 toast 跳回发起请求的窗口,并能切回原 Windows Terminal 标签页 - 自动识别并显示调用方 App 图标(VSCode / Cursor / JetBrains / 终端…) - 输入类通知常驻、完成类自动消失(正盯着目标窗口时停留更短) -- 非阻塞投递,钩子毫秒级返回,不拖慢 Claude Code +- 非阻塞投递,钩子写入通知后立即返回 - NativeAOT 单文件、无运行时依赖 ## 架构 ```mermaid flowchart LR - CC[Claude Code] -->|UserPromptSubmit| SAVE["notify save"] + CC[Hook client] -->|UserPromptSubmit| SAVE["notify save"] CC -->|Stop| NOTIFY["notify notify"] - CC -->|Notification / PreToolUse| INPUT["notify input"] + CC -->|PermissionRequest| INPUT["notify input"] CC -->|SessionEnd| CLEAN["notify cleanup"] SAVE --> ST[(状态文件)] @@ -52,7 +52,7 @@ flowchart LR sequenceDiagram autonumber actor User as 用户 - participant CC as Claude Code + participant CC as Hook client participant Cli as notify (CLI) participant State as 状态文件 participant Spool as spool 队列 @@ -65,7 +65,7 @@ sequenceDiagram Cli->>State: 写入状态 Cli-->>CC: 立即退出 - Note over CC: Claude 处理中… + Note over CC: 任务处理中 alt 任务完成 CC->>Cli: notify notify @@ -137,12 +137,15 @@ sequenceDiagram ## 安装 -```bash -claude plugin marketplace add https://git.pchuan.top/cc-tools/notify.git -claude plugin install claude-code-notify@claude-code-notify +### Codex 示例 + +先将本仓库放在固定位置,再把以下配置写入用户级 `~/.codex/config.toml`: + +```toml +notify = ["E:\\notify\\scripts\\notify.cmd", "codex"] ``` -重启 Claude Code 后即生效。首次触发钩子时,`scripts/notify.cmd` 会自动从 Release 下载单文件 `notify.exe`,之后常驻。 +Codex 完成任务后会把通知 JSON 传给脚本。首次调用会在后台下载 `notify.exe`。 - 托盘**左键单击**打开设置,**右键**退出。 - 从源码构建见 [docs/build-and-install.md](docs/build-and-install.md)。 diff --git a/docs/architecture.md b/docs/architecture.md index a771cdd..7bd3fea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,7 +9,7 @@ | **CLI 子命令** | `notify save\|notify\|input\|cleanup` | 否(纯互操作 / 落盘) | 即起即退(~100ms) | | **Host** | `notify` 或 `notify host` | 是 | 常驻(单例,无主窗口) | -这样设计的原因:hook 在你每次发消息时都会被拉起,必须**极快返回、绝不阻塞 Claude Code**;而真正画 UI 的 Avalonia 较重,放在一个**只初始化一次**的常驻进程里。 +hook 需要快速返回。CLI 只写入状态和通知队列,Avalonia UI 由常驻进程加载一次。 ```mermaid flowchart TD @@ -25,15 +25,15 @@ flowchart TD RUN --> APP[App:托盘 + SpoolWatcher + ToastManager] ``` -- **单例**:`RunHost` 用命名互斥量 `ClaudeCodeNotifyHost` 保证只有一个 Host;第二个实例直接退出。 +- **单例**:`RunHost` 用命名互斥量 `NotifyHost` 保证只有一个 Host;第二个实例直接退出。 - **保活**:`ShutdownMode.OnExplicitShutdown`,没有主窗口也不退,只有托盘"退出"才结束。 ## 两条数据通道 -1. **状态文件**(每会话)`%TEMP%\claude-notify-{session_id}.json` +1. **状态文件**(每会话)`%TEMP%\notify-{session_id}.json` - `save` 写入:前台窗口句柄、prompt、WT 标签 RuntimeId、调用方 exe 路径。 - `notify`/`input` 读取,拼成通知;`cleanup` 删除。 -2. **spool 队列** `%TEMP%\claude-notify-spool\*.json` +2. **spool 队列** `%TEMP%\notify-spool\*.json` - `notify`/`input` 把一条 `NotifyMessage` 原子落盘,Host 用 `FileSystemWatcher` 消费。 - 取代命名管道,**让 CLI 写完即走、不等 Host**(见 README 的「非阻塞投递」时序)。 diff --git a/docs/build-and-install.md b/docs/build-and-install.md index 1a23af8..007823d 100644 --- a/docs/build-and-install.md +++ b/docs/build-and-install.md @@ -1,13 +1,14 @@ # 构建与安装 -## 安装(用户) +## Codex 接入示例 -```bash -claude plugin marketplace add https://git.pchuan.top/cc-tools/notify.git -claude plugin install claude-code-notify@claude-code-notify +在用户级 `~/.codex/config.toml` 中加入: + +```toml +notify = ["E:\\notify\\scripts\\notify.cmd", "codex"] ``` -重启 Claude Code 后生效。插件的 `hooks/hooks.json` 指向 `${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd`,首次触发钩子时该脚本会从 Release 下载单文件 `notify.exe` 到 `bin/`,之后常驻。 +`notify` 必须放在用户级配置中,项目目录内的 `.codex/config.toml` 不会覆盖它。 引导脚本(`scripts/notify.cmd`、`scripts/notify.sh`)顶部的 `DOWNLOAD_URL` 决定从哪拉取 exe;下载用临时文件 + 原子改名 + mkdir 锁,并发触发不会重复下载。 @@ -30,7 +31,7 @@ NativeAOT 静态链接 Skia / HarfBuzz / ANGLE,产出**单个无依赖 exe** ```bash # 从 "Developer Command Prompt for VS" 运行,或用脚本(自动用 vswhere 配 vcvars) scripts\build.bat -# 产物:bin\notify.exe(单文件,~40MB) +# 产物:bin\notify.exe(未压缩单文件,约 40 MB) ``` 然后把它作为 Release 资产发布,并确保引导脚本的 `DOWNLOAD_URL` 指向它: @@ -41,7 +42,7 @@ gh release create v0.1.0 bin/notify.exe > AOT 配置在 `Notify/Notify.csproj`(`PublishAot` 条件块 + `CoreUtils.*.Static` 静态库包 + 发布后清理)。源生成 COM / UIAutomation 与静态渲染需真机运行验证,详见 [interop.md](interop.md)。 -## 不接 Claude 的手动烟雾测试 +## 手动烟雾测试 ```bash notify host & # 起 Host(托盘出现) diff --git a/docs/hooks-and-cli.md b/docs/hooks-and-cli.md index 560b0ad..3de2cd9 100644 --- a/docs/hooks-and-cli.md +++ b/docs/hooks-and-cli.md @@ -1,20 +1,30 @@ # Hook 与 CLI -## Hook → 子命令映射 +## Codex notify -| Claude Code 事件 | 子命令 | 作用 | -|------------------|--------|------| +在用户级 `~/.codex/config.toml` 中配置: + +```toml +notify = ["E:\\notify\\scripts\\notify.cmd", "codex"] +``` + +Codex 调用 `notify codex `。程序处理 `agent-turn-complete` 事件,使用 `thread-id` 或 `turn-id` 标识通知,以 `last-assistant-message` 作为正文。 + +## Hook 与子命令映射 + +| 事件 | 子命令 | 作用 | +|------|--------|------| | `UserPromptSubmit` | `notify save` | 记录前台窗口、prompt、WT 标签、调用方图标路径 | | `Stop` | `notify notify` | 弹"任务完成"通知(自动消失,聚焦时更短) | | `Notification` | `notify input` | 弹"需要输入"通知(常驻),按类型分标题 | | `PreToolUse`(`AskUserQuestion`/`ExitPlanMode`) | `notify input` | 提问 / 出 Plan 时弹常驻通知 | | `SessionEnd` | `notify cleanup` | 删除该会话状态文件 | -`hooks/hooks.json`(插件形式)里命令为 `${CLAUDE_PLUGIN_ROOT}/bin/notify.exe <子命令>`;直连 `settings.json` 时可写 `notify <子命令>` 或绝对路径。 +客户端可调用 `notify.exe <子命令> [来源]`。来源为 `codex` 时固定显示 Codex 图标。 ## stdin JSON -Claude Code 通过 **stdin** 把事件数据以 JSON 传入。`HookInput` 关心这几个字段: +外部 hook 通过 **stdin** 把事件数据以 JSON 传入。`HookInput` 使用以下字段: | 字段 | 用途 | |------|------| @@ -30,10 +40,10 @@ Claude Code 通过 **stdin** 把事件数据以 JSON 传入。`HookInput` 关心 | 条件 | 标题 | |------|------| -| `tool_name == AskUserQuestion` | Claude is Asking | +| `tool_name == AskUserQuestion` | Input Required | | `tool_name == ExitPlanMode` | Plan Ready for Approval | | `notification_type == permission_prompt` | Permission Required | -| `notification_type == idle_prompt` | Claude is Waiting | +| `notification_type == idle_prompt` | Application is Waiting | | `notification_type == elicitation_dialog` | MCP Asks | | 其它 | Input Required | @@ -41,7 +51,7 @@ Claude Code 通过 **stdin** 把事件数据以 JSON 传入。`HookInput` 关心 ## 状态文件 -路径:`%TEMP%\claude-notify-{session_id}.json`(`session_id` 做了文件名安全过滤)。 +路径:`%TEMP%\notify-{session_id}.json`(`session_id` 做了文件名安全过滤)。 ```jsonc { diff --git a/docs/interop.md b/docs/interop.md index 2acf57c..d695502 100644 --- a/docs/interop.md +++ b/docs/interop.md @@ -30,7 +30,7 @@ Windows 限制后台进程抢焦点。组合技:还原最小化 → 模拟一 ## 取图标(AppIcon) -`ExtractIconEx` 拿 HICON → `GetIconInfo` 取彩色位图 → `GetDIBits` 以 32bpp 自上而下读出 BGRA → 构造 `Avalonia.Media.Imaging.Bitmap`。老图标无 alpha(全 0)时补成不透明,避免整块透明。取不到则回退默认 Claude 图标。 +`ExtractIconEx` 拿 HICON → `GetIconInfo` 取彩色位图 → `GetDIBits` 以 32bpp 自上而下读出 BGRA → 构造 `Avalonia.Media.Imaging.Bitmap`。老图标无 alpha(全 0)时补成不透明。取不到图标时隐藏图标区域。 ## AOT diff --git a/hooks/hooks.json b/hooks/hooks.json deleted file mode 100644 index 4511ca6..0000000 --- a/hooks/hooks.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "hooks": { - "UserPromptSubmit": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd save", - "timeout": 30 - } - ] - } - ], - "Notification": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd input", - "timeout": 30 - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "AskUserQuestion|ExitPlanMode", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd input", - "timeout": 30 - } - ] - } - ], - "Stop": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd notify", - "timeout": 30 - } - ] - } - ], - "SessionEnd": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/notify.cmd cleanup", - "timeout": 10 - } - ] - } - ] - } -} diff --git a/scripts/build.bat b/scripts/build.bat index 6040b19..6e35f09 100644 --- a/scripts/build.bat +++ b/scripts/build.bat @@ -9,7 +9,7 @@ if exist "%VSWHERE%" ( for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSPATH=%%i" ) if defined VSPATH if exist "%VSPATH%\VC\Auxiliary\Build\vcvars64.bat" ( - echo === 配置 MSVC 环境: %VSPATH% === + echo === Configure MSVC env: %VSPATH% === call "%VSPATH%\VC\Auxiliary\Build\vcvars64.bat" >nul ) @@ -17,10 +17,13 @@ echo === NativeAOT publish (win-x64) -^> bin\notify.exe === dotnet publish Notify -c Release -r win-x64 -p:PublishAot=true -o bin if errorlevel 1 ( echo. - echo *** 发布失败。若提示找不到 link.exe,请从 "Developer Command Prompt for VS" 运行本脚本 *** + echo *** publish failed. If link.exe not found, run from "Developer Command Prompt for VS" *** exit /b 1 ) +rem 清理发布目录残留:只留 notify.exe(AOT 下 -o 拷贝时序使 csproj 内清理不可靠,这里统一处理) +del /q bin\*.pdb bin\*.dll bin\*.lib >nul 2>&1 + echo. -echo === 完成: %CD%\bin\notify.exe === +echo === Done: %CD%\bin\notify.exe === endlocal diff --git a/scripts/download.ps1 b/scripts/download.ps1 deleted file mode 100644 index d3ed06a..0000000 --- a/scripts/download.ps1 +++ /dev/null @@ -1,19 +0,0 @@ -param( - [Parameter(Mandatory = $true)][string]$Url, - [Parameter(Mandatory = $true)][string]$Out, - [Parameter(Mandatory = $true)][string]$Lock -) - -# Download notify.exe with curl to a temp file, then atomic-rename on success. -# Always remove the lock dir at the end (success or failure). -$tmp = $Out + ".downloading" -try { - & curl.exe -fsSL $Url -o $tmp - if ((Test-Path -LiteralPath $tmp) -and ((Get-Item -LiteralPath $tmp).Length -gt 0)) { - Move-Item -LiteralPath $tmp -Destination $Out -Force - } -} -finally { - Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $Lock -Recurse -Force -ErrorAction SilentlyContinue -} diff --git a/scripts/notify.cmd b/scripts/notify.cmd index 79ba2b9..f5e88f2 100644 --- a/scripts/notify.cmd +++ b/scripts/notify.cmd @@ -6,21 +6,60 @@ rem ============================================================ setlocal set "BIN=%~dp0..\bin" set "EXE=%BIN%\notify.exe" +set "PART=%BIN%\notify.exe.partial" set "LOCK=%BIN%\notify.download.lock" -rem first run: kick off a background download, do NOT block the hook -if not exist "%EXE%" call :bootstrap +rem hidden self-reinvocation: detached background downloader (see :downloader) +if "%~1"=="__download" goto downloader -if exist "%EXE%" "%EXE%" %* -endlocal -exit /b +rem common path: exe present -> run directly (keeps piped stdin intact) +if exist "%EXE%" ( + "%EXE%" %* + endlocal & exit /b +) -:bootstrap +rem ---- exe missing: never block the hook ---- +rem kick off the download once (atomic mkdir lock); a detached worker survives the +rem hook timeout. then report progress and return immediately. if not exist "%BIN%" mkdir "%BIN%" 2>nul -rem mkdir is atomic; success = we start the download, failure = already downloading -mkdir "%LOCK%" 2>nul -if errorlevel 1 exit /b -if exist "%EXE%" ( rmdir "%LOCK%" 2>nul & exit /b ) -rem launch a detached hidden PowerShell downloader (ShellExecute breaks away from the hook) -powershell -NoProfile -WindowStyle Hidden -Command "Start-Process -WindowStyle Hidden -FilePath powershell -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','%~dp0download.ps1','-Url','%DOWNLOAD_URL%','-Out','%EXE%','-Lock','%LOCK%')" -exit /b + +rem self-heal: if a previous worker was hard-killed (shutdown/crash) it may leave a +rem stale lock that blocks all retries. reclaim it so -C - can resume the .partial. +if exist "%LOCK%" call :reclaim + +rem atomic lock: only the first hook spawns the worker; others fall through and just +rem report progress -> no duplicate downloads even when hooks fire concurrently. +rem +rem must use Start-Process (not `start /b`): a child started by cmd inherits the +rem hook's stdout pipe handle, so the caller won't see EOF until the download ends -> +rem the hook would block. Start-Process spawns WITHOUT inheriting handles, so the +rem hook returns immediately while curl keeps running detached. +mkdir "%LOCK%" 2>nul && powershell -nop -w hidden -c "Start-Process -WindowStyle Hidden -FilePath '%~f0' -ArgumentList '__download'" >nul 2>&1 + +rem downloaded size so far (from the .partial file), shown as X.X MB +set "DLBYTES=0" +if exist "%PART%" for %%A in ("%PART%") do set "DLBYTES=%%~zA" +set /a DLKB=DLBYTES/1024 +set /a DLMB=DLKB/1024 +set /a DLF=(DLKB*10/1024)%%10 + +rem Some hook clients display this progress JSON; others ignore it. +echo {"suppressOutput":true,"systemMessage":"[Notify] notifier not ready, downloading in background: %DLMB%.%DLF% MB / ~14 MB done. Works automatically once finished; this notification is skipped."} +endlocal & exit /b 0 + +:downloader +rem detached worker: resume-capable download (-C -), atomic install, always free lock. +rem if curl fails (slow/flaky net), the .partial is kept and the next hook resumes it. +curl -fsSL -C - "%DOWNLOAD_URL%" -o "%PART%" +if not errorlevel 1 move /y "%PART%" "%EXE%" >nul 2>&1 +rmdir "%LOCK%" 2>nul +endlocal & exit /b + +:reclaim +rem no .partial yet => worker died before downloading anything; reclaim immediately +if not exist "%PART%" ( rmdir "%LOCK%" 2>nul & goto :eof ) +rem .partial idle for >120s => worker is dead (a live curl writes continuously); reclaim +for /f "delims=" %%T in ('powershell -nop -c "[int]((Get-Date)-(Get-Item '%PART%').LastWriteTime).TotalSeconds" 2^>nul') do set "IDLE=%%T" +if not defined IDLE goto :eof +if %IDLE% geq 120 rmdir "%LOCK%" 2>nul +goto :eof diff --git a/scripts/notify.sh b/scripts/notify.sh index cc41ae1..79670bb 100644 --- a/scripts/notify.sh +++ b/scripts/notify.sh @@ -7,18 +7,39 @@ DOWNLOAD_URL="https://git.pchuan.top/cc-tools/notify/releases/download/v1.0.0/no DIR="$(cd "$(dirname "$0")" && pwd)" BIN="$DIR/../bin" EXE="$BIN/notify.exe" +PART="$BIN/notify.exe.partial" LOCK="$BIN/notify.download.lock" -# 首次运行:后台下载,不阻塞钩子 -if [ ! -f "$EXE" ]; then - mkdir -p "$BIN" 2>/dev/null - # mkdir 原子:成功=本进程负责下载,失败=已有进程在下 - if mkdir "$LOCK" 2>/dev/null; then - TMP="$EXE.downloading" - # nohup + & 让下载脱离钩子在后台跑;完成后原子改名并清锁 - nohup sh -c "curl -fsSL '$DOWNLOAD_URL' -o '$TMP' && mv -f '$TMP' '$EXE'; rmdir '$LOCK' 2>/dev/null" >/dev/null 2>&1 & +# 常规路径:exe 已就绪 -> 直接转发参数与 stdin(保持管道完整) +if [ -f "$EXE" ]; then + exec "$EXE" "$@" +fi + +# ---- exe 缺失:绝不阻塞 hook ---- +mkdir -p "$BIN" 2>/dev/null + +# 自愈:上次下载进程被硬杀(关机/崩溃)可能留下陈旧锁,挡住所有重试。 +# .partial 不存在(没真正开始)或 >2 分钟没增长(进程已死)则回收锁,让 -C - 续传。 +if [ -d "$LOCK" ]; then + if [ ! -f "$PART" ]; then + rmdir "$LOCK" 2>/dev/null + elif [ -n "$(find "$PART" -mmin +2 2>/dev/null)" ]; then + rmdir "$LOCK" 2>/dev/null fi fi -# 本次不等下载;exe 就绪后才转发参数与 stdin -[ -f "$EXE" ] && exec "$EXE" "$@" +# 原子锁:只有第一个 hook 派生唯一的后台下载进程;并发/后续 hook 抢锁失败 +# -> 不重复下载,只汇报进度。下载脱离 hook 后台进行,不受 30s 超时影响。 +if mkdir "$LOCK" 2>/dev/null; then + # 断点续传 -C -;失败保留 .partial 供下次续传;无论成败都释放锁 + nohup sh -c "curl -fsSL -C - '$DOWNLOAD_URL' -o '$PART' && mv -f '$PART' '$EXE' && chmod +x '$EXE'; rmdir '$LOCK' 2>/dev/null" >/dev/null 2>&1 & +fi + +# 已下载大小(取 .partial 字节数) +DLBYTES=0 +[ -f "$PART" ] && DLBYTES=$(wc -c < "$PART" 2>/dev/null | tr -d ' ') +DLMB=$(awk "BEGIN{printf \"%.1f\", $DLBYTES/1048576}") + +# 部分 hook 客户端会显示进度 JSON,其他客户端会忽略它。 +printf '{"suppressOutput":true,"systemMessage":"[Notify] notifier not ready, downloading in background: %s MB / ~14 MB done. Works automatically once finished; this notification is skipped."}\n' "$DLMB" +exit 0