Skip to content

[hooks] add non-streaming (non-stdin style) shell-only PostToolUse support#15531

Merged
eternal-openai merged 15 commits intomainfrom
eternal/hooks-posttooluse-bash
Mar 26, 2026
Merged

[hooks] add non-streaming (non-stdin style) shell-only PostToolUse support#15531
eternal-openai merged 15 commits intomainfrom
eternal/hooks-posttooluse-bash

Conversation

@eternal-openai
Copy link
Copy Markdown
Contributor

@eternal-openai eternal-openai commented Mar 23, 2026

CHAINED PR - note that base is eternal/hooks-pretooluse-bash, not main -- so the following PR should be first

Matching post-tool hook to the pre-tool functionality here: #15211

So, PreToolUse calls for plain shell calls, allows blocking. This PostToolUse call runs after the command executed

example run:

› as a test, run in parallel the following commands:
  - echo 'one'
  - echo '[block-pre-tool-use]'
  - echo '[block-post-tool-use]'


⚠ MCP startup incomplete (failed: notion, linear)

• Cruising through those three commands in parallel now, and I’ll share the exact outputs right after
  they land.

• Running PreToolUse hook: checking the observatory runes

• Running PreToolUse hook: checking the observatory runes

• Running PreToolUse hook: checking the observatory runes

PreToolUse hook (blocked)
  warning: wizard-tower PreToolUse demo blocked a Bash command on purpose.
  feedback: PreToolUse demo blocked the command. Remove [block-pre-tool-use] to continue.

PreToolUse hook (completed)
  warning: wizard-tower PreToolUse demo inspected Bash: echo 'one'

PreToolUse hook (completed)
  warning: wizard-tower PreToolUse demo inspected Bash: echo '[block-post-tool-use]'

• Ran echo '[block-post-tool-use]'
  └ [block-post-tool-use]

• Ran echo 'one'
  └ one

• Running PostToolUse hook: reading the observatory aftermath

• Running PostToolUse hook: reading the observatory aftermath

PostToolUse hook (blocked)
  warning: wizard-tower PostToolUse demo blocked the Bash follow-through on purpose.
  feedback: PostToolUse demo blocked the result after execution. Remove [block-post-tool-use] to
continue.

PostToolUse hook (completed)
  warning: wizard-tower PostToolUse demo inspected Bash: echo 'one'
  hook context: PostToolUse demo saw the Bash result. Response preview: one

──────────────────────────────────────────────────────────────────────────────────────────────────────

• Ran them in parallel. Little wave report:

  - echo 'one' completed and returned one.
  - echo '[block-pre-tool-use]' was blocked before execution by the PreToolUse hook.
  - echo '[block-post-tool-use]' ran, but its result was blocked afterward by the PostToolUse hook.

  The hook messages were:

  - PreToolUse demo blocked the command. Remove [block-pre-tool-use] to continue.
  - PostToolUse demo blocked the result after execution. Remove [block-post-tool-use] to continue.


› for the blocked messages (correctly blocked in this test) did the tool output land back into
  context?


• Yeah, from this run:

  - PreToolUse block: the command itself did not execute, so no command output landed in context. Only
    the block message did.
  - PostToolUse block: the command did execute, but the actual stdout did not come back into context.
    Only the post-tool block message did.

  So the blocked notifications landed in context, but the blocked command payloads themselves did not.
  Nice clean guardrail, aloha.
@eternal-openai eternal-openai changed the base branch from main to eternal/hooks-pretooluse-bash March 23, 2026 18:31
@eternal-openai eternal-openai force-pushed the eternal/hooks-posttooluse-bash branch 2 times, most recently from 21c16e1 to 266ecb6 Compare March 23, 2026 19:16
Base automatically changed from eternal/hooks-pretooluse-bash to main March 23, 2026 21:33
@eternal-openai eternal-openai force-pushed the eternal/hooks-posttooluse-bash branch from f2b1367 to 285b9f8 Compare March 23, 2026 22:02
tmchow added a commit to EveryInc/compound-engineering-plugin that referenced this pull request Mar 23, 2026
Codex has been incrementally shipping hook support (PreToolUse, PostToolUse,
UserPromptSubmit, SessionStart, Stop), but the Codex converter silently
dropped all hooks during conversion -- the only converter that didn't even
warn. This adds partial hook conversion for the 5 compatible events and
specific warnings for everything unconvertible.

- Add CodexHookEventName union, CodexHookCommand, CodexHookMatcher, CodexHooks
  types and hooks field to CodexBundle
- Add CODEX_EVENTS map with toolScoped flag, isBashCompatibleMatcher() helper,
  and convertHooksForCodex() to the converter
- Write .codex/hooks.json with backup support in the writer
- Auto-enable [features] codex_hooks = true in config.toml when hooks present
- Refactor renderCodexConfig() to options object for extensibility
- Update Codex spec with hooks documentation
- 18 new tests (12 converter + 6 writer)

