diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index e837e477c..646dd93f7 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -47,6 +47,10 @@ fn events() -> CommandResult { HookEvent::MessageSubmit, "fires before model dispatch; can transform or block submitted text", ), + ( + HookEvent::TurnEnd, + "fires after a turn ends; observer-only and receives JSON on stdin", + ), ( HookEvent::ToolCallBefore, "fires before each tool call (read-only observer for now)", @@ -142,6 +146,7 @@ fn event_label(event: HookEvent) -> &'static str { HookEvent::SessionStart => "session_start", HookEvent::SessionEnd => "session_end", HookEvent::MessageSubmit => "message_submit", + HookEvent::TurnEnd => "turn_end", HookEvent::ToolCallBefore => "tool_call_before", HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", @@ -267,6 +272,7 @@ mod tests { "session_start", "session_end", "message_submit", + "turn_end", "tool_call_before", "tool_call_after", "mode_change", @@ -308,6 +314,7 @@ mod tests { assert_eq!(event_label(HookEvent::ToolCallBefore), "tool_call_before"); assert_eq!(event_label(HookEvent::ToolCallAfter), "tool_call_after"); assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit"); + assert_eq!(event_label(HookEvent::TurnEnd), "turn_end"); assert_eq!(event_label(HookEvent::ModeChange), "mode_change"); assert_eq!(event_label(HookEvent::OnError), "on_error"); assert_eq!(event_label(HookEvent::SubagentSpawn), "subagent_spawn"); diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index a528bc1ad..d25d3b179 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -33,6 +33,8 @@ pub enum HookEvent { SessionEnd, /// Triggered before a user message is sent to the LLM MessageSubmit, + /// Triggered after a model turn reaches a terminal outcome + TurnEnd, /// Triggered before a tool is executed ToolCallBefore, /// Triggered after a tool completes (success or failure) @@ -62,6 +64,7 @@ impl HookEvent { HookEvent::SessionStart => "session_start", HookEvent::SessionEnd => "session_end", HookEvent::MessageSubmit => "message_submit", + HookEvent::TurnEnd => "turn_end", HookEvent::ToolCallBefore => "tool_call_before", HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", @@ -473,6 +476,28 @@ impl MessageSubmitOutcome { } } +/// Post-update token totals included in `turn_end` payloads. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TurnEndTotals { + pub session_tokens: u32, + pub conversation_tokens: u32, + pub input_tokens: u32, + pub output_tokens: u32, +} + +/// Input used to build the structured JSON payload for `turn_end` hooks. +pub struct TurnEndPayloadInput<'a> { + pub context: &'a HookContext, + pub turn_id: Option<&'a str>, + pub status: &'a str, + pub error: Option<&'a str>, + pub duration_ms: u64, + pub usage: &'a crate::models::Usage, + pub totals: TurnEndTotals, + pub tool_count: usize, + pub queued_message_count: usize, +} + #[derive(Debug, Clone, PartialEq, Eq)] enum MessageSubmitStdout { Unchanged, @@ -670,6 +695,55 @@ impl HookExecutor { } } + /// Run observer-only hooks with a structured JSON stdin payload. + /// + /// The caller is responsible for invoking this off the UI-critical path. + /// Unlike `message_submit`, observer hooks ignore stdout and can never + /// mutate or block the caller's state. + pub fn execute_structured_observer( + &self, + event: HookEvent, + context: &HookContext, + payload: serde_json::Value, + ) -> Vec { + if !self.config.enabled { + return Vec::new(); + } + + let hooks = self.config.hooks_for_event(event); + if hooks.is_empty() { + return Vec::new(); + } + + let env_vars = context.to_env_vars(); + let mut results = Vec::new(); + + for hook in hooks { + if !self.matches_condition(hook, context) { + continue; + } + + let result = self.execute_sync_with_stdin(hook, &env_vars, &payload); + if !result.success { + let label = result.name.as_deref().unwrap_or("(unnamed)"); + tracing::warn!( + target: "hooks", + hook = label, + event = event.as_str(), + exit_code = ?result.exit_code, + duration_ms = result.duration.as_millis() as u64, + error = result.error.as_deref().unwrap_or(""), + stderr_head = %result.stderr.lines().next().unwrap_or(""), + "structured observer hook failed" + ); + } + + results.push(result); + } + + results + } + /// Run every `ShellEnv` hook for this context and merge their stdout /// (`KEY=VALUE\n` lines) into a single env-var map. Used by the /// `exec_shell` tool to inject ephemeral credentials, per-skill PATH @@ -1121,6 +1195,30 @@ fn message_submit_payload(context: &HookContext, text: &str) -> serde_json::Valu }) } +pub fn turn_end_payload(input: TurnEndPayloadInput<'_>) -> serde_json::Value { + json!({ + "event": HookEvent::TurnEnd.as_str(), + "session_id": input.context.session_id, + "workspace": input.context.workspace.as_ref().map(|p| p.display().to_string()), + "mode": input.context.mode, + "model": input.context.model, + "turn_id": input.turn_id, + "status": input.status, + "error": input.error, + "duration_ms": input.duration_ms, + "usage": input.usage, + "totals": { + "session_tokens": input.totals.session_tokens, + "conversation_tokens": input.totals.conversation_tokens, + "input_tokens": input.totals.input_tokens, + "output_tokens": input.totals.output_tokens, + }, + "tool_count": input.tool_count, + "queued_message_count": input.queued_message_count, + "stop_hook_active": false, + }) +} + fn parse_message_submit_stdout(stdout: &str) -> MessageSubmitStdout { let trimmed = stdout.trim(); if trimmed.is_empty() { @@ -1236,6 +1334,8 @@ fn parse_env_lines(stdout: &str) -> HashMap { mod tests { use super::*; use std::collections::HashMap; + #[cfg(not(windows))] + use std::fs; use std::path::PathBuf; /// #456 — `parse_env_lines` covers the formats users actually emit from @@ -1341,12 +1441,65 @@ NOEQUAL line dropped #[test] fn test_hook_event_as_str() { assert_eq!(HookEvent::SessionStart.as_str(), "session_start"); + assert_eq!(HookEvent::TurnEnd.as_str(), "turn_end"); assert_eq!(HookEvent::ToolCallAfter.as_str(), "tool_call_after"); assert_eq!(HookEvent::ModeChange.as_str(), "mode_change"); assert_eq!(HookEvent::SubagentSpawn.as_str(), "subagent_spawn"); assert_eq!(HookEvent::SubagentComplete.as_str(), "subagent_complete"); } + #[test] + fn turn_end_payload_contains_post_turn_observer_fields() { + let context = HookContext::new() + .with_session_id("sess_test") + .with_workspace(PathBuf::from("/tmp/workspace")) + .with_mode("agent") + .with_model("deepseek-test"); + let usage = crate::models::Usage { + input_tokens: 100, + output_tokens: 25, + prompt_cache_hit_tokens: Some(10), + prompt_cache_miss_tokens: Some(90), + reasoning_replay_tokens: Some(12), + ..Default::default() + }; + + let payload = turn_end_payload(TurnEndPayloadInput { + context: &context, + turn_id: Some("turn_test"), + status: "completed", + error: None, + duration_ms: 42, + usage: &usage, + totals: TurnEndTotals { + session_tokens: 125, + conversation_tokens: 125, + input_tokens: 100, + output_tokens: 25, + }, + tool_count: 2, + queued_message_count: 1, + }); + + assert_eq!(payload["event"], "turn_end"); + assert_eq!(payload["session_id"], "sess_test"); + assert_eq!(payload["workspace"], "/tmp/workspace"); + assert_eq!(payload["mode"], "agent"); + assert_eq!(payload["model"], "deepseek-test"); + assert_eq!(payload["turn_id"], "turn_test"); + assert_eq!(payload["status"], "completed"); + assert!(payload["error"].is_null()); + assert_eq!(payload["duration_ms"], 42); + assert_eq!(payload["usage"]["input_tokens"], 100); + assert_eq!(payload["usage"]["output_tokens"], 25); + assert_eq!(payload["usage"]["reasoning_replay_tokens"], 12); + assert_eq!(payload["totals"]["session_tokens"], 125); + assert_eq!(payload["totals"]["conversation_tokens"], 125); + assert_eq!(payload["tool_count"], 2); + assert_eq!(payload["queued_message_count"], 1); + assert_eq!(payload["stop_hook_active"], false); + } + #[test] fn test_hook_context_to_env_vars() { let ctx = HookContext::new() @@ -1506,6 +1659,89 @@ printf '\ndone:%s\n' "${#payload}" assert!(result.stderr.len() >= 256 * 1024, "stderr was drained"); } + #[cfg(not(windows))] + #[test] + fn structured_observer_receives_stdin_json_and_ignores_stdout() { + let dir = tempfile::tempdir().expect("tempdir"); + let command = write_hook_script( + &dir, + "turn_end_observer.sh", + r#"#!/bin/sh +payload=$(cat) +printf '%s' "$payload" > observed.json +printf '{"text":"must be ignored"}' +"#, + ); + let executor = HookExecutor::new( + HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::TurnEnd, &command)], + ..Default::default() + }, + dir.path().to_path_buf(), + ); + let context = HookContext::new().with_session_id("sess_test"); + let payload = json!({ + "event": "turn_end", + "status": "completed", + }); + + let results = executor.execute_structured_observer(HookEvent::TurnEnd, &context, payload); + + assert_eq!(results.len(), 1); + assert!(results[0].success, "observer should succeed: {results:?}"); + assert!( + results[0].stdout.contains("must be ignored"), + "stdout is captured for diagnostics only" + ); + let observed = fs::read_to_string(dir.path().join("observed.json")).expect("payload file"); + let observed: serde_json::Value = serde_json::from_str(&observed).expect("json payload"); + assert_eq!(observed["event"], "turn_end"); + assert_eq!(observed["status"], "completed"); + } + + #[cfg(not(windows))] + #[test] + fn structured_observer_failure_does_not_stop_later_hooks() { + let dir = tempfile::tempdir().expect("tempdir"); + let failing = write_hook_script( + &dir, + "failing_observer.sh", + r#"#!/bin/sh +printf 'failed\n' >&2 +exit 1 +"#, + ); + let later = write_hook_script( + &dir, + "later_observer.sh", + r#"#!/bin/sh +cat > later.json +"#, + ); + let mut fail_hook = Hook::new(HookEvent::TurnEnd, &failing); + fail_hook.continue_on_error = false; + let executor = HookExecutor::new( + HooksConfig { + enabled: true, + hooks: vec![fail_hook, Hook::new(HookEvent::TurnEnd, &later)], + ..Default::default() + }, + dir.path().to_path_buf(), + ); + + let results = executor.execute_structured_observer( + HookEvent::TurnEnd, + &HookContext::new(), + json!({"event": "turn_end"}), + ); + + assert_eq!(results.len(), 2, "observer failures are warn-only"); + assert!(!results[0].success); + assert!(results[1].success); + assert!(dir.path().join("later.json").exists()); + } + #[test] fn test_executor_session_id() { let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from(".")); @@ -1908,6 +2144,7 @@ exit 7 HookEvent::SessionStart, HookEvent::SessionEnd, HookEvent::MessageSubmit, + HookEvent::TurnEnd, HookEvent::ToolCallBefore, HookEvent::ToolCallAfter, HookEvent::ModeChange, @@ -1942,6 +2179,7 @@ exit 7 enabled: true, hooks: vec![ Hook::new(HookEvent::SessionStart, "echo start"), + Hook::new(HookEvent::TurnEnd, "echo turn"), Hook::new(HookEvent::ToolCallBefore, "echo before"), ], ..HooksConfig::default() @@ -1949,6 +2187,7 @@ exit 7 let executor = HookExecutor::new(config, PathBuf::from(".")); // Configured events return true. assert!(executor.has_hooks_for_event(HookEvent::SessionStart)); + assert!(executor.has_hooks_for_event(HookEvent::TurnEnd)); assert!(executor.has_hooks_for_event(HookEvent::ToolCallBefore)); // Unconfigured events return false even when other events are present. assert!(!executor.has_hooks_for_event(HookEvent::ToolCallAfter)); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b23f4fadf..9fbe25e0d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -49,7 +49,7 @@ use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEve use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; use crate::core::ops::{Op, USER_SHELL_TOOL_ID_PREFIX}; -use crate::hooks::{HookEvent, HookExecutor}; +use crate::hooks::{HookEvent, HookExecutor, TurnEndPayloadInput, TurnEndTotals, turn_end_payload}; use crate::llm_client::LlmClient; use crate::localization::{MessageId, tr}; use crate::models::{ @@ -1704,15 +1704,8 @@ async fn run_event_loop( // turn's chunks pull the view down again until the // user opts out by scrolling up. app.user_scrolled_during_stream = false; - app.runtime_turn_status = Some(match status { - crate::core::events::TurnOutcomeStatus::Completed => { - "completed".to_string() - } - crate::core::events::TurnOutcomeStatus::Interrupted => { - "interrupted".to_string() - } - crate::core::events::TurnOutcomeStatus::Failed => "failed".to_string(), - }); + app.runtime_turn_status = + Some(turn_outcome_status_label(status).to_string()); if matches!( status, crate::core::events::TurnOutcomeStatus::Interrupted @@ -1767,7 +1760,7 @@ async fn run_event_loop( reasoning_replay_tokens: usage.reasoning_replay_tokens, recorded_at: Instant::now(), }); - if let Some(error) = error { + if let Some(error) = error.as_deref() { // Only show "Turn failed:" in the composer status // area when an EngineEvent::Error has NOT already // posted the same message into the transcript. @@ -1938,6 +1931,35 @@ async fn run_event_loop( } } + if app.hooks.has_hooks_for_event(HookEvent::TurnEnd) { + let context = app.base_hook_context(); + let payload = turn_end_payload(TurnEndPayloadInput { + context: &context, + turn_id: app.runtime_turn_id.as_deref(), + status: turn_outcome_status_label(status), + error: error.as_deref(), + duration_ms: u64::try_from(turn_elapsed.as_millis()) + .unwrap_or(u64::MAX), + usage: &usage, + totals: TurnEndTotals { + session_tokens: app.session.total_tokens, + conversation_tokens: app.session.total_conversation_tokens, + input_tokens: app.session.total_input_tokens, + output_tokens: app.session.total_output_tokens, + }, + tool_count: app.tool_evidence.len(), + queued_message_count: app.queued_message_count(), + }); + let hooks = app.hooks.clone(); + tokio::task::spawn_blocking(move || { + let _ = hooks.execute_structured_observer( + HookEvent::TurnEnd, + &context, + payload, + ); + }); + } + if queued_to_send.is_none() { queued_to_send = app.pop_queued_message(); } @@ -4267,6 +4289,14 @@ fn queued_session_to_ui(msg: QueuedSessionMessage) -> QueuedMessage { } } +fn turn_outcome_status_label(status: crate::core::events::TurnOutcomeStatus) -> &'static str { + match status { + crate::core::events::TurnOutcomeStatus::Completed => "completed", + crate::core::events::TurnOutcomeStatus::Interrupted => "interrupted", + crate::core::events::TurnOutcomeStatus::Failed => "failed", + } +} + fn reconcile_turn_liveness(app: &mut App, now: Instant, has_running_agents: bool) -> bool { if app.is_loading && app.runtime_turn_status.is_none() diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ea75ae62a..11450feb8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -555,6 +555,62 @@ the message. Existing environment variables remain available. `shell_env` hooks keep their existing `KEY=VALUE` stdout contract; the JSON stdout contract applies only to `message_submit`. +### Observer `turn_end` hooks + +`turn_end` hooks run after a model turn reaches a terminal outcome and +the TUI has updated visible state, token counters, cost estimates, +receipts, and session persistence scheduling. They are observer-only: +stdout is ignored, failures are logged as warnings, and hook execution +does not block the next input or queued-message dispatch. +Because observer hooks cannot block caller state, `continue_on_error` +does not stop later `turn_end` hooks; all matching hooks are attempted +even when an earlier hook fails. + +```toml +[[hooks.hooks]] +event = "turn_end" +command = "~/.codewhale/hooks/turn-end.sh" +timeout_secs = 5 +continue_on_error = true +``` + +The hook receives JSON on stdin: + +```json +{ + "event": "turn_end", + "session_id": "sess_12345678", + "workspace": "/path/to/workspace", + "mode": "agent", + "model": "deepseek-chat", + "turn_id": "turn_123", + "status": "completed", + "error": null, + "duration_ms": 4200, + "usage": { + "input_tokens": 1000, + "output_tokens": 250, + "prompt_cache_hit_tokens": 128, + "prompt_cache_miss_tokens": 872, + "reasoning_replay_tokens": null + }, + "totals": { + "session_tokens": 1250, + "conversation_tokens": 1250, + "input_tokens": 1000, + "output_tokens": 250 + }, + "tool_count": 2, + "queued_message_count": 0, + "stop_hook_active": false +} +``` + +`status` is `completed`, `interrupted`, or `failed`. `error` is set +only when the same error is already visible to the user. Payloads do +not include full transcript text, assistant response text, tool +arguments, tool outputs, provider headers, or secrets. + ### Sub-agent lifecycle hooks `subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle diff --git a/docs/rfcs/1364-hooks-lifecycle.md b/docs/rfcs/1364-hooks-lifecycle.md index f7f759c11..c213c7077 100644 --- a/docs/rfcs/1364-hooks-lifecycle.md +++ b/docs/rfcs/1364-hooks-lifecycle.md @@ -1,276 +1,482 @@ # RFC: Hook Lifecycle Data Flow **Issue:** #1364 -**Status:** Draft -**Date:** 2026-05-28 +**Status:** Phase 1 landed, Phase 2/3 spec +**Date:** 2026-06-02 +**Baseline:** `main` at `0072209d0096c0b583efeb3da6c6f2ac452bd572` -## 1. Problem +## 1. Current State -CodeWhale already has lifecycle hooks and MCP support, but the current hook -surface is mostly observer-only. This blocks portable extensions that need to -participate in the agent data flow: +CodeWhale has MCP support and configurable hooks. Phase 1 of #1364 landed via +#2434, which harvested the mutable `message_submit` slice from #2318. -- memory/context injection before a user message reaches the model -- post-turn background analysis that prepares context for the next turn -- sub-agent lifecycle visibility for orchestration and audit extensions +Implemented in Phase 1: -The current `message_submit` event fires before dispatch, but its output is -ignored. `TurnComplete`, `AgentSpawned`, and `AgentComplete` exist internally, -but they are not exposed as configurable hook events. +- non-background `message_submit` hooks receive JSON on stdin +- stdout JSON with a non-empty string `text` field replaces submitted text +- exit code `2` blocks submission before a model turn starts +- multiple `message_submit` hooks run serially in config order +- background `message_submit` hooks remain observer-only +- transformed text is used by history, file mention expansion, API messages, + and engine dispatch +- `continue_on_error = true` surfaces stderr/stdout/internal errors as a + transient TUI warning instead of silently swallowing continued failures -## 2. PR split +Remaining #1364 work: -This issue should be implemented as three PRs. Each PR should be independently -reviewable and should leave the hook system in a useful state. +- Phase 2: expose a post-turn `turn_end` lifecycle hook +- Phase 3: expose observer-only subagent lifecycle hooks -### PR 1: Mutable `message_submit` +Future work should start from current `main`; #2318 is now a reference branch, +not the Phase 1 merge target. -Add a structured hook execution path for `message_submit` that can transform or -block the user's submitted text before it is sent to the engine. +## 2. Shared Design Rules -Scope: +All remaining hooks should preserve the existing `[[hooks.hooks]]` config +shape, existing env vars, and existing condition matching. -- keep the existing `[[hooks.hooks]]` config shape -- pass a JSON payload to the hook on stdin -- interpret stdout JSON containing `text` as the replacement user text -- treat exit code `2` as an intentional block -- run multiple submit hooks serially in config order -- keep existing env vars for compatibility -- keep `shell_env` stdout parsing unchanged +Structured payloads should be written to hook stdin as JSON and should include +the stable shared fields: -Non-goals: +- `event` +- `session_id` +- `workspace` +- `mode` +- `model` + +Observer lifecycle hooks must not: + +- mutate transcript content +- mutate submitted user text +- mutate tool arguments or tool results +- block user input or subagent scheduling +- expose secrets, full tool outputs, or unbounded transcript content -- no tool argument mutation -- no global stdout JSON semantics for all hook events -- no transcript or model response mutation +For Phase 2 and Phase 3, stdout is ignored. The stdout JSON mutation contract +remains specific to `message_submit`. + +## 3. PR Split + +### PR 1: Mutable `message_submit` + +Status: landed in #2434. + +No further Phase 1 development should be based on #2318 unless the maintainer +explicitly asks for it. Follow-up behavior should build on `main`. ### PR 2: `turn_end` -Expose the existing turn completion lifecycle as a hook event. +Expose turn completion as a post-turn observer hook. This is the next focused +slice. Scope: - add `HookEvent::TurnEnd` with event name `turn_end` -- fire from the UI's `EngineEvent::TurnComplete` branch after core app state, - usage, cost, notifications, and receipt state have been updated -- pass turn metadata on stdin as JSON -- make failures non-blocking and warn-only -- include a `stop_hook_active` field in the payload, initially `false`, so the - contract can support re-entry protection later +- add a structured observer hook execution path that accepts a JSON payload +- fire from the `EngineEvent::TurnComplete` UI branch after core user-visible + state has been updated +- run hooks without blocking the next user action +- treat hook failures as warn/log only +- include `stop_hook_active`, initially `false`, to reserve the re-entry guard + contract +- document the payload and non-blocking semantics +- update `/hooks events`, `docs/CONFIGURATION.md`, and web docs Non-goals: -- no change to turn status -- no blocking of user input -- no transcript mutation from `turn_end` +- no blocking or replacement behavior +- no transcript mutation +- no model response mutation +- no tool output or full transcript payload +- no subagent lifecycle hook in this PR -### PR 3: Subagent lifecycle observer hooks +### PR 3: Subagent Lifecycle Observer Hooks -Expose subagent start and completion as observer-only hook events. +Expose subagent start and completion as observer-only lifecycle hooks. Scope: - add `HookEvent::SubagentSpawn` with event name `subagent_spawn` - add `HookEvent::SubagentComplete` with event name `subagent_complete` -- fire from the existing `AgentSpawned` and `AgentComplete` UI branches -- pass subagent metadata on stdin as JSON -- make failures non-blocking and warn-only +- reuse the structured observer execution path from Phase 2 +- fire from the existing `EngineEvent::AgentSpawned` and + `EngineEvent::AgentComplete` UI branches +- pass bounded subagent metadata on stdin as JSON +- run hooks without blocking subagent scheduling or parent-turn progress +- treat hook failures as warn/log only +- document the payload and observer-only semantics Non-goals: - no subagent spawn gating in the first version -- no subagent prompt/result mutation +- no subagent prompt or result mutation +- no full prompt/result payload by default - no changes to subagent scheduling +- no new subagent type matcher unless a later review asks for it -## 3. PR 1 detailed plan - -### 3.1 Contract +## 4. Phase 2 Technical Spec: `turn_end` -Configuration: +### 4.1 Hook Configuration ```toml [[hooks.hooks]] -event = "message_submit" -command = "~/.deepseek/hooks/inject-memory.sh" -timeout_secs = 2 +event = "turn_end" +command = "~/.codewhale/hooks/turn-end.sh" +timeout_secs = 5 continue_on_error = true ``` -Input payload on stdin: +The hook should be treated as observer-only even when `background = false`. +The UI must not wait for it before accepting input, dispatching queued +messages, or repainting the idle state. + +### 4.2 Trigger Point + +The trigger point is `crates/tui/src/tui/ui.rs`, inside the +`EngineEvent::TurnComplete` branch. + +The hook should fire after these state updates have happened: + +- loading and streaming state cleared +- turn duration recorded +- runtime turn status set +- session token counters updated +- cache telemetry updated +- turn cost accrued +- user-facing notification/receipt state updated +- session snapshot persistence has been scheduled + +The hook should fire before any automatically queued message starts a new turn, +so the completed turn is observable even when the queue immediately continues. +The hook itself must be fire-and-forget; it must not delay the queued message. + +`turn_end` should fire for all terminal turn outcomes: + +- `completed` +- `interrupted` +- `failed` + +It should not fire on app startup, session startup, or session shutdown without +a completed engine turn. + +### 4.3 Payload + +Example payload: ```json { - "event": "message_submit", - "text": "original user text", - "session_id": "sess_xxxx", + "event": "turn_end", + "session_id": "sess_12345678", "workspace": "/path/to/workspace", "mode": "agent", "model": "deepseek-chat", - "total_tokens": 1234 + "turn_id": "turn_123", + "status": "completed", + "error": null, + "duration_ms": 4200, + "usage": { + "input_tokens": 1000, + "output_tokens": 250, + "prompt_cache_hit_tokens": 128, + "prompt_cache_miss_tokens": 872, + "reasoning_replay_tokens": null + }, + "totals": { + "session_tokens": 1250, + "conversation_tokens": 1250, + "input_tokens": 1000, + "output_tokens": 250 + }, + "tool_count": 2, + "queued_message_count": 0, + "stop_hook_active": false } ``` -Output payload on stdout: +Payload rules: -```json -{ "text": "replacement user text" } -``` +- `status` is one of `completed`, `interrupted`, or `failed` +- `error` is the turn error string only when already visible to the user +- `duration_ms` is wall-clock turn duration from `app.turn_started_at` +- `usage` mirrors `EngineEvent::TurnComplete.usage` +- `totals` are the post-update app/session counters +- `tool_count` is the number of tool evidence entries, not full tool output +- `queued_message_count` is informational and must not change queue behavior +- `stop_hook_active` is always `false` in this PR -Rules: +Do not include: -- exit `0` with stdout JSON containing `text: string` replaces the current text -- exit `0` with empty stdout leaves the current text unchanged -- exit `0` with JSON that does not contain `text` leaves the current text - unchanged -- exit `2` blocks submission before the message is appended to history or sent - to the engine -- other non-zero exits follow `continue_on_error` - - `true`: warn, keep the current text, continue later hooks - - `false`: stop later hooks and block submission with an error message -- `background = true` on `message_submit` remains observer-only and cannot - transform or block submission +- full transcript text +- full tool arguments or outputs +- API keys or provider headers +- raw assistant response text -Multiple hooks: +### 4.4 Implementation Plan + +1. Extend `HookEvent` in `crates/tui/src/hooks.rs`. + +```rust +TurnEnd, +``` -- hooks run in config order -- each hook receives the latest transformed text -- the final transformed text is the only text used by file mention expansion, - skill wrapping, auto routing, history, and `api_messages` +Map it in `HookEvent::as_str()` as `"turn_end"`. -### 3.2 Implementation steps +2. Add a structured observer execution helper in `crates/tui/src/hooks.rs`. -1. Add structured submit outcome types in `crates/tui/src/hooks.rs`: +Suggested shape: ```rust -pub enum MessageSubmitOutcome { - Unchanged, - Replaced(String), - Blocked { reason: String }, +pub fn execute_structured_observer( + &self, + event: HookEvent, + context: &HookContext, + payload: serde_json::Value, +) +``` + +This helper should: + +- filter hooks with `hooks_for_event(event)` +- apply existing condition matching +- pass existing env vars +- write `payload` to stdin using the existing stdin-capable executor path +- ignore stdout +- log failures with `tracing::warn!` +- ignore `continue_on_error` for observer sequencing; attempt all matching hooks +- never return a blocking outcome to the caller + +The caller should invoke this helper from a background task/thread so UI event +handling remains non-blocking. If that requires cloning `HookExecutor`, keep the +payload owned and avoid borrowing `App`. + +3. Add a `turn_end_payload(...)` builder. + +Keep it private to `hooks.rs` if all fields can be passed through +`HookContext`, or place it near the UI call site if it needs direct `App` +access. Prefer a small explicit payload builder over ad hoc JSON construction +inside the event branch. + +4. Wire the UI event branch. + +In `crates/tui/src/tui/ui.rs`, after the `TurnComplete` branch finishes the +core app-state updates, build the payload and dispatch the observer hook. + +5. Update command and docs. + +- `crates/tui/src/commands/hooks.rs` +- `docs/CONFIGURATION.md` +- `web/app/[locale]/docs/page.tsx` + +### 4.5 Tests + +Hook unit tests: + +- `HookEvent::TurnEnd` serializes/deserializes as `turn_end` +- configured `turn_end` hooks receive stdin JSON +- stdout is ignored +- non-zero exit logs/warns but does not produce a blocking result +- timeout does not block the caller path + +TUI tests: + +- completed turn fires one `turn_end` hook +- failed turn fires one `turn_end` hook with `status = "failed"` and `error` +- interrupted turn fires one `turn_end` hook with `status = "interrupted"` +- payload contains post-update token totals +- queued messages still dispatch after `TurnComplete` + +Manual smoke test: + +1. Configure a `turn_end` hook that appends stdin JSON to a temp file. +2. Run one successful turn. +3. Confirm the file contains `event = "turn_end"` and `status = "completed"`. +4. Configure a slow hook with `timeout_secs = 1`. +5. Confirm the TUI returns to idle and accepts input without waiting. + +## 5. Phase 3 Technical Spec: Subagent Lifecycle Hooks + +### 5.1 Hook Configuration + +```toml +[[hooks.hooks]] +event = "subagent_spawn" +command = "~/.codewhale/hooks/subagent-spawn.sh" +timeout_secs = 3 +continue_on_error = true + +[[hooks.hooks]] +event = "subagent_complete" +command = "~/.codewhale/hooks/subagent-complete.sh" +timeout_secs = 3 +continue_on_error = true +``` + +Both events are observer-only. Their hooks must not gate spawn, cancel +subagents, or rewrite prompts/results. + +### 5.2 Trigger Points + +Trigger from `crates/tui/src/tui/ui.rs`: + +- `EngineEvent::AgentSpawned { id, prompt }` -> `subagent_spawn` +- `EngineEvent::AgentComplete { id, result }` -> `subagent_complete` + +Fire after the existing UI state/status updates for each branch have been +applied. Hook failures must not affect: + +- `app.agent_progress` +- `app.status_message` +- `Op::ListSubAgents` +- subagent cards or mailbox routing +- parent turn completion + +### 5.3 Payloads + +Spawn payload: + +```json +{ + "event": "subagent_spawn", + "session_id": "sess_12345678", + "workspace": "/path/to/workspace", + "mode": "agent", + "model": "deepseek-chat", + "agent_id": "agent_abc", + "prompt_preview": "Investigate failing tests", + "prompt_truncated": false } ``` -2. Add a stdin-capable sync executor: +Complete payload: -```rust -fn execute_sync_with_stdin( - &self, - hook: &Hook, - env_vars: &HashMap, - stdin_json: &serde_json::Value, -) -> HookResult +```json +{ + "event": "subagent_complete", + "session_id": "sess_12345678", + "workspace": "/path/to/workspace", + "mode": "agent", + "model": "deepseek-chat", + "agent_id": "agent_abc", + "status": "completed", + "result_preview": "Found the failing assertion in parser tests", + "result_truncated": false +} ``` -This should reuse the existing timeout, working directory, stdout, stderr, and -error handling behavior from `execute_sync`. +Payload rules: + +- use bounded previews, not full prompt/result text +- use the same truncation helper for prompt and result previews +- if agent type or assignment metadata is available without extra blocking + lookups, include it as optional `agent_type` / `assignment` fields +- do not add blocking lookups only to enrich hook payloads +- do not include full subagent transcript -3. Add a `message_submit` transform entrypoint: +### 5.4 Implementation Plan + +1. Extend `HookEvent` in `crates/tui/src/hooks.rs`. ```rust -pub fn execute_message_submit_transform( - &self, - context: &HookContext, - original_text: &str, -) -> MessageSubmitOutcome +SubagentSpawn, +SubagentComplete, ``` -This method should: +Map them as: -- filter configured `MessageSubmit` hooks through existing condition matching -- build a JSON payload for each hook using the current text -- run non-background hooks through `execute_sync_with_stdin` -- run background hooks with the existing observer-only path -- parse stdout JSON only for non-background hooks -- return the final text or a block result +- `"subagent_spawn"` +- `"subagent_complete"` -4. Apply the transformed message in `dispatch_user_message`: +2. Reuse the Phase 2 structured observer helper. -- run the transform before `last_submitted_prompt`, file mentions, history, and - `api_messages` -- create a local mutable `QueuedMessage` or replacement display text -- if blocked, show a status message or toast and return without dispatch +Subagent lifecycle hooks should not create a second observer execution path. +Reuse the `execute_structured_observer` helper and payload writing behavior. -5. Update `/hooks events`: +3. Add bounded preview helpers. -- keep `message_submit` listed -- update description to say it can transform or block user text +Keep prompt/result payloads bounded. Reuse an existing truncation helper if one +is already available in the TUI layer; otherwise add a small private helper +that truncates by chars and returns both preview text and a boolean +`*_truncated` flag. -6. Update user-facing docs: +4. Wire the UI branches. -- document the stdin/stdout contract -- document exit code `2` -- document that `shell_env` still uses `KEY=VALUE` stdout +In the `AgentSpawned` branch, build the spawn payload from `id` and the +existing `prompt_summary`/bounded prompt preview. -### 3.3 Test plan +In the `AgentComplete` branch, build the complete payload from `id` and a +bounded result preview. -Unit tests in `crates/tui/src/hooks.rs`: +5. Update docs and `/hooks events`. -- parses stdout `{"text":"changed"}` as replacement -- empty stdout means unchanged -- JSON without `text` means unchanged -- malformed stdout means unchanged with warning semantics -- exit `2` maps to blocked -- multiple hooks apply transforms in order -- background `message_submit` hook cannot transform -- `continue_on_error = false` blocks on non-zero failure +- `crates/tui/src/commands/hooks.rs` +- `docs/CONFIGURATION.md` +- `web/app/[locale]/docs/page.tsx` -TUI integration or focused dispatch tests: +### 5.5 Tests -- transformed text is written to `api_messages` -- transformed text is written to visible history -- transformed text is used by file mention expansion -- blocked submit does not append user history -- blocked submit does not push an API message -- blocked submit leaves loading state false +Hook unit tests: -Manual smoke test: +- `HookEvent::SubagentSpawn` serializes/deserializes as `subagent_spawn` +- `HookEvent::SubagentComplete` serializes/deserializes as + `subagent_complete` +- subagent observer hooks receive stdin JSON +- stdout is ignored +- non-zero exits do not affect caller state -1. Add a config hook that prepends `[hooked] ` to every submitted message. -2. Submit `hello`. -3. Verify the transcript and model input use `[hooked] hello`. -4. Replace the hook with one that exits `2`. -5. Submit `hello`. -6. Verify no turn starts and the TUI shows the block reason. +TUI tests: -## 4. Shared payload conventions +- `AgentSpawned` fires one `subagent_spawn` hook +- `AgentComplete` fires one `subagent_complete` hook +- payload previews are truncated and marked when input is long +- hook failure does not prevent `Op::ListSubAgents` +- hook failure does not alter `app.agent_progress` +- hook failure does not alter `app.status_message` -All new structured hook payloads should include: +Manual smoke test: -- `event` -- `session_id` -- `workspace` -- `mode` -- `model` +1. Configure both subagent hooks to append stdin JSON to a temp file. +2. Trigger a subagent. +3. Confirm `subagent_spawn` appears before `subagent_complete`. +4. Confirm prompts/results are bounded previews, not full transcripts. -Event-specific payloads should add only fields that are stable and useful for -extension authors. Avoid leaking secrets, full tool outputs, or unbounded -transcript content in the first version. +## 6. Contribution Workflow -## 5. Compatibility +For Phase 2 and Phase 3: -- Existing hook config remains valid. -- Existing observer-only hooks keep working. -- Existing env vars remain available. -- `shell_env` keeps its existing stdout `KEY=VALUE` contract. -- Structured stdout is interpreted only by `message_submit` in PR 1. +- create a fresh branch from current `main` +- keep each PR focused on one behavior boundary +- use `Refs #1364 (partial)` unless a maintainer reopens or re-scopes the issue +- do not use `Closes #1364` for either follow-up slice unless the maintainer + confirms the issue should be fully closed by that PR +- include local validation evidence in the PR body +- keep PRs ready for the direct-merge path: rebased, non-draft, green CI, and + backed by focused tests -## 6. Review checkpoints +Suggested PR body shape: -PR 1 should be accepted only if: +```text +Summary: +Scope: +Not in this slice: +Builds on: #2434 +Issues: Refs #1364 (partial) +Validation: +``` -- submit mutation is covered by tests -- submit blocking is covered by tests -- the unchanged path preserves current behavior -- `shell_env` tests still prove the old stdout contract -- the docs clearly mark `message_submit` as the only mutable hook +## 7. Review Checkpoints -PR 2 should be accepted only if: +Phase 2 is ready for review only if: -- `turn_end` fires after `TurnComplete` app state updates -- failure is warn-only -- payload contains status and usage +- `turn_end` fires after post-turn app state is updated +- all terminal turn statuses are covered by tests +- hook execution is non-blocking from the UI perspective +- payload excludes transcript/tool-output content +- docs specify that stdout is ignored -PR 3 should be accepted only if: +Phase 3 is ready for review only if: - subagent hooks are observer-only -- failures do not affect subagent lifecycle -- payloads do not include unbounded or secret data +- spawn and completion are both covered by tests +- failures do not affect subagent state or scheduling +- payload previews are bounded and tested +- docs clearly state that gating/mutation are out of scope diff --git a/web/app/[locale]/docs/page.tsx b/web/app/[locale]/docs/page.tsx index f141001a0..db4619203 100644 --- a/web/app/[locale]/docs/page.tsx +++ b/web/app/[locale]/docs/page.tsx @@ -179,7 +179,7 @@ default_timeout_secs = 30 [[hooks.hooks]] event = "session_start" # 也支持: tool_call_before / tool_call_after -command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change / on_error / shell_env`} +command = "~/.codewhale/hooks/pre.sh" # / message_submit / turn_end / mode_change / on_error / shell_env`}

完整参考:config.example.toml。 @@ -187,6 +187,7 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change /

message_submit hooks run before a user message is sent to the model. A non-background hook can print {'{"text":"replacement"}'} on stdout to replace the message; text must be non-empty. Exit with code 2 to block the submission. + turn_end hooks run after a turn finishes, receive JSON on stdin, ignore stdout, and never block the next input. shell_env keeps its existing KEY=VALUE stdout contract.

@@ -434,7 +435,7 @@ default_timeout_secs = 30 [[hooks.hooks]] event = "session_start" # or: tool_call_before / tool_call_after -command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change / on_error / shell_env`} +command = "~/.codewhale/hooks/pre.sh" # / message_submit / turn_end / mode_change / on_error / shell_env`}

Full reference: config.example.toml. @@ -442,6 +443,7 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change /

message_submit hooks run before a user message is sent to the model. A non-background hook can print {'{"text":"replacement"}'} on stdout to replace the message; text must be non-empty. Exit with code 2 to block the submission. + turn_end hooks run after a turn finishes, receive JSON on stdin, ignore stdout, and never block the next input. shell_env keeps its existing KEY=VALUE stdout contract.

diff --git a/web/package-lock.json b/web/package-lock.json index 1958d3fa7..33806a683 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1669,6 +1669,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2434,6 +2435,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2456,6 +2458,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2478,6 +2481,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2494,6 +2498,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2510,6 +2515,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2526,6 +2532,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2542,6 +2549,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2558,6 +2566,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2574,6 +2583,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2590,6 +2600,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2606,6 +2617,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2622,6 +2634,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2638,6 +2651,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2660,6 +2674,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2682,6 +2697,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2704,6 +2720,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2726,6 +2743,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2748,6 +2766,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2770,6 +2789,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2792,6 +2812,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2814,6 +2835,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2833,6 +2855,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2852,6 +2875,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2871,6 +2895,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -13082,6 +13107,474 @@ } } }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", @@ -13109,6 +13602,50 @@ } } }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/vitest/node_modules/vite": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",