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
+ }
+ ]
+ }
+ ]
+ }
+}