feat: 打通 hook CLI 与命名管道链路(P0)

- 子命令 save/notify/input/cleanup:纯互操作 IPC,不加载 Avalonia
- 命名管道:瘦客户端投递请求,Host 未运行时自动拉起并重试
- 每会话状态文件 %TEMP%\claude-notify-{id}.json,保存前台窗口与 prompt
- Host 单例互斥量 + 管道监听,收到请求切回 UI 线程弹窗
- stdin 改为 UTF-8 原始字节读取,修复中文乱码与读不到的问题
- 符合 Claude Code 插件规范的 .claude-plugin 与 hooks 结构
This commit is contained in:
2026-06-22 14:40:22 +08:00
parent cfb1b99162
commit 37fc06e274
15 changed files with 583 additions and 2 deletions
+18
View File
@@ -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"
}
}
]
}
+9
View File
@@ -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"
}
+20 -1
View File
@@ -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()
{
// 普通:会自动消失
+153
View File
@@ -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;
/// <summary>
/// 钩子子命令实现:纯互操作 / IPC,绝不加载 Avalonia,做完即退出
/// </summary>
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;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace Notify.Cli;
/// <summary>
/// Claude Code 钩子经 stdin 传入的 JSON
/// </summary>
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; }
}
+10
View File
@@ -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();
}
+10
View File
@@ -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";
}
+79
View File
@@ -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;
/// <summary>
/// 瘦客户端侧:把一条 PipeMessage 发给 HostHost 不在则拉起后重试
/// </summary>
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
{
// 拉起失败则发送会重试超时后放弃
}
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace Notify.Ipc;
/// <summary>
/// 瘦客户端经命名管道投递给 Host 的一条弹窗请求
/// </summary>
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; }
}
+58
View File
@@ -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;
/// <summary>
/// Host 侧命名管道监听:每个连接读取一条 PipeMessage 并回调
/// </summary>
public sealed class PipeServer
{
private readonly Action<PipeMessage> _onMessage;
public PipeServer(Action<PipeMessage> 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
{
// 单个连接出错不影响后续监听
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace Notify.Models;
/// <summary>
/// 每会话持久化的状态,由 save 钩子写入、notify 钩子与点击激活读取
/// </summary>
public sealed class StateData
{
// 触发时的前台窗口句柄
public long Hwnd { get; set; }
// 用户当次输入的 prompt,用作"任务完成"通知的正文
public string Prompt { get; set; } = "";
}
+33 -1
View File
@@ -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<App>()
@@ -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;
}
}
+5
View File
@@ -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;
+67
View File
@@ -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;
/// <summary>
/// 每会话状态文件的读写,位于 %TEMP%\claude-notify-{session_id}.json
/// </summary>
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());
}
+64
View File
@@ -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
}
]
}
]
}
}