feat(hooks): add before_llm_call, after_llm_call, before_response_emit modifying hooks#17
Closed
zeroaltitude wants to merge 43 commits intomainfrom
Closed
feat(hooks): add before_llm_call, after_llm_call, before_response_emit modifying hooks#17zeroaltitude wants to merge 43 commits intomainfrom
zeroaltitude wants to merge 43 commits intomainfrom
Conversation
18 tasks
a94eed9 to
a520ab2
Compare
…t modifying hooks Three new sequential/modifying plugin hooks for LLM call interception and response gating: - before_llm_call: fires before every LLM API call. Plugins can inspect/modify messages, system prompt, filter tools, or block the call entirely. Integrated via stream function wrapping. - after_llm_call: fires after receiving the LLM response but before tool execution. Plugins can filter/modify tool calls or block. Integrated via event subscription on message_end events. - before_response_emit: fires when the agent's final response is ready, before delivery. Plugins can modify content or block emission. Integrated at the snapshot selection point. All three run sequentially and merge results across handlers, following the same pattern as before_tool_call and message_sending. Enables security plugins to implement: - Context-aware tool filtering (before_llm_call) - Tool call auditing/blocking (after_llm_call) - Output content policies (before_response_emit) Complements the existing llm_input/llm_output observation hooks (which are void/fire-and-forget) by adding modifying capability. 36 unit tests across hook runners, stream wrapper, and response emit. [claude, human developer oversight]
…tion tracking - after_llm_call is now void (parallel, observational) — modifying tool calls requires agent loop interception points that don't exist yet. Documented as a future upgrade path. - before_response_emit block now returns empty string instead of undefined, so callers can distinguish blocked from unmodified. - hookIterationRef declared before event subscription (fixes temporal dead zone warning). Subscription now activates for before_llm_call too, so iteration counter updates even without after_llm_call hooks. Addresses greptile + codex-connector review on PR openclaw#33916.
…separate PR) hook-stream-wrapper.ts incorrectly referenced context_assembled which is defined in feat/hook-context-assembled-loop-iteration (openclaw#33915), not this branch. Removed cross-branch dependency so CI passes standalone. Fixes type errors in CI check job.
…re_response_emit - before_response_emit: use explicit undefined check instead of truthiness for content, so plugins can return empty string as valid modified content - before_llm_call: check systemPrompt !== undefined instead of truthiness, so plugins can clear the system prompt with empty string Addresses codex P2 reviews on PR openclaw#33916.
…and session save control - Move clearInternalHooks() to server.impl.ts (before plugin registration) to ensure plugins register first, bundled hooks second (FIFO ordering) - Add session-memory handler extensions: blockSessionSave, sessionSaveRedirectPath, sessionSaveContent — allows upstream hooks (e.g. security plugins) to control session save behavior - Update internal-hooks JSDoc documenting command:new context fields - Align plugin on() opts type to OpenClawPluginHookOptions (drop priority)
- Add hooks.extended.test.ts covering before_llm_call, after_llm_call, before_response_emit (context_assembled/loop_iteration tests are in hooks.context-loop.test.ts in the companion PR) - Move clearInternalHooks to server.impl.ts for correct plugin→bundled ordering - Add session-memory handler extensions (blockSessionSave, redirectPath, customContent) - Align registry opts type to OpenClawPluginHookOptions - Add internal-hooks JSDoc for command:new context fields
1. Clear blocked content from session history when before_response_emit returns block:true (prevents data leak to subsequent turns) 2. Rewrite ALL text parts in multi-part assistant messages (prevents stale/unredacted fragments in parts after the first) 3. Switch hook execution from priority-sorted to FIFO (registration order) to match the FIFO contract established in registry.ts Refactored session message rewriting into rewriteAssistantContent() and clearAssistantContent() helpers for clarity and reuse. Note: Greptile/Codex false positives on tools:[] and systemPrompt:"" were verified — [] is truthy in JS and ?? only triggers for null/undefined.
…bridge
after_llm_call is now a modifying hook (sequential execution, merged results).
Plugins can return { block: true } to block ALL tool execution for the turn,
or { toolCalls: [...] } to filter which tool calls are allowed.
Implementation uses a mutable ref bridge (after-llm-call-gate.ts) between
the streaming subscription callback (where after_llm_call fires) and
before_tool_call (where each tool executes). This avoids restructuring the
agent loop while delivering batch tool-call analysis capabilities.
Gate lifecycle:
- Set after after_llm_call returns block/filter decisions
- Checked by before_tool_call before each tool executes
- Cleared on turn_start (prevents stale decisions) and run cleanup
This enables provenance plugin capabilities:
- Batch pattern detection (correlate multiple tool calls in one turn)
- Taint-aware blanket blocking (block all tools when context is tainted)
- Selective tool call removal (filter specific calls from the batch)
…ponse guard 1. Guard before_response_emit: only runs when this turn produced an assistant reply (assistantTexts.length > 0). Prevents stale prior-turn content from being emitted on timeout/abort turns. (Codex P1) 2. Fix session history rewrite: scan backwards for last assistant message instead of assuming messages[-1] is assistant. Prevents silent no-op when tool results are appended after the snapshot. (Greptile) 3. Fix after_llm_call gate race: capture iteration at event time and compare in .then() callback. Stale results from slow handlers are discarded when the turn has advanced. (Codex P1) 4. Export PluginHookAfterLlmCallResult from hooks.ts public API. (Greptile) 5. Fix stale test description: 'parallel (void)' → 'sequentially (modifying)' in hooks.llm-response.test.ts. (Greptile)
…ter response-emit
1. Clear after_llm_call gate when hook returns no block/filter (undefined
or { block: false } without toolCalls). Prevents stale gates from earlier
message_end events in multi-step tool loops from blocking later tools. (Codex P1)
2. Refresh messagesSnapshot after before_response_emit modifies content,
so downstream consumers (agent_end, llm_output, cache trace) see
post-redaction content instead of original pre-hook text. (Codex P2)
…es context Spread original context object before applying hook modifications so that provider metadata and other fields attached by upstream wrappers are preserved. Previously, the effectiveContext was rebuilt from only 3 keys (systemPrompt, messages, tools), silently dropping any extra fields.
after-llm-call-gate.test.ts (10 tests): - Block all tools, block with/without reason, filter by toolCallId - Allow listed tools, no-filter passthrough, session isolation - Gate overwrite, clear, block-takes-precedence-over-filter hook-response-emit.test.ts (4 new tests, 15 total): - Blocked content cleared from session history (PII scrubbing) - Multi-part text parts all cleared on block - Multi-part text parts: first rewritten, rest cleared on modify - Assistant message found even when not last element
…memory changes - Delete hooks.llm-response.test.ts (328 lines) — complete duplicate of hooks.extended.test.ts which has 2 extra after_llm_call tests - Remove hooks.context-loop.test.ts (160 lines) — belongs to PR openclaw#33915 (feat/hook-context-assembled-loop-iteration), not this PR - Revert session-memory handler changes (58 lines) — blockSessionSave and sessionSaveRedirectPath are a separate feature; will move to own PR Net: -512 lines, 49 tests remain (zero coverage loss)
…+ gate error handling - Move hookCtx declaration above subscription callback to eliminate temporal dead zone forward reference - Use isToolCallBlockType() in after_llm_call tool extraction to handle toolUse/functionCall variants (not just toolCall) - Clear gate on hook error to prevent stale gates blocking tool calls - Remove session-memory handler changes (separate feature)
Four fixes from deep audit: 1. Document after_llm_call gate as best-effort for async hooks — the .then() microtask may not resolve before tool dispatch begins for slow/async hook implementations. 2. Add hookRunDisposed flag to prevent late-resolving after_llm_call hooks from repopulating the gate after run cleanup. Set before clearAfterLlmCallGate in finally block, checked in .then(). 3. Block tool calls with missing toolCallId when allowedToolCallIds is set — a tool without an ID cannot be verified against the allowlist and must be blocked (security gap). 4. Use !== undefined consistently for messages, systemPrompt, and tools in hook-stream-wrapper.ts. Any explicit value (including [] or '') means the hook wants that exact value; return undefined for 'no change'.
…le JSDoc - Remove dead else branch in before_response_emit (assistantTexts.length is guaranteed > 0 by the outer guard) - Check hookRunDisposed in .catch() handler for consistency (idempotent clear, but prevents unnecessary work after run disposal) - Remove session-memory policy JSDoc from internal-hooks.ts (belongs in feat/hook-session-memory-policy PR, not here)
… change
Restores `opts?: { priority?: number }` on the `on()` method and
`.toSorted()` by priority in getHooksForName. The removal was
accidental — priority ordering was part of the existing plugin API.
Changes `next.block ?? acc?.block` to `next.block || acc?.block` across all 4 merge functions (before_tool_call, after_llm_call, before_llm_call, before_response_emit). Once a security plugin sets block: true, a later plugin returning block: false cannot override it. This is critical for the stated security use case (taint tracking, prompt injection defense).
…ck, filter to assistant message_end 1. Tools/toolCalls use intersection when both handlers provide lists — prevents later handler from widening security plugin's allowlist. 2. Block (modifiedContent === '') now clears ALL assistantTexts via splice, not just the last entry. Earlier tool-loop chunks would have escaped. 3. after_llm_call only fires on assistant message_end — non-assistant message_end was clearing the gate and dropping computed allowlists.
…onse_emit Plugin authors targeting PII redaction across full multi-turn tool-loop runs should use after_llm_call (per-turn) rather than before_response_emit which only sees the last assistant message. Blocking is always reliable.
Plugins can now access and modify ALL assistant messages from a tool-loop run, not just the last one. This closes the multi-turn PII redaction gap identified in review. Changes: - PluginHookBeforeResponseEmitEvent gains allContent: string[] (all assistant texts from the run, chronological) - PluginHookBeforeResponseEmitResult gains allContent?: string[] (when returned, replaces the full assistantTexts array) - allContent takes precedence over content when both are returned - Block now clears ALL assistant messages in session history, not just last - applyBeforeResponseEmitHook returns a structured result object instead of string|undefined for cleaner caller logic - rewriteAllAssistantContent walks all assistant messages in order - 21 tests covering allContent modification, precedence, block clearing Fully backward-compatible: existing plugins using only content still work.
…es only Block and allContent rewrites now only touch assistant messages from the current run (using preRunMessageCount), never corrupting previously-delivered session history. Prior assistant turns are left intact. Also fixes: - Missing allContent field in hooks.extended.test.ts fixture (TS error) - Garbled duplicate 'priority order' comment in hooks.ts
… allContent Two fixes: 1. after_llm_call gate: move staleness check before the no-result clear path. Previously a slow turn-N handler resolving with no result could erase turn-N+1's gate decision. Now stale results are discarded first. 2. Empty allContent ([] from plugins suppressing all output) was treated as falsy and ignored. Use !== undefined check instead.
If compaction shrinks the message array during a run, preRunMessageCount may exceed the current length. getRunScopedMessages now falls back to tail-based extraction (last N assistant messages) when the index is stale, ensuring block/rewrite operations still target the correct messages. Also replied to Codex re: intra-turn gate race — pre-existing best-effort contract, documented at line 1287.
…all gate Within the same turn, multiple message_end events share the same iteration counter. A slow async handler from an earlier message_end could resolve after a later one already set the gate, causing the wrong policy to apply. Added a monotonic hookMessageEndSeq counter that increments on every message_end. The .then() handler now checks both eventIteration (cross-turn) and eventSeq (intra-turn) before touching the gate. The .catch() handler also checks both before clearing.
…sts; harden fallback 1. before_response_emit now fires even when the last assistant message has no text content, as long as assistantTexts has entries. Policy plugins get their chance to inspect/block allContent from earlier tool-loop turns. 2. getRunScopedMessages returns empty (not full transcript) when the backward scan can't find enough assistants or assistantTextCount is 0. This prevents compaction edge cases from corrupting pre-run history.
Accidentally committed .openclaw-tank/memory/2026-03-05.md. Removed from tracking and added .openclaw-*/ to .gitignore to prevent future leaks.
…ength mismatch 1. before_response_emit merge semantics: content and allContent now use first-writer-wins (acc ?? next) instead of last-writer-wins (next ?? acc). Once a higher-priority security plugin sets redacted content, later utility plugins cannot override it. Matches the one-way latch on block. 2. rewriteAllAssistantContent logs a warning when allContent length differs from the assistant message count, making silent data loss visible in logs.
…_call Consistent with first-writer-wins on content/allContent in before_response_emit, the intersection latch on tools, and the one-way latch on block. Once a higher-priority security plugin sanitizes inputs, later utility plugins cannot override them.
…response_emit merge Once a higher-priority plugin sets either content or allContent, both fields are locked for later plugins. Prevents a lower-priority plugin from bypassing single-message redaction (content) by supplying allContent (which takes precedence in application), or vice versa.
…ted in after_llm_call
…afe) Removes index-based slicing via preRunMessageCount which can become stale when compaction replaces/merges transcript entries without changing the array length. Tail scan finds the last N assistant messages from the end, which is always correct regardless of compaction state.
…ction warning - Removed preRunMessageCount from ApplyBeforeResponseEmitParams, getRunScopedMessages signature, and all callers. The parameter was unused since switching to tail-based scanning. - Added warn-level log when block fires but getRunScopedMessages returns [] (compaction edge case where run boundaries are unidentifiable). Response delivery is still suppressed but blocked content may persist in session history.
…dMessages Tool-call-only assistant messages have content (array with tool_use blocks) but no text. assistantTextCount is derived from streamed text entries, so the tail scan must match that filter. Using extractAssistantText().length > 0 instead of 'content' in msg.
before_llm_call block now uses BeforeLlmCallBlockError sentinel class instead of generic Error. The agent loop catches this specifically and completes the run gracefully (no error surfaced) — consistent with before_response_emit block behavior. Previously, blocking threw a generic error that surfaced as a run failure.
Consistent with block (one-way latch) and content/messages (first-writer-wins): the higher-priority plugin that originally blocked also owns the reason. Prevents a lower-priority plugin from silently overwriting the block reason.
…s only Tool-call-only assistant messages (content arrays with only tool_use blocks, no text) are now skipped in rewriteAllAssistantContent, matching getRunScopedMessages and assistantTexts counting. Prevents allContent index misalignment when tool-call-only turns are interspersed with text-bearing turns.
…ges block - allContent splice now bounded to original assistantTexts.length — plugins cannot expand the response beyond what was produced. - Block with empty runMessages (compaction edge case) now fails closed by clearing ALL assistant content in the session, preventing blocked text from leaking into future turns or persistence.
clearAllAssistantContent now sets array content to [] instead of only clearing text parts. Prevents sensitive data in tool_use input arguments from persisting in session history when a response is blocked by before_response_emit.
…ors, guard gate clear
1. Test comment: 'h2 overrides' → clarifies h1 never blocked (not a latch scenario)
2. Cross-lock: added NOTE for plugin authors about silent drop behavior
3. Gate clear: guarded by hasHooks('after_llm_call') to avoid no-op on before_llm_call-only
… to PLUGIN_HOOK_NAMES Required for isPluginHookName validation and registerTypedHook. Same pattern as the context_assembled/loop_iteration fix.
a520ab2 to
68a78a0
Compare
zeroaltitude
pushed a commit
that referenced
this pull request
Apr 2, 2026
* feat: add QQ Bot channel extension * fix(qqbot): add setupWizard to runtime plugin for onboard re-entry * fix: fix review * fix: fix review * chore: sync lockfile and config-docs baseline for qqbot extension * refactor: 移除图床服务器相关代码 * fix * docs: 新增 QQ Bot 插件文档并修正链接路径 * refactor: remove credential backup functionality and update setup logic - Deleted the credential backup module to streamline the codebase. - Updated the setup surface to handle client secrets more robustly, allowing for configured secret inputs. - Simplified slash commands by removing unused hot upgrade compatibility checks and related functions. - Adjusted types to use SecretInput for client secrets in QQBot configuration. - Modified bundled plugin metadata to allow additional properties in the config schema. * feat: 添加本地媒体路径解析功能,修正 QQBot 媒体路径处理 * feat: 添加本地媒体路径解析功能,修正 QQBot 媒体路径处理 * feat: remove qqbot-media and qqbot-remind skills, add tests for config and setup - Deleted the qqbot-media and qqbot-remind skills documentation files. - Added unit tests for qqbot configuration and setup processes, ensuring proper handling of SecretRef-backed credentials and account configurations. - Implemented tests for local media path remapping, verifying correct resolution of media file paths. - Removed obsolete channel and remind tools, streamlining the codebase. * feat: 更新 QQBot 配置模式,添加音频格式和账户定义 * feat: 添加 QQBot 频道管理和定时提醒技能,更新媒体路径解析功能 * fix * feat: 添加 /bot-upgrade 指令以查看 QQBot 插件升级指引 * feat: update reminder and qq channel skills * feat: 更新remind工具投递目标地址格式 * feat: Refactor QQBot payload handling and improve code documentation - Simplified and clarified the structure of payload interfaces for Cron reminders and media messages. - Enhanced the parsing function to provide clearer error messages and improved validation. - Updated platform utility functions for better cross-platform compatibility and clearer documentation. - Improved text parsing utilities for better readability and consistency in emoji representation. - Optimized upload cache management with clearer comments and reduced redundancy. - Integrated QQBot plugin into the bundled channel plugins and updated metadata for installation. * OK apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift > openclaw@2026.3.26 check:bundled-channel-config-metadata /Users/yuehuali/code/PR/openclaw > node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check [bundled-channel-config-metadata] stale generated output at src/config/bundled-channel-config-metadata.generated.ts ELIFECYCLE Command failed with exit code 1. ELIFECYCLE Command failed with exit code 1. * feat: 添加 QQBot 渠道配置及相关账户设置 * fix(qqbot): resolve 14 high-priority bugs from PR openclaw#52986 review DM routing (7 fixes): - #1: DM slash-command replies use sendDmMessage(guildId) instead of sendC2CMessage(senderId) - #2: DM qualifiedTarget uses qqbot:dm:${guildId} instead of qqbot:c2c:${senderId} - #3: sendTextChunks adds DM branch - #4: sendMarkdownReply adds DM branch for text and Base64 images - #5: parseAndSendMediaTags maps DM to targetType:dm + guildId - #6: sendTextToTarget DM branch uses sendDmMessage; MessageTarget adds guildId field - #7: handleImage/Audio/Video/FilePayload add DM branches Other high-priority fixes: - #8: Fix sendC2CVoiceMessage/sendGroupVoiceMessage parameter misalignment - #9: broadcastMessage uses groupOpenid instead of member_openid for group users - #10: Unify KnownUser storage - proactive.ts delegates to known-users.ts - #11: Remove invalid recordKnownUser calls for guild/DM users - #12: sendGroupMessage uses sendAndNotify to trigger onMessageSent hook - #13: sendPhoto channel unsupported returns error field - #14: sendTextAfterMedia adds channel and dm branches Type fixes: - DeliverEventContext adds guildId field - MediaTargetContext.targetType adds dm variant - sendPlainTextReply imgMediaTarget adds DM branch * fix(qqbot): resolve 2 blockers + 7 medium-priority bugs from PR openclaw#52986 review Blocker-1: Remove unused dmPolicy config knob - dmPolicy was declared in schema/types/plugin.json but never consumed at runtime - Removed from config-schema.ts, types.ts, and openclaw.plugin.json - allowFrom remains active (already wired into framework command-auth) Blocker-2: Gate sensitive slash commands with allowFrom authorization - SlashCommand interface adds requireAuth?: boolean - SlashCommandContext adds commandAuthorized: boolean - /bot-logs set to requireAuth: true (reads local log files) - matchSlashCommand rejects unauthorized senders for requireAuth commands - trySlashCommandOrEnqueue computes commandAuthorized from allowFrom config Medium-priority fixes: - #15: Strip non-HTTP/non-local markdown image tags to prevent path leakage - #16: applyQQBotAccountConfig clears clientSecret when setting clientSecretFile and vice versa - #17: getAdminMarkerFile sanitizes accountId to prevent path traversal - #18: URGENT_COMMANDS uses exact match instead of startsWith prefix match - #19: isCronExpression validates each token starts with a cron-valid character - openclaw#20: --token format validation rejects malformed input without colon separator - openclaw#21: resolveDefaultQQBotAccountId checks QQBOT_APP_ID environment variable * test(qqbot): add focused tests for slash command authorization path - Unauthorized sender rejected for /bot-logs (requireAuth: true) - Authorized sender allowed for /bot-logs - Non-requireAuth commands (/bot-ping, /bot-help, /bot-version) work for all senders - Unknown slash commands return null (passthrough) - Non-slash messages return null - Usage query (/bot-logs ?) also gated by auth check * fix(qqbot): align global TTS fallback with framework config resolution - Extract isGlobalTTSAvailable to utils/audio-convert.ts, mirroring core resolveTtsConfig logic: check auto !== 'off', fall back to legacy enabled boolean, default to off when neither is set. - Add pre-check in reply-dispatcher before calling globalTextToSpeech to avoid unnecessary TTS calls and noisy error logs when TTS is not configured. - Remove inline as any casts; use OpenClawConfig type throughout. - Refactor handleAudioPayload into flat early-return structure with unified send path (plugin TTS → global fallback → send). * fix(qqbot): break ESM circular dependency causing multi-account startup crash The bundled gateway chunk had a circular static import on the channel chunk (gateway -> outbound-deliver -> channel, while channel dynamically imports gateway). When two accounts start concurrently via Promise.all, the first dynamic import triggers module graph evaluation; the circular reference causes api exports (including runDiagnostics) to resolve as undefined before the module finishes evaluating. Fix: extract chunkText and TEXT_CHUNK_LIMIT from channel.ts into a new text-utils.ts leaf module. outbound-deliver.ts now imports from text-utils.ts, breaking the cycle. channel.ts re-exports for backward compatibility. * fix(qqbot): serialize gateway module import to prevent multi-account startup race When multiple accounts start concurrently via Promise.all, each calls await import('./gateway.js') independently. Due to ESM circular dependencies in the bundled output, the first import can resolve transitive exports as undefined before module evaluation completes. Fix: cache the dynamic import promise in a module-level variable so all concurrent startAccount calls share the same import, ensuring the gateway module is fully evaluated before any account uses it. * refactor(qqbot): remove startup greeting logic Remove getStartupGreetingPlan and related startup greeting delivery: - Delete startup-greeting.ts (greeting plan, marker persistence) - Delete admin-resolver.ts (admin resolution, greeting dispatch) - Remove startup greeting calls from gateway READY/RESUMED handlers - Remove isFirstReadyGlobal flag and adminCtx * fix(qqbot): skip octal escape decoding for Windows local paths Windows paths like C:\Users\1\file.txt contain backslash-digit sequences that were incorrectly matched as octal escape sequences and decoded, corrupting the file path. Detect Windows local paths (drive letter or UNC prefix) and skip the octal decoding step for them. * fix bot issue * feat: 支持 TTS 自动开关并清理配置中的 clientSecretFile * docs: 添加 QQBot 配置和消息处理的设计说明 * rebase * fix(qqbot): align slash-command auth with shared command-auth model Route requireAuth:true slash commands (e.g. /bot-logs) through the framework's api.registerCommand() so resolveCommandAuthorization() applies commands.allowFrom.qqbot precedence and qqbot: prefix normalization before any handler runs. - slash-commands.ts: registerCommand() now auto-routes by requireAuth into two maps (commands / frameworkCommands); getFrameworkCommands() exports the auth-required set for framework registration; bot-help lists both maps - index.ts: registerFull() iterates getFrameworkCommands() and calls api.registerCommand() for each; handler derives msgType from ctx.from, sends file attachments via sendDocument, supports multi-account via ctx.accountId - gateway.ts (inbound): replace raw allowFrom string comparison with qqbotPlugin.config.formatAllowFrom() to strip qqbot: prefix and uppercase before matching event.senderId - gateway.ts (pre-dispatch): remove stale auth computation; commandAuthorized is true (requireAuth:true commands never reach matchSlashCommand) - command-auth.test.ts: add regression tests for qqbot: prefix normalization in the inbound commandAuthorized computation - slash-commands.test.ts: update /bot-logs tests to expect null (command routed to framework, not in local registry) * rebase and solve conflict * fix(qqbot): preserve mixed env setup credentials --------- Co-authored-by: yuehuali <yuehuali@tencent.com> Co-authored-by: walli <walli@tencent.com> Co-authored-by: WideLee <limkuan24@gmail.com> Co-authored-by: Frank Yang <frank.ekn@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Upstream: openclaw#33916
Part 3/3 of hooks split. See upstream PR for full description.
[claude, human developer oversight]