Skip to content

feat(hooks): connect foreground hooks to the terminal#3129

Open
worktrunk-bot wants to merge 2 commits into
mainfrom
feat/issue-3093-interactive-foreground-hooks
Open

feat(hooks): connect foreground hooks to the terminal#3129
worktrunk-bot wants to merge 2 commits into
mainfrom
feat/issue-3093-interactive-foreground-hooks

Conversation

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Problem

Foreground hooks couldn't run interactively. Every foreground hook had the JSON context piped to its stdin, which stole the controlling terminal, so a hook like gum confirm 'trust this worktree?' && mise trust could never see a TTY. The issue asked for a way to gate a hook behind a confirmation — #3093.

Solution

Per the maintainer's call in the thread ("implement the same as we have with aliases, so a simple foreground hook gets connected to stdin"), foreground (pre-*) hooks now inherit the parent's stdin exactly as aliases already do. A foreground hook keeps the controlling terminal and can prompt before continuing:

[pre-start]
trust = "gum confirm 'trust this worktree?' && mise trust"

The lever is the one aliases already used: sourced_steps_to_foreground hard-coded pipe_stdin = true for hooks and false for aliases. With both sides now inheriting stdin in the single-step foreground path, the flag was uniformly false — so rather than leave it as a vestigial always-false field, the pipe_stdin field is removed and the single-step path simply never pipes.

The trade-off the maintainer flagged — giving up the JSON-on-stdin context — applies only to foreground hooks. The JSON context is unchanged for the paths that can't be interactive anyway: concurrent hook groups and background (post-*) detached hooks still receive it on stdin. Template variables ({{ branch }}, etc.) reach every hook regardless of form.

Scope is deliberately the pre-* half only; promoting post-* hooks out of the detached path is the separate request tracked in #3102.

Testing

  • Reworked test_pre_start_json_stdintest_pre_start_inherits_stdin: pipes a sentinel to wt switch's stdin and asserts a pre-start hook captures the raw bytes verbatim (and that the JSON context is not piped).
  • test_post_start_script_reads_json was misnamed — it configured a [pre-start] hook despite living among the post-start tests. Repointed it at [post-start] (with wait_for_file_content for the detached write) so it now genuinely covers the JSON-on-stdin path that survives.
  • test_post_start_json_stdin (unchanged) continues to assert background hooks receive the JSON context.
  • Full post_start_commands, user_hooks, step_alias, merge, and switch suites pass locally; docs regenerated via test_docs_are_in_sync. One unrelated failure (test_switch_pr_self_hosted_tea_authed_dispatches_to_gitea) is an environmental snapshot mismatch in the CI sandbox — the HTTP proxy returns 502 CONNECT tunnel failed where the snapshot expects Could not resolve host; it touches no hook code.

Docs

Updated the hook docs (## Interactive hooks and JSON context) and the hooks-vs-aliases stdin row in extending.md to describe the new split: pre-* inherit the terminal, post-* receive JSON.

Closes #3093

Foreground (`pre-*`) hooks now inherit the parent's stdin, exactly as
aliases already do, so an interactive child keeps the controlling
terminal — a hook can prompt before continuing (e.g. `gum confirm`
before `mise trust`). Previously every foreground hook had the JSON
context piped to its stdin, which stole the tty and made interactive
hooks impossible.

The lever is the one aliases already use: `sourced_steps_to_foreground`
hard-coded `pipe_stdin = true` for hooks and `false` for aliases. With
both sides now inheriting stdin in the single-step path, the flag was
uniformly false, so the `pipe_stdin` field is removed rather than left
vestigial.

The JSON context is unchanged for the paths that can't be interactive:
concurrent hook groups and background (`post-*`) detached hooks still
receive it on stdin. Template variables (`{{ }}`) reach every hook
regardless of form.

Closes #3093

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The core change is clean and the docs/tests match it. I traced the two paths the PR claims still receive the JSON context and both hold: concurrent groups build context_jsons and write them to each child's stdin (command_executor.rsoutput/concurrent.rs), and the detached post-* pipeline writes context_json in run_pipeline.rs. The foreground single-step path (run_one_command) is the only one that flips to inheriting stdin, which is exactly the pre-* half the issue asked for. The misnamed test_post_start_script_reads_json repoint (it was configured with [pre-start] while living among the post-start tests) is a real correctness improvement — it now exercises the JSON-on-stdin path that actually survives, with wait_for_file_content for the detached write.

One thing that looks unintended: this PR adds .claude/settings.local.json containing {"permissions":{"defaultMode":"bypassPermissions",...},"skipDangerousModePermissionPrompt":true}. That's a local agent-sandbox settings file (the .local.json suffix is the convention for per-machine, gitignore'd settings), and the content is unrelated to the foreground-hooks feature. On main this file is tracked but empty; this PR fills it with bypass-permission flags. Worth dropping it from the commit (and likely adding .claude/settings.local.json to .gitignore) so the feature PR stays scoped to the hook change.

