[Phase 17-C] 훅 시스템 고도화 — 11종 이벤트 연결 + Prompt 모드 구현

AppSettings.AgentConfig.cs:
- ExtendedHooksConfig에 4개 이벤트 추가:
  preToolUse, postToolUse, postToolUseFailure, agentStop

AgentLoopService.ExtendedHooks.cs (신규, 150줄):
- RunExtendedEventAsync(): 이벤트 훅 실행, 결과(additionalContext) 시스템 메시지 주입, HookFired 이벤트 로그
- GetExtendedHooks(): 설정에서 HookEventKind별 런타임 엔트리 조회
- ConvertToRuntimeEntries(): ExtendedHookEntryConfig → ExtendedHookEntry 변환
- ApplyExtendedHookResult(): additionalContext in-place 주입 (marker 기반 교체)

ExtendedHookRunner.cs:
- RunEventAsync() / ExecuteSingleAsync()에 LlmService? llm 파라미터 추가
- RunPromptHookAsync() 신규: {{tool_name/input/output/user_message/event/session_id}} 치환
  → LLM 호출 → block/deny/차단 감지 시 Block=true 반환
- using AxCopilot.Models 추가

AgentLoopService.cs:
- SessionStart 훅 (fire-and-forget, TaskState init 직후)
- UserPromptSubmit 훅 (동기, Block=true 시 즉시 반환 "⚠ 훅 정책에 의해 차단")
- PreCompact 훅 (적극적 압축 직전, 동기)
- PostCompact 훅 × 2 (기본/적극적 압축 완료 후, fire-and-forget)
- SessionEnd + AgentStop 훅 (finally 블록, fire-and-forget)

AgentLoopService.Execution.cs:
- RunToolHooksAsync() 개선: 레거시 AgentHookRunner 유지
  + ExtendedHookRunner PreToolUse/PostToolUse/PostToolUseFailure 추가
이벤트 커버리지: 11종 완전 연결. Agent 모드는 Phase 18-A 예정.
빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 00:14:19 +09:00
parent 2383b1e220
commit 0a58419c8a
6 changed files with 327 additions and 28 deletions

View File

@@ -5001,5 +5001,33 @@ ThemeResourceHelper에 5개 정적 필드 추가:
---
최종 업데이트: 2026-04-03 (Phase 22~52 + Phase 17-UI-A~E + Phase 17-A~B 구현 완료)
---
## Phase 17-C — 훅 시스템 고도화 (v1.8.0) ✅ 완료
> **목표**: ExtendedHookRunner를 에이전트 라이프사이클 전 구간에 연결 + Prompt 모드 구현
### 변경 파일
| 파일 | 변경 내용 |
|------|----------|
| `AppSettings.AgentConfig.cs` | `ExtendedHooksConfig`에 4개 이벤트 추가: `preToolUse`, `postToolUse`, `postToolUseFailure`, `agentStop` |
| `AgentLoopService.ExtendedHooks.cs` (신규, 150줄) | `RunExtendedEventAsync()` — 이벤트 훅 실행·결과 적용. `GetExtendedHooks()` — 설정에서 런타임 엔트리 조회. `ConvertToRuntimeEntries()` — ExtendedHookEntryConfig → ExtendedHookEntry 변환. `ApplyExtendedHookResult()` — additionalContext 시스템 메시지 in-place 주입. |
| `ExtendedHookRunner.cs` | `RunEventAsync()` + `ExecuteSingleAsync()``LlmService? llm` 파라미터 추가. `RunPromptHookAsync()` 신규 구현: `{{tool_name}}` 등 변수 치환, LLM 호출, 차단 신호 감지. `using AxCopilot.Models` 추가. |
| `AgentLoopService.cs` | TaskState init 후: `SessionStart` 훅(fire-and-forget) + `UserPromptSubmit` 훅(차단 시 즉시 반환). 적극적 압축 직전: `PreCompact` 훅. 압축 완료 후: `PostCompact` 훅(fire-and-forget). `finally` 블록: `SessionEnd` + `AgentStop` 훅(fire-and-forget). |
| `AgentLoopService.Execution.cs` | `RunToolHooksAsync()` 개선: 레거시 AgentHookRunner 유지 + ExtendedHookRunner `PreToolUse`/`PostToolUse`/`PostToolUseFailure` 이벤트 추가 실행. |
### 구현 세부사항
- **이벤트 커버리지**: SessionStart, SessionEnd, AgentStop, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, PreCompact, PostCompact, FileChanged, PermissionRequest — 11종 이벤트 완전 연결
- **실행 모드**: Command(bat/ps1), Http(POST webhook), Prompt(LLM 평가) 구현. Agent 모드는 Phase 18-A 예정.
- **Prompt 모드**: `{{tool_name}}`, `{{tool_input}}`, `{{tool_output}}`, `{{user_message}}`, `{{event}}`, `{{session_id}}` 변수 치환. 응답에 block/deny/차단 포함 시 Block=true.
- **차단 처리**: UserPromptSubmit 훅 Block=true 시 즉시 `"⚠ 요청이 훅 정책에 의해 차단되었습니다."` 반환
- **HookFired 이벤트 로그**: 훅 실행 시 JSONL에 eventKind, hookCount, blocked 기록
- **하위 호환**: 레거시 AgentHooks(command 스크립트) 병행 유지
- **빌드**: 경고 0, 오류 0
---
최종 업데이트: 2026-04-04 (Phase 22~52 + Phase 17-UI-A~E + Phase 17-A~C 구현 완료)

