Skip to content

feat(core): run Claude Code tool hooks - #52765

Closed
rudolf-blue wants to merge 5 commits into
anomalyco:devfrom
rudolf-blue:command-hooks
Closed

rudolf-blue wants to merge 5 commits into
anomalyco:devfrom
rudolf-blue:command-hooks

Conversation

@rudolf-blue

@rudolf-blue rudolf-blue commented Oct 2, 2026 •

Copy link
Copy Markdown

Issue for this PR

Refs #12472

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Runs the PreToolUse, PostToolUse and SessionStart command hooks from ~/.claude/settings.json and settings.local.json, next to the existing .claude skills compatibility.

It is an internal plugin on the existing tool execute.before / execute.after hooks. Each matching hook gets the Claude Code payload on stdin (session_id, cwd, hook_event_name, tool_name, tool_input, tool_use_id, plus tool_response after the call). Tool names are mapped to Claude Code's (shell -> Bash, edit -> Edit, ...) and tool_input gets file_path / old_string / new_string added beside the native keys, so existing matchers and scripts keep working.

  • PreToolUse exit 2 fails the call with stderr as the tool error.
  • PreToolUse exit 0 with JSON hookSpecificOutput.permissionDecision: deny fails the call with permissionDecisionReason, ask shows the normal permission prompt for the call even when rules would allow it (a rule that denies still wins). The older top-level decision: "block" form counts as deny. allow is ignored on purpose, so a hook can tighten permissions but not loosen them.
  • PostToolUse exit 2 appends stderr to the tool result. Exit 0 with JSON decision: "block" + reason, or hookSpecificOutput.additionalContext, appends that text the same way.
  • When several hooks match, a deny from any of them wins over an ask.
  • The ask reason is the permission request message. The TUI permission prompt now shows a request's message under its title (before, only the attention notification did), with a render test in test/cli/tui/input-scope.test.tsx.
  • SessionStart runs when a session is created (not for subagent sessions), with source: "startup" for the matcher. Its output, either hookSpecificOutput.additionalContext or plain stdout, is saved to the session as a synthetic message. A first request that starts before the hooks finish waits for them and gets the context directly.
  • Any other exit code, a timeout, or a hook that fails to start is logged and does not block the tool.

What this leaves out, on purpose:

  • Stop. There is no hook on v2 that can keep the loop going, which is [FEATURE]: add session.stopping plugin hook to allow re-entering the agent loop #16626.
  • Hooks from a project's .claude directory. They would run commands from a repo the user has not reviewed, and I did not want to add a trust prompt in this PR. Only the home directory settings are read.
  • SessionStart with source resume, clear or compact. Only new sessions run it.

How did you verify your code works?

  • Tests in test/config/claude-hooks.test.ts run real hook commands from a temp .claude directory: exit 2 blocks with stderr, the stdin payload matches, exit 0/1 and non-matching matchers let the call through, settings.local.json is read, project .claude hooks are ignored, PostToolUse feedback is appended, a JSON deny blocks with its reason, and a JSON ask goes through Permission.assert (the evaluate hook turns allow into ask, keeps deny, and leaves other requests alone; a reject with feedback fails the call with that feedback), and SessionStart output is saved to a new top-level session, skipped for subagents, and spliced into a first request that started before it was saved.
  • bun typecheck in packages/core is clean, and the repo pre-push bun run check passes.
  • The tests drive the plugin through a stub plugin host and a stub Permission service, the same way the other internal plugin tests do. I have not run it in a live session.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@nkoynov

nkoynov commented Oct 5, 2026

Copy link
Copy Markdown

Thanks for this. Here's what one real setup needs: both of our Claude Code hooks answer with JSON on exit 0, not with exit 2. One is a PreToolUse Bash hook that returns hookSpecificOutput.permissionDecision: "deny" with a permissionDecisionReason (it blocks pkill -f). The other is a SessionStart hook that returns hookSpecificOutput.additionalContext (it warns the agent that a cloud login has expired). If only exit codes are supported, the first hook lets the command through without any warning, and the second has no event to run on. Supporting JSON permissionDecision (deny/ask plus the reason) would make our PreToolUse hook work as it is, and SessionStart additionalContext could come in a later PR.

@thdxr
thdxr changed the base branch from v2 to dev October 10, 2026 19:27
rudolf-blue and others added 2 commits October 10, 2026 20:56
PreToolUse hooks can print permissionDecision deny or ask with a reason on
exit 0. Ask goes through the normal permission prompt. PostToolUse block
reasons and additionalContext are fed back like exit 2 stderr. Also moves
the ChildProcess import to effect/process after the rebase.
@rudolf-blue

Copy link
Copy Markdown
Author

Thanks, that is a useful case. Pushed support for it:

  • PreToolUse on exit 0 now reads hookSpecificOutput.permissionDecision. deny fails the call with permissionDecisionReason as the error, so your pkill -f hook works as written. ask puts up the normal permission prompt for that call. allow is ignored, so a hook can only make things stricter.
  • PostToolUse also reads decision: "block" + reason and additionalContext, since the parsing is the same.

SessionStart with additionalContext is left for a follow-up as you suggested.

Output from SessionStart hooks (plain stdout or additionalContext) is saved
to a new session. The first request waits for the hooks so it does not miss
the context.
@rudolf-blue

Copy link
Copy Markdown
Author

Added SessionStart as well, since it fit in the same plugin: when a session is created, matching hooks run with source: "startup" and their additionalContext (or plain stdout) is saved to the session. Your login-expired warning should work as is. resume/compact sources are not covered.

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants