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:
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
{
|
||||
// 普通:会自动消失
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 发给 Host,Host 不在则拉起后重试
|
||||
/// </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
|
||||
{
|
||||
// 拉起失败则发送会重试超时后放弃
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
// 单个连接出错不影响后续监听
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user