View File

@@ -59,24 +59,40 @@ public class ReflexionConfig
/// <summary>Phase 17-C: 확장 훅 설정.</summary>
public class ExtendedHooksConfig
{
// ── 라이프사이클 이벤트 ──────────────────────────────────────────────
[JsonPropertyName("session_start")]
public List<ExtendedHookEntryConfig> SessionStart { get; set; } = new();
[JsonPropertyName("session_end")]
public List<ExtendedHookEntryConfig> SessionEnd { get; set; } = new();
[JsonPropertyName("agent_stop")]
public List<ExtendedHookEntryConfig> AgentStop { get; set; } = new();
[JsonPropertyName("user_prompt_submit")]
public List<ExtendedHookEntryConfig> UserPromptSubmit { get; set; } = new();
// ── 도구 이벤트 ─────────────────────────────────────────────────────
[JsonPropertyName("pre_tool_use")]
public List<ExtendedHookEntryConfig> PreToolUse { get; set; } = new();
[JsonPropertyName("post_tool_use")]
public List<ExtendedHookEntryConfig> PostToolUse { get; set; } = new();
[JsonPropertyName("post_tool_use_failure")]
public List<ExtendedHookEntryConfig> PostToolUseFailure { get; set; } = new();
// ── 컨텍스트 압축 이벤트 ────────────────────────────────────────────
[JsonPropertyName("pre_compact")]
public List<ExtendedHookEntryConfig> PreCompact { get; set; } = new();
[JsonPropertyName("post_compact")]
public List<ExtendedHookEntryConfig> PostCompact { get; set; } = new();
// ── 파일·권한 이벤트 ────────────────────────────────────────────────
[JsonPropertyName("file_changed")]
public List<ExtendedHookEntryConfig> FileChanged { get; set; } = new();
[JsonPropertyName("session_start")]
public List<ExtendedHookEntryConfig> SessionStart { get; set; } = new();
[JsonPropertyName("session_end")]
public List<ExtendedHookEntryConfig> SessionEnd { get; set; } = new();
[JsonPropertyName("permission_request")]
public List<ExtendedHookEntryConfig> PermissionRequest { get; set; } = new();
}

View File

