Merge upstream and add generic Codex notifications

This commit is contained in:
2026-08-04 15:16:36 +08:00
Unverified
31 changed files with 374 additions and 218 deletions
-18
View File
@@ -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"
}
}
]
}
-9
View File
@@ -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"
}
+2 -2
View File
@@ -10,8 +10,8 @@
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="/Assets/claude.ico"
ToolTipText="Claude Code Notify"
<TrayIcon Icon="/Assets/codex.ico"
ToolTipText="Notify"
Clicked="OnTrayClicked">
<TrayIcon.Menu>
<NativeMenu>
+1 -1
View File
@@ -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
{
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+109 -14
View File
@@ -15,8 +15,37 @@ namespace Notify.Cli;
/// </summary>
public static class CliRunner
{
private const string CodexSource = "codex";
// Codex notifyJSON 作为命令行最后一个参数传入。
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.
}
}
}
+28 -1
View File
@@ -3,7 +3,7 @@ using System.Text.Json.Serialization;
namespace Notify.Cli;
/// <summary>
/// Claude Code 钩子经 stdin 传入的 JSON
/// 外部 hook 或 Codex notify 命令传入的 JSON
/// </summary>
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; }
}
+21
View File
@@ -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<uint, (uint Parent, string Name)> BuildParentMap()
{
var map = new Dictionary<uint, (uint, string)>();
@@ -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);
+6
View File
@@ -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;
+35 -15
View File
@@ -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);
}
}
}
}
+1 -1
View File
@@ -3,5 +3,5 @@ namespace Notify.Ipc;
internal static class IpcConstants
{
// 保证 Host 单例的互斥量名(Local 级,按用户会话隔离)
public const string HostMutexName = "ClaudeCodeNotifyHost";
public const string HostMutexName = "NotifyHost";
}
+3 -4
View File
@@ -10,12 +10,12 @@ namespace Notify.Ipc;
/// <summary>
/// 基于落盘队列的非阻塞投递:CLI 写文件后立即返回,Host 监视目录消费
///
/// 取代命名管道,避免 CLI 在 Host 冷启动时阻塞而拖住 Claude Code
/// 取代命名管道,避免 CLI 在 Host 冷启动时阻塞调用方
/// </summary>
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,
+21 -3
View File
@@ -8,7 +8,7 @@
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>Assets\claude.ico</ApplicationIcon>
<ApplicationIcon>Assets\codex.ico</ApplicationIcon>
<RootNamespace>Notify</RootNamespace>
<AssemblyName>notify</AssemblyName>
<Version>1.0.0</Version>
@@ -16,7 +16,16 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<!-- NativeAOT 发布配置(仅 publish 生效):单文件原生 exe -->
<!-- 框架依赖单文件发布(默认,仅 publish 生效):需目标机装 .NET 10 运行时
原生库(Skia/HarfBuzz/ANGLE)随单文件打包、运行时自解压 -->
<PropertyGroup Condition="'$(PublishAot)' != 'true'">
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>false</SelfContained>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<DebuggerSupport>false</DebuggerSupport>
</PropertyGroup>
<!-- NativeAOT 发布配置(需 -p:PublishAot=true,仅 publish 生效):单文件原生 exe -->
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<StripSymbols>true</StripSymbols>
@@ -50,8 +59,9 @@
CoreUtils.SkiaSharp.Static 含 skia + libHarfBuzzSharp 的 .libCoreUtils.ANGLE.Static 含 ANGLE
两包各自的 .targets 会在 PublishAot 时自动追加 NativeLibrary,这里只补 DirectPInvoke 与系统 lib
版本对应:Avalonia 12 → SkiaSharp 3.119
仅 AOT 需要;框架依赖单文件用 Avalonia 自带的动态 Skia 原生库
-->
<ItemGroup>
<ItemGroup Condition="'$(PublishAot)' == 'true'">
<PackageReference Include="CoreUtils.SkiaSharp.Static" Version="3.119.0.1" />
<PackageReference Include="CoreUtils.ANGLE.Static" Version="7151.0.1" />
</ItemGroup>
@@ -79,4 +89,12 @@
<Delete Files="@(_AotJunk)" />
</Target>
<!-- 框架依赖单文件清理:原生库符号(libSkiaSharp.pdb 等)与托管 .pdb 发布不需要,只留 notify.exe -->
<Target Name="CleanSingleFileOutput" AfterTargets="Publish" Condition="'$(PublishAot)' != 'true' And '$(PublishSingleFile)' == 'true'">
<ItemGroup>
<_SingleFileJunk Include="$(PublishDir)*.pdb" />
</ItemGroup>
<Delete Files="@(_SingleFileJunk)" />
</Target>
</Project>
+5 -3
View File
@@ -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),
};
}
+1 -1
View File
@@ -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");
+2 -2
View File
@@ -8,12 +8,12 @@ using Notify.Serialization;
namespace Notify.Services;
/// <summary>
/// 每会话状态文件的读写,位于 %TEMP%\claude-notify-{session_id}.json
/// 每会话状态文件的读写,位于 %TEMP%\notify-{session_id}.json
/// </summary>
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)
{
+1 -1
View File
@@ -9,7 +9,7 @@
SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen"
Icon="/Assets/claude.ico"
Icon="/Assets/codex.ico"
Title="弹窗设置">
<StackPanel Margin="20" Spacing="16">
+1 -1
View File
@@ -29,7 +29,7 @@
x:Name="IconImage"
Width="44" Height="44"
VerticalAlignment="Center"
Source="/Assets/claude.ico" />
IsVisible="False" />
<StackPanel Grid.Column="1" Margin="12,0,8,0" VerticalAlignment="Center" Spacing="2">
<TextBlock Text="{Binding Title}"
+2 -1
View File
@@ -57,13 +57,14 @@ public partial class ToastWindow : Window
Root.BorderBrush = new SolidColorBrush(vm.InputMode ? BorderInput : BorderNormal);
// 调用方 App 图标取不到则保留默认 Claude 图标
// 调用方 App 图标取不到时不显示图标
if (!string.IsNullOrEmpty(iconPath))
{
_appIcon = Notify.Interop.AppIcon.Extract(iconPath);
if (_appIcon is not null)
{
IconImage.Source = _appIcon;
IconImage.IsVisible = true;
}
}
+15 -12
View File
@@ -1,8 +1,8 @@
# Claude Code Notify
# Notify
> 为 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)。
+4 -4
View File
@@ -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 的「非阻塞投递」时序)。
+8 -7
View File
@@ -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(托盘出现)
+18 -8
View File
@@ -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 <JSON>`。程序处理 `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
{
+1 -1
View File
@@ -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
-64
View File
@@ -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
}
]
}
]
}
}
+6 -3
View File
@@ -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.exeAOT 下 -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
-19
View File
@@ -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
}
+52 -13
View File
@@ -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
+31 -10
View File
@@ -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