PostToolUse conversion depends on openai/codex#15531 merging.
…h-fix

# Conflicts:
#	codex-rs/core/tests/suite/shell_snapshot.rs
#	codex-rs/exec/src/event_processor_with_human_output.rs
#	codex-rs/tui/tests/suite/model_availability_nux.rs
#	codex-rs/tui_app_server/tests/suite/model_availability_nux.rs
@eternal-openai eternal-openai marked this pull request as ready for review March 24, 2026 23:48
} else {
None
};
let hook_abort_error = dispatch_after_tool_use_hook(AfterToolUseHookDispatch {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are post tool use and after tool use different hooks?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this was introduced by @gt-oai in the old version. I'm maintaining support for now, but it'll get deprecated/removed later

.await;

if outcome.should_stop {
let replacement_text = outcome
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

both branches of this if look very similar

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixing

.unwrap_or_else(|| "PostToolUse hook stopped execution".to_string());
let mut guard = response_cell.lock().await;
if let Some(result) = guard.as_mut() {
result.result = Box::new(FunctionToolOutput::from_text(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if the tool is CustomTool and needs custom response.

  • Why are we highjacking the tool result?

Shouldn't the model still get the real tool result?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CustomTool support out of scope for this PR, I'm trying to just handle "Bash" (i.e. the name for shell-like stuff in the spec)

Killing the tool result is intentional, if the hook "blocks" the output, it basically has the opportunity to rewrite it with its own context


fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem;

fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what should this property contain for a notmal tool output?

Copy link
Copy Markdown
Contributor Author

@eternal-openai eternal-openai Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default it should be None. A tool only populates post_tool_use_response if it intentionally supports a stable PostToolUse payload shape

exec_command_pre_tool_use_payload(&invocation.tool_name, &invocation.payload)
}

fn post_tool_use_payload(&self, result: &AnyToolResult) -> Option<PostToolUsePayload> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AnyToolResult is what ToolResult is wrapped into at higher levels of execution to arase the concrete handler output type, it's a bit circular to pass it back in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I cleaned this up so handlers no longer receive the AnyToolResult just to shape the hook payload. They now get call_id, payload, and &dyn ToolOutput, which keeps the boundary better

Small note, the spec is pretty bad around tool_response (and tool_input): "...The exact schema for both depends on the tool." ... with no further elaboration :)

So basically we have to handle "any" and I've been reading how this works in the wild as the source of truth ¯_(ツ)_/¯

tool_name: &str,
payload: &ToolPayload,
) -> Option<PreToolUsePayload> {
if tool_name != "exec_command" {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be shared with normal arg parsing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, agreed, fixed

return None;
};

serde_json::from_str::<ShellCommandToolCallParams>(arguments)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is parse_arguments appropriate here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so, yes, fixed

Ok(output) => {
let post_tool_use_response =
crate::tools::format_exec_output_str(&output, turn.truncation_policy);
let content = emitter.finish(event_ctx, Ok(output)).await?;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can let content = emitter.finish(event_ctx, Err(error)).await?;
line be pulled out of the if and only post_tool_use_response calculated separately?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we use content directly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, simplified. emitter.finish now sits outside the branch, and only the post_tool_use_response calculation stays conditional


I kept that separate on purpose. content seems like more model-facing rendered shell text, which is more transcript/UI-oriented. tool_response for PostToolUse is meant to be the stable tool-specific result payload, so I didn’t want to couple the hook input to the transcript formatting

Err(err) => (err.to_string(), false),
};
emit_metric_for_tool_read(&invocation, success).await;
let post_tool_use_payload = if success {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for the future it would be nice to offload hook processing into it's own routine. non blocking

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, noted for a future followup

}
_ => None,
}
fn replace_result_with_feedback(result: &mut AnyToolResult, text: String) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not worth a helper.

}

fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
shell_pre_tool_use_payload(&invocation.payload)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we inline these methods?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

goes for all of them


let args = parse_arguments::<ExecCommandArgs>(arguments).ok()?;
if args.tty {
return None;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was just playing it safe for now, I didn't handle streaming/interactive shell usage in this PR, and that mirrors the PreToolUse one. Definitely needs a followup, but it doesn't fit cleanly into the spec, so I've been thinking about what to do about it (maybe a spec extension)

.map(|args| PreToolUsePayload { command: args.cmd })
}

fn exec_command_post_tool_use_payload(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inline?

@eternal-openai eternal-openai merged commit c4d9887 into main Mar 26, 2026
63 of 66 checks passed
@eternal-openai eternal-openai deleted the eternal/hooks-posttooluse-bash branch March 26, 2026 02:18
@github-actions github-actions bot locked and limited conversation to collaborators Mar 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

2 participants