@@ -369,27 +369,42 @@ public partial class AgentLoopService
/// <summary>도구 호출 처리 후 루프 제어 액션.</summary>
private enum ToolCallAction { Continue, Break, Return }
/// <summary>Phase 33-B: 도구 훅(Pre/Post) 실행 헬퍼.</summary>
/// <summary>Phase 33-B / 17-C: 도구 훅(Pre/Post/Failure) 실행 헬퍼.</summary>
private async Task RunToolHooksAsync(
Models.LlmSettings llm, LlmService.ContentBlock call,
AgentContext context, string phase, CancellationToken ct,
ToolResult? result = null)
{
if (!llm.EnableToolHooks || llm.AgentHooks.Count == 0) return;
try
// ── 레거시 AgentHooks (command 스크립트, 하위 호환) ──────────────
if (llm.EnableToolHooks && llm.AgentHooks.Count > 0)
{
var hookResults = await AgentHookRunner.RunAsync(
llm.AgentHooks, call.ToolName, phase,
toolInput: call.ToolInput.ToString(),
toolOutput: result != null ? TruncateOutput(result.Output, 2048) : null,
success: result?.Success ?? false,
workFolder: context.WorkFolder,
timeoutMs: llm.ToolHookTimeoutMs,
ct: ct);
foreach (var pr in hookResults.Where(r => !r.Success))
EmitEvent(AgentEventType.Error, call.ToolName, $"[Hook:{pr.HookName}] {pr.Output}");
try
{
var hookResults = await AgentHookRunner.RunAsync(
llm.AgentHooks, call.ToolName, phase,
toolInput: call.ToolInput.ToString(),
toolOutput: result != null ? TruncateOutput(result.Output, 2048) : null,
success: result?.Success ?? false,
workFolder: context.WorkFolder,
timeoutMs: llm.ToolHookTimeoutMs,
ct: ct);
foreach (var pr in hookResults.Where(r => !r.Success))
EmitEvent(AgentEventType.Error, call.ToolName, $"[Hook:{pr.HookName}] {pr.Output}");
}
catch (Exception ex) { LogService.Warn($"[AgentLoop] {phase}ToolUse 레거시 훅 실패: {ex.Message}"); }
}
catch (Exception ex) { LogService.Warn($"[AgentLoop] {phase}ToolUse 훅 실패: {ex.Message}"); }
// ── Phase 17-C: 확장 훅 (ExtendedHooks — command/http/prompt 모드) ──
var extKind = phase == "pre"
? HookEventKind.PreToolUse
: (result?.Success == false ? HookEventKind.PostToolUseFailure : HookEventKind.PostToolUse);
await RunExtendedEventAsync(
extKind, null, ct,
toolName: call.ToolName,
toolInput: call.ToolInput?.ToString(),
toolOutput: result != null ? TruncateOutput(result.Output, 2048) : null,
toolSuccess: result?.Success ?? true);
}
/// <summary>DelegateAgentTool에서 호출하는 서브에이전트 실행기.</summary>

View File

