Compare commits
6 Commits
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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
@@ -10,8 +10,8 @@
|
|||||||
|
|
||||||
<TrayIcon.Icons>
|
<TrayIcon.Icons>
|
||||||
<TrayIcons>
|
<TrayIcons>
|
||||||
<TrayIcon Icon="/Assets/claude.ico"
|
<TrayIcon Icon="/Assets/codex.ico"
|
||||||
ToolTipText="Claude Code Notify"
|
ToolTipText="Notify"
|
||||||
Clicked="OnTrayClicked">
|
Clicked="OnTrayClicked">
|
||||||
<TrayIcon.Menu>
|
<TrayIcon.Menu>
|
||||||
<NativeMenu>
|
<NativeMenu>
|
||||||
|
|||||||
+1
-1
@@ -81,7 +81,7 @@ public partial class App : Application
|
|||||||
private void RunDemo()
|
private void RunDemo()
|
||||||
{
|
{
|
||||||
// 普通:会自动消失
|
// 普通:会自动消失
|
||||||
Toasts.Show(new ToastRequest { Title = "Claude Code", Message = "任务已完成 — 4 秒后自动消失" });
|
Toasts.Show(new ToastRequest { Title = "Notify", Message = "任务已完成,4 秒后自动消失" });
|
||||||
// 常驻:InputMode 且 Sticky,不点不消失
|
// 常驻:InputMode 且 Sticky,不点不消失
|
||||||
Toasts.Show(new ToastRequest
|
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
@@ -15,8 +15,37 @@ namespace Notify.Cli;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class CliRunner
|
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
|
// UserPromptSubmit:记录前台窗口与 prompt
|
||||||
public static int Save()
|
public static int Save(string? source = null)
|
||||||
{
|
{
|
||||||
var input = ReadStdin();
|
var input = ReadStdin();
|
||||||
if (string.IsNullOrEmpty(input?.SessionId))
|
if (string.IsNullOrEmpty(input?.SessionId))
|
||||||
@@ -31,18 +60,20 @@ public static class CliRunner
|
|||||||
? WinTerminalTabs.GetSelectedTabRuntimeId(hwnd)
|
? WinTerminalTabs.GetSelectedTabRuntimeId(hwnd)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
var callerExePath = ResolveIconPath(source, hwnd);
|
||||||
|
|
||||||
StateStore.Save(input.SessionId, new StateData
|
StateStore.Save(input.SessionId, new StateData
|
||||||
{
|
{
|
||||||
Hwnd = hwnd.ToInt64(),
|
Hwnd = hwnd.ToInt64(),
|
||||||
Prompt = input.Prompt ?? "",
|
Prompt = input.Prompt ?? "",
|
||||||
WtRuntimeId = wtRuntimeId,
|
WtRuntimeId = wtRuntimeId,
|
||||||
CallerExePath = ProcessTree.FindCallerExePath(),
|
CallerExePath = callerExePath,
|
||||||
});
|
});
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop:任务完成通知,正文取本次 prompt
|
// Stop:任务完成通知,正文取本次 prompt
|
||||||
public static int Notify()
|
public static int Notify(string? source = null)
|
||||||
{
|
{
|
||||||
var input = ReadStdin();
|
var input = ReadStdin();
|
||||||
if (string.IsNullOrEmpty(input?.SessionId))
|
if (string.IsNullOrEmpty(input?.SessionId))
|
||||||
@@ -51,24 +82,32 @@ public static class CliRunner
|
|||||||
}
|
}
|
||||||
|
|
||||||
var state = StateStore.Load(input.SessionId);
|
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
|
NotificationSpool.Deliver(new NotifyMessage
|
||||||
{
|
{
|
||||||
SessionId = input.SessionId,
|
SessionId = input.SessionId,
|
||||||
Title = "Claude Code",
|
Title = isCodex ? "Codex" : "Notify",
|
||||||
Message = Sanitize(message),
|
Message = Sanitize(message),
|
||||||
InputMode = false,
|
InputMode = false,
|
||||||
Sticky = false,
|
Sticky = false,
|
||||||
TargetHwnd = state?.Hwnd ?? 0,
|
TargetHwnd = state?.Hwnd ?? 0,
|
||||||
WtRuntimeId = state?.WtRuntimeId,
|
WtRuntimeId = state?.WtRuntimeId,
|
||||||
IconPath = state?.CallerExePath,
|
IconPath = IsCodex(source) ? GetCodexIconPath() : state?.CallerExePath,
|
||||||
});
|
});
|
||||||
|
if (isCodex)
|
||||||
|
{
|
||||||
|
WriteHookSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification / PreToolUse:需要输入,常驻显示
|
// Notification / PreToolUse:需要输入,常驻显示
|
||||||
public static int Input()
|
public static int Input(string? source = null)
|
||||||
{
|
{
|
||||||
var input = ReadStdin();
|
var input = ReadStdin();
|
||||||
if (string.IsNullOrEmpty(input?.SessionId))
|
if (string.IsNullOrEmpty(input?.SessionId))
|
||||||
@@ -94,7 +133,7 @@ public static class CliRunner
|
|||||||
Sticky = true,
|
Sticky = true,
|
||||||
TargetHwnd = state?.Hwnd ?? 0,
|
TargetHwnd = state?.Hwnd ?? 0,
|
||||||
WtRuntimeId = state?.WtRuntimeId,
|
WtRuntimeId = state?.WtRuntimeId,
|
||||||
IconPath = state?.CallerExePath,
|
IconPath = IsCodex(source) ? GetCodexIconPath() : state?.CallerExePath,
|
||||||
});
|
});
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -114,28 +153,53 @@ public static class CliRunner
|
|||||||
// 按 tool_name / notification_type 决定标题与正文,对齐原版语义
|
// 按 tool_name / notification_type 决定标题与正文,对齐原版语义
|
||||||
private static (string Title, string Message) Resolve(HookInput input)
|
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")
|
if (input.ToolName == "AskUserQuestion")
|
||||||
{
|
{
|
||||||
var msg = string.IsNullOrEmpty(input.Message) ? "Claude 在向你提问" : input.Message!;
|
var msg = string.IsNullOrEmpty(input.Message) ? "Input is required" : input.Message!;
|
||||||
return ("Claude is Asking", msg);
|
return ("Input Required", msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.ToolName == "ExitPlanMode")
|
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
|
var title = input.NotificationType switch
|
||||||
{
|
{
|
||||||
"permission_prompt" => "Permission Required",
|
"permission_prompt" => "Permission Required",
|
||||||
"idle_prompt" => "Claude is Waiting",
|
"idle_prompt" => "Application is Waiting",
|
||||||
"elicitation_dialog" => "MCP Asks",
|
"elicitation_dialog" => "MCP Asks",
|
||||||
_ => "Input Required",
|
_ => "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);
|
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 的省略号)
|
// 折叠换行/制表/多余空白为单行,避免撑乱 toast 布局(截断交给 toast 的省略号)
|
||||||
private static string Sanitize(string s)
|
private static string Sanitize(string s)
|
||||||
{
|
{
|
||||||
@@ -175,11 +239,42 @@ public static class CliRunner
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return JsonSerializer.Deserialize(text, AppJsonContext.Default.HookInput);
|
return Deserialize(text);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return null;
|
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
@@ -3,7 +3,7 @@ using System.Text.Json.Serialization;
|
|||||||
namespace Notify.Cli;
|
namespace Notify.Cli;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Claude Code 钩子经 stdin 传入的 JSON
|
/// 外部 hook 或 Codex notify 命令传入的 JSON
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HookInput
|
public sealed class HookInput
|
||||||
{
|
{
|
||||||
@@ -21,4 +21,31 @@ public sealed class HookInput
|
|||||||
|
|
||||||
[JsonPropertyName("tool_name")]
|
[JsonPropertyName("tool_name")]
|
||||||
public string? ToolName { get; set; }
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ internal static partial class ProcessTree
|
|||||||
return "";
|
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()
|
private static Dictionary<uint, (uint Parent, string Name)> BuildParentMap()
|
||||||
{
|
{
|
||||||
var map = new Dictionary<uint, (uint, string)>();
|
var map = new Dictionary<uint, (uint, string)>();
|
||||||
@@ -128,6 +146,9 @@ internal static partial class ProcessTree
|
|||||||
[LibraryImport("kernel32.dll")]
|
[LibraryImport("kernel32.dll")]
|
||||||
private static partial uint GetCurrentProcessId();
|
private static partial uint GetCurrentProcessId();
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll")]
|
||||||
|
private static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||||
|
|
||||||
[LibraryImport("kernel32.dll")]
|
[LibraryImport("kernel32.dll")]
|
||||||
private static partial IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID);
|
private static partial IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID);
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ internal static partial class Win32
|
|||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
internal static partial bool IsWindow(IntPtr hWnd);
|
internal static partial bool IsWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll")]
|
||||||
|
internal static partial IntPtr GetAncestor(IntPtr hWnd, uint gaFlags);
|
||||||
|
|
||||||
[LibraryImport("user32.dll")]
|
[LibraryImport("user32.dll")]
|
||||||
internal static partial void SwitchToThisWindow(IntPtr hWnd, [MarshalAs(UnmanagedType.Bool)] bool fAltTab);
|
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 uint ASFW_ANY = 0xFFFFFFFF;
|
||||||
internal const int SW_RESTORE = 9;
|
internal const int SW_RESTORE = 9;
|
||||||
internal const int SW_SHOW = 5;
|
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_NOSIZE = 0x0001;
|
||||||
internal const uint SWP_NOMOVE = 0x0002;
|
internal const uint SWP_NOMOVE = 0x0002;
|
||||||
internal const uint SWP_SHOWWINDOW = 0x0040;
|
internal const uint SWP_SHOWWINDOW = 0x0040;
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ public static class WindowActivator
|
|||||||
return false;
|
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
|
try
|
||||||
{
|
{
|
||||||
// 最小化的先还原
|
// 最小化的先还原
|
||||||
@@ -26,23 +39,18 @@ public static class WindowActivator
|
|||||||
Win32.ShowWindow(hwnd, Win32.SW_RESTORE);
|
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 的"防焦点抢占"前置条件
|
// 模拟一次 ALT 抬起,满足 Windows 的"防焦点抢占"前置条件
|
||||||
Win32.keybd_event(Win32.VK_MENU, 0, 0, IntPtr.Zero);
|
Win32.keybd_event(Win32.VK_MENU, 0, 0, IntPtr.Zero);
|
||||||
Win32.keybd_event(Win32.VK_MENU, 0, Win32.KEYEVENTF_KEYUP, IntPtr.Zero);
|
Win32.keybd_event(Win32.VK_MENU, 0, Win32.KEYEVENTF_KEYUP, IntPtr.Zero);
|
||||||
|
|
||||||
if (fgThread != curThread)
|
if (fgThread != curThread)
|
||||||
{
|
{
|
||||||
Win32.AttachThreadInput(curThread, fgThread, true);
|
attachedForeground = Win32.AttachThreadInput(curThread, fgThread, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetThread != curThread && targetThread != fgThread)
|
if (targetThread != curThread && targetThread != fgThread)
|
||||||
{
|
{
|
||||||
Win32.AttachThreadInput(curThread, targetThread, true);
|
attachedTarget = Win32.AttachThreadInput(curThread, targetThread, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Win32.AllowSetForegroundWindow(Win32.ASFW_ANY);
|
Win32.AllowSetForegroundWindow(Win32.ASFW_ANY);
|
||||||
@@ -52,21 +60,33 @@ public static class WindowActivator
|
|||||||
Win32.SetForegroundWindow(hwnd);
|
Win32.SetForegroundWindow(hwnd);
|
||||||
Win32.ShowWindow(hwnd, Win32.SW_SHOW);
|
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)
|
return Win32.GetAncestor(Win32.GetForegroundWindow(), Win32.GA_ROOT) == hwnd;
|
||||||
{
|
|
||||||
Win32.AttachThreadInput(curThread, fgThread, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Win32.GetForegroundWindow() == hwnd;
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (attachedTarget)
|
||||||
|
{
|
||||||
|
Win32.AttachThreadInput(curThread, targetThread, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachedForeground)
|
||||||
|
{
|
||||||
|
Win32.AttachThreadInput(curThread, fgThread, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ namespace Notify.Ipc;
|
|||||||
internal static class IpcConstants
|
internal static class IpcConstants
|
||||||
{
|
{
|
||||||
// 保证 Host 单例的互斥量名(Local 级,按用户会话隔离)
|
// 保证 Host 单例的互斥量名(Local 级,按用户会话隔离)
|
||||||
public const string HostMutexName = "ClaudeCodeNotifyHost";
|
public const string HostMutexName = "NotifyHost";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ namespace Notify.Ipc;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 基于落盘队列的非阻塞投递:CLI 写文件后立即返回,Host 监视目录消费
|
/// 基于落盘队列的非阻塞投递:CLI 写文件后立即返回,Host 监视目录消费
|
||||||
///
|
///
|
||||||
/// 取代命名管道,避免 CLI 在 Host 冷启动时被阻塞而拖住 Claude Code
|
/// 取代命名管道,避免 CLI 在 Host 冷启动时阻塞调用方
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class NotificationSpool
|
public static class NotificationSpool
|
||||||
{
|
{
|
||||||
public static readonly string Dir =
|
public static readonly string Dir =
|
||||||
Path.Combine(Path.GetTempPath(), "claude-notify-spool");
|
Path.Combine(Path.GetTempPath(), "notify-spool");
|
||||||
|
|
||||||
// CLI 侧:写入一条请求,必要时拉起 Host,全程不阻塞
|
// CLI 侧:写入一条请求,必要时拉起 Host,全程不阻塞
|
||||||
public static void Deliver(NotifyMessage message)
|
public static void Deliver(NotifyMessage message)
|
||||||
@@ -64,8 +64,7 @@ public static class NotificationSpool
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 必须用 UseShellExecute=true 让 host 彻底脱离本进程的标准句柄
|
// 必须用 UseShellExecute=true 让 host 彻底脱离本进程的标准句柄
|
||||||
// 否则常驻 host 会继承并攥住钩子的 stdout 管道,导致 Claude Code
|
// 否则常驻 Host 会继承钩子的 stdout 管道,导致调用方等不到 EOF。
|
||||||
// 等不到管道 EOF 而卡在 "running stop hook"
|
|
||||||
Process.Start(new ProcessStartInfo
|
Process.Start(new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = exe,
|
FileName = exe,
|
||||||
|
|||||||
+21
-3
@@ -8,7 +8,7 @@
|
|||||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<ApplicationIcon>Assets\claude.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\codex.ico</ApplicationIcon>
|
||||||
<RootNamespace>Notify</RootNamespace>
|
<RootNamespace>Notify</RootNamespace>
|
||||||
<AssemblyName>notify</AssemblyName>
|
<AssemblyName>notify</AssemblyName>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.0</Version>
|
||||||
@@ -16,7 +16,16 @@
|
|||||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</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'">
|
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<StripSymbols>true</StripSymbols>
|
<StripSymbols>true</StripSymbols>
|
||||||
@@ -50,8 +59,9 @@
|
|||||||
CoreUtils.SkiaSharp.Static 含 skia + libHarfBuzzSharp 的 .lib,CoreUtils.ANGLE.Static 含 ANGLE
|
CoreUtils.SkiaSharp.Static 含 skia + libHarfBuzzSharp 的 .lib,CoreUtils.ANGLE.Static 含 ANGLE
|
||||||
两包各自的 .targets 会在 PublishAot 时自动追加 NativeLibrary,这里只补 DirectPInvoke 与系统 lib
|
两包各自的 .targets 会在 PublishAot 时自动追加 NativeLibrary,这里只补 DirectPInvoke 与系统 lib
|
||||||
版本对应:Avalonia 12 → SkiaSharp 3.119
|
版本对应: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.SkiaSharp.Static" Version="3.119.0.1" />
|
||||||
<PackageReference Include="CoreUtils.ANGLE.Static" Version="7151.0.1" />
|
<PackageReference Include="CoreUtils.ANGLE.Static" Version="7151.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -79,4 +89,12 @@
|
|||||||
<Delete Files="@(_AotJunk)" />
|
<Delete Files="@(_AotJunk)" />
|
||||||
</Target>
|
</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>
|
</Project>
|
||||||
|
|||||||
+5
-3
@@ -23,14 +23,16 @@ internal static class Program
|
|||||||
public static int Main(string[] args)
|
public static int Main(string[] args)
|
||||||
{
|
{
|
||||||
var mode = args.Length > 0 ? args[0].ToLowerInvariant() : "host";
|
var mode = args.Length > 0 ? args[0].ToLowerInvariant() : "host";
|
||||||
|
var source = args.Length > 1 ? args[1].ToLowerInvariant() : null;
|
||||||
|
|
||||||
// 钩子子命令:纯互操作 / IPC,不加载 Avalonia
|
// 钩子子命令:纯互操作 / IPC,不加载 Avalonia
|
||||||
return mode switch
|
return mode switch
|
||||||
{
|
{
|
||||||
"save" => CliRunner.Save(),
|
"save" => CliRunner.Save(source),
|
||||||
"notify" => CliRunner.Notify(),
|
"notify" => CliRunner.Notify(source),
|
||||||
"input" => CliRunner.Input(),
|
"input" => CliRunner.Input(source),
|
||||||
"cleanup" => CliRunner.Cleanup(),
|
"cleanup" => CliRunner.Cleanup(),
|
||||||
|
"codex" => CliRunner.Codex(args.Length > 1 ? args[^1] : null),
|
||||||
_ => RunHost(args),
|
_ => RunHost(args),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace Notify.Services;
|
|||||||
public sealed class SettingsService
|
public sealed class SettingsService
|
||||||
{
|
{
|
||||||
private static readonly string Dir =
|
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");
|
private static readonly string FilePath = Path.Combine(Dir, "settings.json");
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ using Notify.Serialization;
|
|||||||
namespace Notify.Services;
|
namespace Notify.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 每会话状态文件的读写,位于 %TEMP%\claude-notify-{session_id}.json
|
/// 每会话状态文件的读写,位于 %TEMP%\notify-{session_id}.json
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class StateStore
|
public static class StateStore
|
||||||
{
|
{
|
||||||
private static string FilePath(string sessionId) =>
|
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)
|
public static void Save(string sessionId, StateData data)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
SizeToContent="Height"
|
SizeToContent="Height"
|
||||||
CanResize="False"
|
CanResize="False"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
Icon="/Assets/claude.ico"
|
Icon="/Assets/codex.ico"
|
||||||
Title="弹窗设置">
|
Title="弹窗设置">
|
||||||
|
|
||||||
<StackPanel Margin="20" Spacing="16">
|
<StackPanel Margin="20" Spacing="16">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
x:Name="IconImage"
|
x:Name="IconImage"
|
||||||
Width="44" Height="44"
|
Width="44" Height="44"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Source="/Assets/claude.ico" />
|
IsVisible="False" />
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" Margin="12,0,8,0" VerticalAlignment="Center" Spacing="2">
|
<StackPanel Grid.Column="1" Margin="12,0,8,0" VerticalAlignment="Center" Spacing="2">
|
||||||
<TextBlock Text="{Binding Title}"
|
<TextBlock Text="{Binding Title}"
|
||||||
|
|||||||
@@ -57,13 +57,14 @@ public partial class ToastWindow : Window
|
|||||||
|
|
||||||
Root.BorderBrush = new SolidColorBrush(vm.InputMode ? BorderInput : BorderNormal);
|
Root.BorderBrush = new SolidColorBrush(vm.InputMode ? BorderInput : BorderNormal);
|
||||||
|
|
||||||
// 调用方 App 图标,取不到则保留默认 Claude 图标
|
// 调用方 App 图标;取不到时不显示图标。
|
||||||
if (!string.IsNullOrEmpty(iconPath))
|
if (!string.IsNullOrEmpty(iconPath))
|
||||||
{
|
{
|
||||||
_appIcon = Notify.Interop.AppIcon.Extract(iconPath);
|
_appIcon = Notify.Interop.AppIcon.Extract(iconPath);
|
||||||
if (_appIcon is not null)
|
if (_appIcon is not null)
|
||||||
{
|
{
|
||||||
IconImage.Source = _appIcon;
|
IconImage.Source = _appIcon;
|
||||||
|
IconImage.IsVisible = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 标签页
|
- 点击 toast 跳回发起请求的窗口,并能切回原 Windows Terminal 标签页
|
||||||
- 自动识别并显示调用方 App 图标(VSCode / Cursor / JetBrains / 终端…)
|
- 自动识别并显示调用方 App 图标(VSCode / Cursor / JetBrains / 终端…)
|
||||||
- 输入类通知常驻、完成类自动消失(正盯着目标窗口时停留更短)
|
- 输入类通知常驻、完成类自动消失(正盯着目标窗口时停留更短)
|
||||||
- 非阻塞投递,钩子毫秒级返回,不拖慢 Claude Code
|
- 非阻塞投递,钩子写入通知后立即返回
|
||||||
- NativeAOT 单文件、无运行时依赖
|
- NativeAOT 单文件、无运行时依赖
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
CC[Claude Code] -->|UserPromptSubmit| SAVE["notify save"]
|
CC[Hook client] -->|UserPromptSubmit| SAVE["notify save"]
|
||||||
CC -->|Stop| NOTIFY["notify notify"]
|
CC -->|Stop| NOTIFY["notify notify"]
|
||||||
CC -->|Notification / PreToolUse| INPUT["notify input"]
|
CC -->|PermissionRequest| INPUT["notify input"]
|
||||||
CC -->|SessionEnd| CLEAN["notify cleanup"]
|
CC -->|SessionEnd| CLEAN["notify cleanup"]
|
||||||
|
|
||||||
SAVE --> ST[(状态文件)]
|
SAVE --> ST[(状态文件)]
|
||||||
@@ -52,7 +52,7 @@ flowchart LR
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
autonumber
|
autonumber
|
||||||
actor User as 用户
|
actor User as 用户
|
||||||
participant CC as Claude Code
|
participant CC as Hook client
|
||||||
participant Cli as notify (CLI)
|
participant Cli as notify (CLI)
|
||||||
participant State as 状态文件
|
participant State as 状态文件
|
||||||
participant Spool as spool 队列
|
participant Spool as spool 队列
|
||||||
@@ -65,7 +65,7 @@ sequenceDiagram
|
|||||||
Cli->>State: 写入状态
|
Cli->>State: 写入状态
|
||||||
Cli-->>CC: 立即退出
|
Cli-->>CC: 立即退出
|
||||||
|
|
||||||
Note over CC: Claude 处理中…
|
Note over CC: 任务处理中
|
||||||
|
|
||||||
alt 任务完成
|
alt 任务完成
|
||||||
CC->>Cli: notify notify
|
CC->>Cli: notify notify
|
||||||
@@ -137,12 +137,15 @@ sequenceDiagram
|
|||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
### Codex 示例
|
||||||
claude plugin marketplace add https://git.pchuan.top/cc-tools/notify
|
|
||||||
claude plugin install claude-code-notify@claude-code-notify
|
先将本仓库放在固定位置,再把以下配置写入用户级 `~/.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)。
|
- 从源码构建见 [docs/build-and-install.md](docs/build-and-install.md)。
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| **CLI 子命令** | `notify save\|notify\|input\|cleanup` | 否(纯互操作 / 落盘) | 即起即退(~100ms) |
|
| **CLI 子命令** | `notify save\|notify\|input\|cleanup` | 否(纯互操作 / 落盘) | 即起即退(~100ms) |
|
||||||
| **Host** | `notify` 或 `notify host` | 是 | 常驻(单例,无主窗口) |
|
| **Host** | `notify` 或 `notify host` | 是 | 常驻(单例,无主窗口) |
|
||||||
|
|
||||||
这样设计的原因:hook 在你每次发消息时都会被拉起,必须**极快返回、绝不阻塞 Claude Code**;而真正画 UI 的 Avalonia 较重,放在一个**只初始化一次**的常驻进程里。
|
hook 需要快速返回。CLI 只写入状态和通知队列,Avalonia UI 由常驻进程加载一次。
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
@@ -25,15 +25,15 @@ flowchart TD
|
|||||||
RUN --> APP[App:托盘 + SpoolWatcher + ToastManager]
|
RUN --> APP[App:托盘 + SpoolWatcher + ToastManager]
|
||||||
```
|
```
|
||||||
|
|
||||||
- **单例**:`RunHost` 用命名互斥量 `ClaudeCodeNotifyHost` 保证只有一个 Host;第二个实例直接退出。
|
- **单例**:`RunHost` 用命名互斥量 `NotifyHost` 保证只有一个 Host;第二个实例直接退出。
|
||||||
- **保活**:`ShutdownMode.OnExplicitShutdown`,没有主窗口也不退,只有托盘"退出"才结束。
|
- **保活**:`ShutdownMode.OnExplicitShutdown`,没有主窗口也不退,只有托盘"退出"才结束。
|
||||||
|
|
||||||
## 两条数据通道
|
## 两条数据通道
|
||||||
|
|
||||||
1. **状态文件**(每会话)`%TEMP%\claude-notify-{session_id}.json`
|
1. **状态文件**(每会话)`%TEMP%\notify-{session_id}.json`
|
||||||
- `save` 写入:前台窗口句柄、prompt、WT 标签 RuntimeId、调用方 exe 路径。
|
- `save` 写入:前台窗口句柄、prompt、WT 标签 RuntimeId、调用方 exe 路径。
|
||||||
- `notify`/`input` 读取,拼成通知;`cleanup` 删除。
|
- `notify`/`input` 读取,拼成通知;`cleanup` 删除。
|
||||||
2. **spool 队列** `%TEMP%\claude-notify-spool\*.json`
|
2. **spool 队列** `%TEMP%\notify-spool\*.json`
|
||||||
- `notify`/`input` 把一条 `NotifyMessage` 原子落盘,Host 用 `FileSystemWatcher` 消费。
|
- `notify`/`input` 把一条 `NotifyMessage` 原子落盘,Host 用 `FileSystemWatcher` 消费。
|
||||||
- 取代命名管道,**让 CLI 写完即走、不等 Host**(见 README 的「非阻塞投递」时序)。
|
- 取代命名管道,**让 CLI 写完即走、不等 Host**(见 README 的「非阻塞投递」时序)。
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
# 构建与安装
|
# 构建与安装
|
||||||
|
|
||||||
## 安装(用户)
|
## Codex 接入示例
|
||||||
|
|
||||||
```bash
|
在用户级 `~/.codex/config.toml` 中加入:
|
||||||
claude plugin marketplace add https://git.pchuan.top/cc-tools/notify
|
|
||||||
claude plugin install claude-code-notify@claude-code-notify
|
```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 锁,并发触发不会重复下载。
|
引导脚本(`scripts/notify.cmd`、`scripts/notify.sh`)顶部的 `DOWNLOAD_URL` 决定从哪拉取 exe;下载用临时文件 + 原子改名 + mkdir 锁,并发触发不会重复下载。
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ NativeAOT 静态链接 Skia / HarfBuzz / ANGLE,产出**单个无依赖 exe**
|
|||||||
```bash
|
```bash
|
||||||
# 从 "Developer Command Prompt for VS" 运行,或用脚本(自动用 vswhere 配 vcvars)
|
# 从 "Developer Command Prompt for VS" 运行,或用脚本(自动用 vswhere 配 vcvars)
|
||||||
scripts\build.bat
|
scripts\build.bat
|
||||||
# 产物:bin\notify.exe(单文件,~40MB)
|
# 产物:bin\notify.exe(未压缩单文件,约 40 MB)
|
||||||
```
|
```
|
||||||
|
|
||||||
然后把它作为 Release 资产发布,并确保引导脚本的 `DOWNLOAD_URL` 指向它:
|
然后把它作为 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)。
|
> AOT 配置在 `Notify/Notify.csproj`(`PublishAot` 条件块 + `CoreUtils.*.Static` 静态库包 + 发布后清理)。源生成 COM / UIAutomation 与静态渲染需真机运行验证,详见 [interop.md](interop.md)。
|
||||||
|
|
||||||
## 不接 Claude 的手动烟雾测试
|
## 手动烟雾测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
notify host & # 起 Host(托盘出现)
|
notify host & # 起 Host(托盘出现)
|
||||||
|
|||||||
+18
-8
@@ -1,20 +1,30 @@
|
|||||||
# Hook 与 CLI
|
# 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 标签、调用方图标路径 |
|
| `UserPromptSubmit` | `notify save` | 记录前台窗口、prompt、WT 标签、调用方图标路径 |
|
||||||
| `Stop` | `notify notify` | 弹"任务完成"通知(自动消失,聚焦时更短) |
|
| `Stop` | `notify notify` | 弹"任务完成"通知(自动消失,聚焦时更短) |
|
||||||
| `Notification` | `notify input` | 弹"需要输入"通知(常驻),按类型分标题 |
|
| `Notification` | `notify input` | 弹"需要输入"通知(常驻),按类型分标题 |
|
||||||
| `PreToolUse`(`AskUserQuestion`/`ExitPlanMode`) | `notify input` | 提问 / 出 Plan 时弹常驻通知 |
|
| `PreToolUse`(`AskUserQuestion`/`ExitPlanMode`) | `notify input` | 提问 / 出 Plan 时弹常驻通知 |
|
||||||
| `SessionEnd` | `notify cleanup` | 删除该会话状态文件 |
|
| `SessionEnd` | `notify cleanup` | 删除该会话状态文件 |
|
||||||
|
|
||||||
`hooks/hooks.json`(插件形式)里命令为 `${CLAUDE_PLUGIN_ROOT}/bin/notify.exe <子命令>`;直连 `settings.json` 时可写 `notify <子命令>` 或绝对路径。
|
客户端可调用 `notify.exe <子命令> [来源]`。来源为 `codex` 时固定显示 Codex 图标。
|
||||||
|
|
||||||
## stdin JSON
|
## 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 |
|
| `tool_name == ExitPlanMode` | Plan Ready for Approval |
|
||||||
| `notification_type == permission_prompt` | Permission Required |
|
| `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 |
|
| `notification_type == elicitation_dialog` | MCP Asks |
|
||||||
| 其它 | Input Required |
|
| 其它 | 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
|
```jsonc
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ Windows 限制后台进程抢焦点。组合技:还原最小化 → 模拟一
|
|||||||
|
|
||||||
## 取图标(AppIcon)
|
## 取图标(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
|
## AOT
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
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" (
|
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
|
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
|
dotnet publish Notify -c Release -r win-x64 -p:PublishAot=true -o bin
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo.
|
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
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
rem 清理发布目录残留:只留 notify.exe(AOT 下 -o 拷贝时序使 csproj 内清理不可靠,这里统一处理)
|
||||||
|
del /q bin\*.pdb bin\*.dll bin\*.lib >nul 2>&1
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo === 完成: %CD%\bin\notify.exe ===
|
echo === Done: %CD%\bin\notify.exe ===
|
||||||
endlocal
|
endlocal
|
||||||
|
|||||||
+53
-33
@@ -6,40 +6,60 @@ rem ============================================================
|
|||||||
setlocal
|
setlocal
|
||||||
set "BIN=%~dp0..\bin"
|
set "BIN=%~dp0..\bin"
|
||||||
set "EXE=%BIN%\notify.exe"
|
set "EXE=%BIN%\notify.exe"
|
||||||
|
set "PART=%BIN%\notify.exe.partial"
|
||||||
set "LOCK=%BIN%\notify.download.lock"
|
set "LOCK=%BIN%\notify.download.lock"
|
||||||
|
|
||||||
rem only bootstrap on first run; the common path runs the exe directly (keeps piped stdin intact)
|
rem hidden self-reinvocation: detached background downloader (see :downloader)
|
||||||
if not exist "%EXE%" call :bootstrap
|
if "%~1"=="__download" goto downloader
|
||||||
|
|
||||||
if exist "%EXE%" "%EXE%" %*
|
rem common path: exe present -> run directly (keeps piped stdin intact)
|
||||||
endlocal
|
if exist "%EXE%" (
|
||||||
exit /b
|
"%EXE%" %*
|
||||||
|
endlocal & exit /b
|
||||||
:bootstrap
|
|
||||||
if not exist "%BIN%" mkdir "%BIN%" 2>nul
|
|
||||||
set "TMP=%BIN%\notify.exe.%RANDOM%.tmp"
|
|
||||||
rem mkdir is atomic; success = this process downloads, failure = someone else is downloading
|
|
||||||
mkdir "%LOCK%" 2>nul
|
|
||||||
if errorlevel 1 goto :waitdl
|
|
||||||
rem double-check in case it just finished
|
|
||||||
if exist "%EXE%" ( rmdir "%LOCK%" 2>nul & exit /b )
|
|
||||||
rem download to temp then atomic rename, so no half-written exe is ever seen
|
|
||||||
curl -fsSL "%DOWNLOAD_URL%" -o "%TMP%"
|
|
||||||
if errorlevel 1 ( del "%TMP%" 2>nul & rmdir "%LOCK%" 2>nul & exit /b )
|
|
||||||
move /y "%TMP%" "%EXE%" >nul
|
|
||||||
rmdir "%LOCK%" 2>nul
|
|
||||||
exit /b
|
|
||||||
|
|
||||||
:waitdl
|
|
||||||
rem did not get the lock; wait for the exe to appear (up to ~60s)
|
|
||||||
set /a _w=0
|
|
||||||
:waitloop
|
|
||||||
if exist "%EXE%" exit /b
|
|
||||||
if %_w% geq 120 (
|
|
||||||
rem timed out; a killed download may have left a stale lock, clear it for next time
|
|
||||||
rmdir "%LOCK%" 2>nul
|
|
||||||
exit /b
|
|
||||||
)
|
)
|
||||||
ping -n 2 127.0.0.1 >nul
|
|
||||||
set /a _w+=1
|
rem ---- exe missing: never block the hook ----
|
||||||
goto :waitloop
|
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 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
|
||||||
|
|||||||
+30
-25
@@ -7,34 +7,39 @@ DOWNLOAD_URL="https://git.pchuan.top/cc-tools/notify/releases/download/v1.0.0/no
|
|||||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
BIN="$DIR/../bin"
|
BIN="$DIR/../bin"
|
||||||
EXE="$BIN/notify.exe"
|
EXE="$BIN/notify.exe"
|
||||||
|
PART="$BIN/notify.exe.partial"
|
||||||
LOCK="$BIN/notify.download.lock"
|
LOCK="$BIN/notify.download.lock"
|
||||||
|
|
||||||
if [ ! -f "$EXE" ]; then
|
# 常规路径:exe 已就绪 -> 直接转发参数与 stdin(保持管道完整)
|
||||||
mkdir -p "$BIN" 2>/dev/null
|
if [ -f "$EXE" ]; then
|
||||||
# mkdir 是原子操作,用作锁:成功=本进程负责下载,失败=已有进程在下
|
exec "$EXE" "$@"
|
||||||
if mkdir "$LOCK" 2>/dev/null; then
|
fi
|
||||||
# 双重检查,避免刚好别人下完
|
|
||||||
if [ ! -f "$EXE" ]; then
|
# ---- exe 缺失:绝不阻塞 hook ----
|
||||||
TMP="$BIN/notify.exe.$$.tmp"
|
mkdir -p "$BIN" 2>/dev/null
|
||||||
if curl -fsSL "$DOWNLOAD_URL" -o "$TMP"; then
|
|
||||||
mv -f "$TMP" "$EXE" # 原子改名,避免半截 exe
|
# 自愈:上次下载进程被硬杀(关机/崩溃)可能留下陈旧锁,挡住所有重试。
|
||||||
chmod +x "$EXE" 2>/dev/null
|
# .partial 不存在(没真正开始)或 >2 分钟没增长(进程已死)则回收锁,让 -C - 续传。
|
||||||
else
|
if [ -d "$LOCK" ]; then
|
||||||
rm -f "$TMP"
|
if [ ! -f "$PART" ]; then
|
||||||
fi
|
rmdir "$LOCK" 2>/dev/null
|
||||||
fi
|
elif [ -n "$(find "$PART" -mmin +2 2>/dev/null)" ]; then
|
||||||
rmdir "$LOCK" 2>/dev/null
|
rmdir "$LOCK" 2>/dev/null
|
||||||
else
|
|
||||||
# 没抢到锁:等 exe 出现(最多约 60 秒)
|
|
||||||
i=0
|
|
||||||
while [ ! -f "$EXE" ] && [ "$i" -lt 120 ]; do
|
|
||||||
sleep 0.5
|
|
||||||
i=$((i + 1))
|
|
||||||
done
|
|
||||||
# 超时仍没下好:可能上次下载被杀留下陈旧锁,清掉让下次重下
|
|
||||||
[ ! -f "$EXE" ] && rmdir "$LOCK" 2>/dev/null
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 转发全部参数与 stdin 给真正的 exe
|
# 原子锁:只有第一个 hook 派生唯一的后台下载进程;并发/后续 hook 抢锁失败
|
||||||
[ -f "$EXE" ] && exec "$EXE" "$@"
|
# -> 不重复下载,只汇报进度。下载脱离 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
|
||||||
|
|||||||
Reference in New Issue
Block a user