using System; using System.Diagnostics; using System.IO; using System.Text.Json; using System.Threading; using Notify.Serialization; namespace Notify.Ipc; /// /// 基于落盘队列的非阻塞投递:CLI 写文件后立即返回,Host 监视目录消费 /// /// 取代命名管道,避免 CLI 在 Host 冷启动时被阻塞而拖住 Claude Code /// public static class NotificationSpool { public static readonly string Dir = Path.Combine(Path.GetTempPath(), "claude-notify-spool"); // CLI 侧:写入一条请求,必要时拉起 Host,全程不阻塞 public static void Deliver(NotifyMessage message) { try { Directory.CreateDirectory(Dir); // 先写 .tmp 再原子改名为 .json,避免 Host 读到半截文件 var id = Guid.NewGuid().ToString("N"); var tmp = Path.Combine(Dir, id + ".tmp"); var final = Path.Combine(Dir, id + ".json"); File.WriteAllText(tmp, JsonSerializer.Serialize(message, AppJsonContext.Default.NotifyMessage)); File.Move(tmp, final); } catch { // 写入失败则放弃这条通知,绝不影响调用方 } EnsureHostRunning(); } // Host 未运行则拉起(不等待);运行中则什么都不做 private static void EnsureHostRunning() { try { if (Mutex.TryOpenExisting(IpcConstants.HostMutexName, out var existing)) { existing.Dispose(); return; } } catch { // 打不开就当作未运行,继续尝试拉起 } try { var exe = Environment.ProcessPath; if (exe is null) { return; } // 必须用 UseShellExecute=true 让 host 彻底脱离本进程的标准句柄 // 否则常驻 host 会继承并攥住钩子的 stdout 管道,导致 Claude Code // 等不到管道 EOF 而卡在 "running stop hook" Process.Start(new ProcessStartInfo { FileName = exe, Arguments = "host", UseShellExecute = true, WindowStyle = ProcessWindowStyle.Hidden, }); } catch { // 拉起失败则该通知会在下次 Host 启动时由 DrainExisting 补弹 } } // Host 侧:消费单个 spool 文件并删除 public static NotifyMessage? ReadAndRemove(string path) { try { var json = File.ReadAllText(path); File.Delete(path); return JsonSerializer.Deserialize(json, AppJsonContext.Default.NotifyMessage); } catch { return null; } } // Host 启动时把已有的 spool 文件补弹一遍 public static void DrainExisting(Action handler) { try { if (!Directory.Exists(Dir)) { return; } foreach (var path in Directory.GetFiles(Dir, "*.json")) { if (ReadAndRemove(path) is { } msg) { handler(msg); } } } catch { // 忽略 } } }