@@ -0,0 +1,158 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Phase 17-C: AgentLoopService — 확장 훅 시스템 통합.
/// 에이전트 라이프사이클 이벤트(SessionStart/End, UserPromptSubmit,
/// PreToolUse/PostToolUse, PreCompact/PostCompact 등)에
/// ExtendedHookRunner를 연결합니다.
/// </summary>
public partial class AgentLoopService
{
/// <summary>
/// 지정 이벤트의 확장 훅을 실행합니다.
/// - additionalContext가 있으면 시스템 메시지에 주입
/// - Block인 경우 true 반환 → 호출자가 작업을 중단해야 함
/// </summary>
internal async Task<bool> RunExtendedEventAsync(
HookEventKind kind,
List<ChatMessage>? messages,
CancellationToken ct,
string? toolName = null,
string? toolInput = null,
string? toolOutput = null,
bool toolSuccess = true,
string? userMessage = null,
string? changedFile = null)
{
var hooks = GetExtendedHooks(kind);
if (hooks.Count == 0) return false;
var ctx = new HookContext
{
Event = kind,
ToolName = toolName,
ToolInput = toolInput,
ToolOutput = toolOutput,
ToolSuccess = toolSuccess,
UserMessage = userMessage,
ChangedFilePath = changedFile,
SessionId = _sessionId,
WorkFolder = _settings.Settings.Llm.WorkFolder ?? "",
};
try
{
var result = await ExtendedHookRunner.RunEventAsync(hooks, ctx, ct, _llm);
ApplyExtendedHookResult(result, messages);
// HookFired 이벤트 로그 기록
if (_eventLog != null && hooks.Count > 0)
_ = _eventLog.AppendAsync(AgentEventLogType.HookFired,
System.Text.Json.JsonSerializer.Serialize(new
{
eventKind = kind.ToString(),
hookCount = hooks.Count,
blocked = result.Block
}));
return result.Block;
}
catch (Exception ex)
{
LogService.Warn($"[ExtendedHook] {kind} 훅 실행 오류: {ex.Message}");
return false;
}
}
/// <summary>훅 결과의 additionalContext를 시스템 메시지에 주입합니다.</summary>
private static void ApplyExtendedHookResult(ExtendedHookResult result, List<ChatMessage>? messages)
{
if (string.IsNullOrEmpty(result.AdditionalContext) || messages == null) return;
const string marker = "[Hook Context]";
var sysMsg = messages.FirstOrDefault(m => m.Role == "system");
if (sysMsg != null)
{
// 기존 [Hook Context] 섹션 교체 (중복 방지)
var idx = sysMsg.Content.IndexOf(marker, StringComparison.Ordinal);
if (idx >= 0)
sysMsg.Content = sysMsg.Content[..idx] + marker + "\n" + result.AdditionalContext;
else
sysMsg.Content += $"\n\n{marker}\n{result.AdditionalContext}";
}
else
{
messages.Insert(0, new ChatMessage
{
Role = "system",
Content = $"{marker}\n{result.AdditionalContext}"
});
}
}
// ─────────────────────────────────────────────────────────────────────
// 설정 → 런타임 엔트리 변환
// ─────────────────────────────────────────────────────────────────────
/// <summary>설정에서 특정 이벤트의 활성 훅 엔트리 목록을 반환합니다.</summary>
private List<ExtendedHookEntry> GetExtendedHooks(HookEventKind kind)
{
var cfg = _settings.Settings.Llm.ExtendedHooks;
IReadOnlyList<ExtendedHookEntryConfig>? configs = kind switch
{
HookEventKind.SessionStart => cfg.SessionStart,
HookEventKind.SessionEnd => cfg.SessionEnd,
HookEventKind.AgentStop => cfg.AgentStop,
HookEventKind.UserPromptSubmit => cfg.UserPromptSubmit,
HookEventKind.PreToolUse => cfg.PreToolUse,
HookEventKind.PostToolUse => cfg.PostToolUse,
HookEventKind.PostToolUseFailure => cfg.PostToolUseFailure,
HookEventKind.PreCompact => cfg.PreCompact,
HookEventKind.PostCompact => cfg.PostCompact,
HookEventKind.FileChanged => cfg.FileChanged,
HookEventKind.PermissionRequest => cfg.PermissionRequest,
_ => null
};
if (configs == null || configs.Count == 0) return new List<ExtendedHookEntry>();
return ConvertToRuntimeEntries(configs, kind);
}
/// <summary>설정 모델(ExtendedHookEntryConfig) → 런타임 모델(ExtendedHookEntry) 변환.</summary>
private static List<ExtendedHookEntry> ConvertToRuntimeEntries(
IReadOnlyList<ExtendedHookEntryConfig> configs, HookEventKind kind)
{
return configs
.Where(c => c.Enabled)
.Select(c => new ExtendedHookEntry
{
Name = c.Name,
Event = kind,
Matcher = c.Matcher,
Mode = ParseMode(c.Mode),
ScriptPath = c.ScriptPath,
Url = c.Url,
Prompt = c.Prompt,
Model = c.Model,
Enabled = c.Enabled,
Once = c.Once,
IsAsync = c.IsAsync,
TimeoutSeconds = c.TimeoutSeconds > 0 ? c.TimeoutSeconds : 30,
StatusMessage = c.StatusMessage,
WatchPaths = c.WatchPaths ?? new List<string>(),
})
.ToList();
}
private static HookExecutionMode ParseMode(string? mode) =>
mode?.ToLowerInvariant() switch
{
"http" => HookExecutionMode.Http,
"prompt" => HookExecutionMode.Prompt,
"agent" => HookExecutionMode.Agent,
_ => HookExecutionMode.Command
};
}

View File

