281 lines
8.1 KiB
C#
281 lines
8.1 KiB
C#
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
|
||
{
|
||
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
|
||
public static int Save(string? source = null)
|
||
{
|
||
var input = ReadStdin();
|
||
if (string.IsNullOrEmpty(input?.SessionId))
|
||
{
|
||
return 0;
|
||
}
|
||
|
||
var hwnd = Win32.GetForegroundWindow();
|
||
|
||
// 前台是 Windows Terminal 时,记录当前标签的 RuntimeId
|
||
var wtRuntimeId = WinTerminalTabs.IsWindowsTerminal(hwnd)
|
||
? WinTerminalTabs.GetSelectedTabRuntimeId(hwnd)
|
||
: "";
|
||
|
||
var callerExePath = ResolveIconPath(source, hwnd);
|
||
|
||
StateStore.Save(input.SessionId, new StateData
|
||
{
|
||
Hwnd = hwnd.ToInt64(),
|
||
Prompt = input.Prompt ?? "",
|
||
WtRuntimeId = wtRuntimeId,
|
||
CallerExePath = callerExePath,
|
||
});
|
||
return 0;
|
||
}
|
||
|
||
// Stop:任务完成通知,正文取本次 prompt
|
||
public static int Notify(string? source = null)
|
||
{
|
||
var input = ReadStdin();
|
||
if (string.IsNullOrEmpty(input?.SessionId))
|
||
{
|
||
return 0;
|
||
}
|
||
|
||
var state = StateStore.Load(input.SessionId);
|
||
var isCodex = IsCodex(source) || input.HookEventName == "Stop";
|
||
var message = !string.IsNullOrWhiteSpace(state?.Prompt)
|
||
? state!.Prompt
|
||
: input.HookLastAssistantMessage ?? "Task completed";
|
||
|
||
NotificationSpool.Deliver(new NotifyMessage
|
||
{
|
||
SessionId = input.SessionId,
|
||
Title = isCodex ? "Codex" : "Notify",
|
||
Message = Sanitize(message),
|
||
InputMode = false,
|
||
Sticky = false,
|
||
TargetHwnd = state?.Hwnd ?? 0,
|
||
WtRuntimeId = state?.WtRuntimeId,
|
||
IconPath = IsCodex(source) ? GetCodexIconPath() : state?.CallerExePath,
|
||
});
|
||
if (isCodex)
|
||
{
|
||
WriteHookSuccess();
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
// Notification / PreToolUse:需要输入,常驻显示
|
||
public static int Input(string? source = null)
|
||
{
|
||
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);
|
||
var state = StateStore.Load(input.SessionId);
|
||
|
||
NotificationSpool.Deliver(new NotifyMessage
|
||
{
|
||
SessionId = input.SessionId,
|
||
Title = title,
|
||
Message = Sanitize(message),
|
||
InputMode = true,
|
||
Sticky = true,
|
||
TargetHwnd = state?.Hwnd ?? 0,
|
||
WtRuntimeId = state?.WtRuntimeId,
|
||
IconPath = IsCodex(source) ? GetCodexIconPath() : state?.CallerExePath,
|
||
});
|
||
return 0;
|
||
}
|
||
|
||
// 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.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")
|
||
{
|
||
var msg = string.IsNullOrEmpty(input.Message) ? "Input is required" : input.Message!;
|
||
return ("Input Required", msg);
|
||
}
|
||
|
||
if (input.ToolName == "ExitPlanMode")
|
||
{
|
||
return ("Plan Ready for Approval", "A plan is ready for approval");
|
||
}
|
||
|
||
var title = input.NotificationType switch
|
||
{
|
||
"permission_prompt" => "Permission Required",
|
||
"idle_prompt" => "Application is Waiting",
|
||
"elicitation_dialog" => "MCP Asks",
|
||
_ => "Input Required",
|
||
};
|
||
var message = string.IsNullOrEmpty(input.Message) ? "Input is required" : input.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 的省略号)
|
||
private static string Sanitize(string s)
|
||
{
|
||
if (string.IsNullOrEmpty(s))
|
||
{
|
||
return s;
|
||
}
|
||
|
||
s = s.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' ');
|
||
while (s.Contains(" "))
|
||
{
|
||
s = s.Replace(" ", " ");
|
||
}
|
||
|
||
return s.Trim();
|
||
}
|
||
|
||
// 直接读原始字节并按 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 Deserialize(text);
|
||
}
|
||
catch
|
||
{
|
||
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.
|
||
}
|
||
}
|
||
}
|