This bypass-permissions agent-sandbox settings file was committed
unintentionally and is unrelated to the foreground-hooks change. Remove it
from tracking and gitignore it (per the .local.json per-machine convention).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Good catch on the stray .claude/settings.local.json. Confirmed and addressed in a9f7bc2: that bypass-permissions file was an agent-sandbox artifact unrelated to the foreground-hooks change. I removed it from tracking (git rm --cached) and added .claude/settings.local.json to .gitignore so future sessions don't re-stage it.

One small correction to the review note: on main the file isn't tracked at all (it's a brand-new file in this PR), rather than "tracked but empty" — but the disposition is the same. The feature PR is now scoped to just the hook change.

@max-sixty

Copy link
Copy Markdown
Owner

I'm really not sure about adding the complication around whether or not we pipe JSON in.

I think we should probably either remove it completely or not merge this — any votes either way?

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

My vote: merge as-is, with "remove JSON entirely" as the clean fallback and "don't merge" as the weakest option. Reasoning below — the key fact is that the JSON context carries no data the templates don't.

The split isn't a new axis. The JSON-vs-stdin distinction this PR introduces maps exactly onto the foreground/background split that already structures hooks: pre-* run in the foreground, post-* are detached background processes. JSON-on-stdin is physically what stole the tty — the prompt needs the tty on the stdin fd, the JSON needs that same fd. They can't coexist on one descriptor, so for pre-* you must pick one, and the thread already picked interactive. post-* are detached and can never be interactive, so JSON there is free. So the rule is "stdin carries the tty for hooks that can be interactive, JSON for the ones that can't" — which is the only coherent assignment, not really a second thing to remember.

If you want strict uniformity anyway, "remove it completely" is strictly better than "don't merge" — because the JSON context loses nothing. It's built from the same HashMap<String, String> that feeds template expansion (command_executor.rs:209 — "Build hook context as a HashMap for JSON serialization and template expansion"; context_json() at :46 just serializes it). Every value is a flat string already reachable as {{ branch }} etc. So dropping JSON-on-stdin removes a delivery mechanism, not any data — the only thing it costs is hooks that prefer json.load(sys.stdin) over passing {{ }} as args (quoting-safety and programmatic iteration being the real niche). That collapses the model to one rule ("hooks inherit stdin; use templates for context") and still ships the #3093 interactivity. "Don't merge" is the worst of the three: it abandons the requested feature to preserve the JSON path that templates already subsume.

So my ranking: merge as-is ≥ remove JSON entirely ≫ don't merge.

Caveats if you go the "remove entirely" route
  • It's a documented behavior break (hook.md § JSON context), so per the maturing-mode policy it'd want a deprecation note rather than a silent drop — though there's no config-format or CLI-flag change, only runtime stdin behavior.
  • post-* are detached with stdin(Stdio::null()) today; removing JSON just leaves them on null stdin, which is fine.
  • I can spin this variant up quickly if you'd prefer it — it's a smaller diff than this PR (delete the context_json plumbing + the post-* JSON test, swap the docs example to templates).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feature request: option to approve user hooks

2 participants