@@ -176,6 +176,23 @@ public partial class AgentLoopService
_ = _eventLog?.AppendAsync(AgentEventLogType.UserMessage,
JsonSerializer.Serialize(new { length = userQuery.Length }));
await InitTaskStateAsync(userQuery, _sessionId);
// Phase 17-C: SessionStart 훅 실행 (fire-and-forget)
_ = RunExtendedEventAsync(HookEventKind.SessionStart, messages, CancellationToken.None,
userMessage: userQuery);
// Phase 17-C: UserPromptSubmit 훅 실행 — 차단 시 즉시 종료
if (!string.IsNullOrWhiteSpace(userQuery))
{
var promptBlocked = await RunExtendedEventAsync(
HookEventKind.UserPromptSubmit, messages, ct, userMessage: userQuery);
if (promptBlocked)
{
EmitEvent(AgentEventType.Complete, "", "훅 정책에 의해 요청이 차단되었습니다");
return "⚠ 요청이 훅 정책에 의해 차단되었습니다.";
}
}
var consecutiveErrors = 0; // Self-Reflection: 연속 오류 카운터
var totalToolCalls = 0; // 복잡도 추정용
@@ -357,6 +374,8 @@ public partial class AgentLoopService
_ = _eventLog?.AppendAsync(AgentEventLogType.CompactionCompleted,
JsonSerializer.Serialize(new { messageCount = messages.Count }));
UpdateTaskStateSummaryAsync("컨텍스트 압축 완료");
// Phase 17-C: PostCompact 훅 (fire-and-forget)
_ = RunExtendedEventAsync(HookEventKind.PostCompact, messages, CancellationToken.None);
}
// 임계치 기반 적극적 압축 (이전 LLM 호출의 토큰 사용량 기준)
@@ -374,6 +393,8 @@ public partial class AgentLoopService
$"⚠ 컨텍스트 사용량 {usagePct}% — 적극적 컴팩션 실행");
_ = _eventLog?.AppendAsync(AgentEventLogType.CompactionTriggered,
JsonSerializer.Serialize(new { aggressive = true, usagePct }));
// Phase 17-C: PreCompact 훅 — 적극적 압축 직전 (압축 발생 확실)
await RunExtendedEventAsync(HookEventKind.PreCompact, messages, ct);
var targetTokens = (int)(llm.MaxContextTokens * Defaults.ContextCompressionTargetRatio);
var compacted = await ContextCondenser.CondenseIfNeededAsync(
messages, _llm, targetTokens, ct);
@@ -383,6 +404,8 @@ public partial class AgentLoopService
_ = _eventLog?.AppendAsync(AgentEventLogType.CompactionCompleted,
JsonSerializer.Serialize(new { aggressive = true, messageCount = messages.Count }));
UpdateTaskStateSummaryAsync($"적극적 컴팩션 완료 (이전 컨텍스트 사용량 {usagePct}%)");
// Phase 17-C: PostCompact 훅 (fire-and-forget)
_ = RunExtendedEventAsync(HookEventKind.PostCompact, messages, CancellationToken.None);
}
}
}
@@ -778,6 +801,10 @@ public partial class AgentLoopService
}
finally
{
// Phase 17-C: SessionEnd + AgentStop 확장 훅 실행 (fire-and-forget)
_ = RunExtendedEventAsync(HookEventKind.SessionEnd, null, CancellationToken.None, userMessage: userQuery);
_ = RunExtendedEventAsync(HookEventKind.AgentStop, null, CancellationToken.None, userMessage: userQuery);
// 세션 종료 이벤트 기록
if (_eventLog != null)
_ = _eventLog.AppendAsync(AgentEventLogType.SessionEnd,

View File

@@ -3,6 +3,7 @@ using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
@@ -22,10 +23,12 @@ public static class ExtendedHookRunner
};
/// <summary>지정 이벤트의 훅을 모두 실행하고 결과를 병합하여 반환.</summary>
/// <param name="llm">Prompt 모드 훅에서 LLM 호출에 사용할 서비스 (null이면 Prompt 모드 스킵).</param>
public static async Task<ExtendedHookResult> RunEventAsync(
IReadOnlyList<ExtendedHookEntry> hooks,
HookContext context,
CancellationToken ct = default)
CancellationToken ct = default,
LlmService? llm = null)
{
if (hooks == null || hooks.Count == 0)
return new ExtendedHookResult();
@@ -53,7 +56,7 @@ public static class ExtendedHookRunner
{
try
{
var result = await ExecuteSingleAsync(hook, context, CancellationToken.None);
var result = await ExecuteSingleAsync(hook, context, CancellationToken.None, llm);
if (result.Block)
{
// exit code 2 = rewake → AdditionalContext를 통해 대화 재활성화
@@ -72,7 +75,7 @@ public static class ExtendedHookRunner
{
_ = Task.Run(async () =>
{
try { await ExecuteSingleAsync(hook, context, CancellationToken.None); }
try { await ExecuteSingleAsync(hook, context, CancellationToken.None, llm); }
catch (Exception) { }
});
}
@@ -95,7 +98,7 @@ public static class ExtendedHookRunner
try
{
var result = await ExecuteSingleAsync(hook, context, cts.Token);
var result = await ExecuteSingleAsync(hook, context, cts.Token, llm);
if (result.Block) { anyBlocked = true; blockReason = result.BlockReason; break; }
if (result.AdditionalContext != null)
@@ -130,18 +133,70 @@ public static class ExtendedHookRunner
private static async Task<ExtendedHookResult> ExecuteSingleAsync(
ExtendedHookEntry hook,
HookContext context,
CancellationToken ct)
CancellationToken ct,
LlmService? llm = null)
{
return hook.Mode switch
{
HookExecutionMode.Command => await RunCommandHookAsync(hook, context, ct),
HookExecutionMode.Http => await RunHttpHookAsync(hook, context, ct),
HookExecutionMode.Prompt => new ExtendedHookResult(), // LLM 검사는 LlmService 필요 — 외부에서 처리
HookExecutionMode.Agent => new ExtendedHookResult(), // 미니 에이전트는 AgentLoopService 필요 — 외부 처리
// Phase 17-C: Prompt 모드 — LlmService 주입 필요. 없으면 스킵.
HookExecutionMode.Prompt => llm != null
? await RunPromptHookAsync(hook, context, llm, ct)
: new ExtendedHookResult(),
// Agent 모드: AgentLoopService 참조 필요 — 미구현 (향후 Phase 18-A에서 처리)
HookExecutionMode.Agent => new ExtendedHookResult(),
_ => new ExtendedHookResult()
};
}
/// <summary>
/// Phase 17-C: Prompt 모드 훅 — LLM으로 도구/이벤트를 평가합니다.
/// 프롬프트에서 {{tool_name}}, {{tool_input}}, {{tool_output}}, {{user_message}} 치환.
/// 응답에 "block", "deny", "차단" 포함 시 Block=true 반환.
/// </summary>
private static async Task<ExtendedHookResult> RunPromptHookAsync(
ExtendedHookEntry hook,
HookContext context,
LlmService llm,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(hook.Prompt)) return new ExtendedHookResult();
// 변수 치환
var evalPrompt = hook.Prompt
.Replace("{{tool_name}}", context.ToolName ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{{tool_input}}", context.ToolInput ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{{tool_output}}", context.ToolOutput ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{{user_message}}", context.UserMessage ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{{event}}", context.Event.ToString(), StringComparison.OrdinalIgnoreCase)
.Replace("{{session_id}}", context.SessionId, StringComparison.OrdinalIgnoreCase);
string response;
try
{
var messages = new List<ChatMessage>
{
new() { Role = "user", Content = evalPrompt }
};
// 훅 전용 소형 모델이 지정된 경우 → 현재는 기본 LLM 사용 (향후 모델 오버라이드 지원 예정)
response = await llm.SendAsync(messages, ct);
}
catch (Exception)
{
return new ExtendedHookResult();
}
if (string.IsNullOrWhiteSpace(response)) return new ExtendedHookResult();
// 차단 신호 감지
var lower = response.ToLowerInvariant();
if (lower.Contains("block") || lower.Contains("deny") || lower.Contains("차단"))
return new ExtendedHookResult { Block = true, BlockReason = Truncate(response, 500) };
return new ExtendedHookResult { AdditionalContext = Truncate(response, 2000) };
}
private static async Task<ExtendedHookResult> RunCommandHookAsync(
ExtendedHookEntry hook,
HookContext context,