Files

281 lines
8.1 KiB
C#
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 notifyJSON 作为命令行最后一个参数传入。
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.
}
}
}