Repository navigation
feat(hooks): let hooks ask the user to confirm - #119
Merged
openjiuwenai merged 2 commits intoOct 27, 2026
Merged
Conversation
Contributor
|
head_sha: 🤖 正在生成合并请求摘要,请稍候… |
Contributor
|
head_sha: 🤖 AI 代码检视正在进行中,请稍候… |
Contributor
|
head_sha: |
1 similar comment
Contributor
|
head_sha: |
Contributor
Miaoranmmm
force-pushed
the
feat/hook-ask-confirm
branch
from
October 25, 2026 10:56
49c47aa to
b21176c
Compare
Contributor
Miaoranmmm
force-pushed
the
feat/hook-ask-confirm
branch
from
October 25, 2026 16:44
b21176c to
525ad79
Compare
Contributor
Miaoranmmm
force-pushed
the
feat/hook-ask-confirm
branch
from
October 26, 2026 02:30
525ad79 to
e1f248d
Compare
Contributor
Contributor
Miaoranmmm
force-pushed
the
feat/hook-ask-confirm
branch
from
October 26, 2026 11:13
ffa5df2 to
0cbffcb
Compare
Contributor
Miaoranmmm
force-pushed
the
feat/hook-ask-confirm
branch
from
October 27, 2026 02:16
0cbffcb to
cd190a2
Compare
Contributor
A blocking before_tool_call or user_prompt_submit hook can now write
`{"action": "ask", "reason": ...}`. iCode pauses the operation and asks the
user: a TUI dialog, or a standard session/request_permission in ACP clients.
Continue runs the remaining hooks and then the usual approval; cancel,
timeout (the human approval timeout, none by default) or nobody to ask
rejects it with a reason. Waiting never counts toward the hook's own timeout.
One path for every entry: each tool call (main agent, sub-agents, workflow
nodes) and each input (new message, injected message, retry note) is a
HookOperation whose checked version is committed right before it runs, so an
answer applies only to the arguments or text it was given. A hook changing
the arguments after the user continued rechecks from the first hook, at most
three times. One confirmation service per engine owns the pending questions
and withdraws them when their operation ends (interrupt, withdrawal, turn or
session end). Requests and answers are recorded as hook_confirmation waits.
- The TUI question shows the hook, its source and reason, then each tool
argument (or the message) whole in a box under its name, as the approval
dialog does: those value boxes move to widgets/value_box.py and both
dialogs use them. Asked again, it lists what changed, cut around the
change.
- ACP: the title names what is asked about on one line (asked again: the
changed names and the first new value); the content shortens each value
and points to rawInput for the whole arguments.
- A refused tool call tells the model it did not run and whether calling it
again can help, so a timeout no longer leads to the same question again.
After-tool hooks see `hook_confirmation` (declined, timed_out, unavailable);
hooks see `confirmation_available` before they decide.
- The Hooks tab rows show the hook's name, an icon and the time; the event,
tool and outcome are in the run's details. A running row shows a still
dot, as a running background task does.
- Hook payloads use the canonical form of the effective arguments, so typed
keys reach the hook as the check compares them.
- ACP: a prompt or retry note the hooks refuse ends with stopReason
"refusal" and an "Input rejected: <reason>" message instead of an internal
error, so clients keep the session; a refused injection no longer fails the
turn it was sent into. `icode run` still exits with an error.
Miaoranmmm
force-pushed
the
feat/hook-ask-confirm
branch
from
October 27, 2026 06:58
cd190a2 to
75cb688
Compare
Contributor
…left The quiet check read "no process left" as "no CPU used". On a loaded machine a command's exit is reaped, and the rest of its output read, a while after its last process is gone; when that outlasted the quiet window, a command that had printed and exited was moved to the background as idle and reported as still running. This made test_a_command_busy_without_output_is_not_quiet flaky on macOS CI. A command with no process left now counts as ending, never as quiet: it ends on its own once its exit is reaped and its output read. A command whose processes cannot be listed no longer goes quiet either; it still moves to the background at its timeout.
Contributor
|
🤖 This pull request has been merged, and the target branch is up to date. |
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.
Paired: GitHub #119 ↔ GitCode !86
Summary
A blocking
before_tool_calloruser_prompt_submithook can now leave the decision to the user instead of allowing or rejecting the operation itself:{"action": "ask", "reason": "This changes the production configuration. Continue?"}iCode pauses the operation and asks. Continue runs the remaining hooks for the event, then everything goes on as usual (a tool call still needs approval where the approval settings require it). Cancel, no answer within the human approval timeout (none by default), or nobody to ask (
icode run, a workflow run from the command line, no frontend that can show it) rejects the operation with a reason. The time spent waiting never counts toward the hook'stimeout_seconds, and the hook holds nomax_parallel_hooksslot meanwhile. Hooks can readconfirmation_availablein their payload to decide what to do when nobody can be asked.What the user sees
name: old → new, a long value cut around the change. After three such rechecks, or when the arguments come back to an earlier version, the call is rejected.hook_confirmation:declined,timed_outorunavailable.?until the user answers; the details then sayuser continued,user cancelled,no answer in time, and so on, orcall rejectedfor the run whose argument change got a call rejected. A running row shows a still●, as a running background task does.session/request_permissionwith its owntoolCallId(hook-confirm-<id>) andallow_once/reject_once. When the backend stops waiting first, the server sends atool_call_updatewith statusfailed. The title names what is asked about on one line (asked again: the changed names and the first new value), for clients that show only the title; the content shortens each value and points torawInputfor the whole arguments. A prompt or retry note that the hooks refuse now ends withstopReason: "refusal"(reason andcodein_meta.chrys), preceded by oneInput rejected: <reason>message. Previously it was a JSON-RPC internal error, which PyCharm treats as fatal and answers by stopping the agent. Other ACP clients are unverified; PyCharm is the one checked by hand.How it works
One operation per entry. Every entry builds a
HookOperation(service/hooks/operation.py): main-agent, sub-agent and workflow-node tool calls, and new, injected and retry-note input. The operation holds the current version of the arguments or text, plus the version the hooks checked through to the end.check_operationis the one place a version is marked checked, with or without hooks configured. A commit at the last step takes the right to run that exact version:HookCommitMiddleware, innermost in every function chain;A change after the check, or an operation that ended, refuses the run.
Argument snapshots compare in the canonical form of effective arguments that execution stamps already use (
canonicalize_effective_arguments), and are deep copies. Arguments without such a form are refused where a hook would check them; elsewhere they run as before. Hook payloads are written with the same normalization (encode_json_like), so typed keys such as dates reach the hook as the check compares them.One confirmation service per engine (
service/hooks/confirmation.py), attached to everyHookManagerbySessionHookFactory. It publishesHookConfirmationRequest, and the first frontend to claim it answers withHookConfirmationResponse. The service owns the pending questions and their timeout, and withdraws a question (HookConfirmationWithdrawn) when its operation is invalidated. Close and cancellation drain throughOnceClose/finish_close. Requests are delivered by session-level subscription, never through the headless turn stream, so a new input's question reaches the frontend while that input is still being published.Lifetimes.
PendingInputOperationstracks input until it commits: a withdrawn injection, a user interrupt, a session or build change, and session shutdown each invalidate what they outlive. An injected message's question also ends with the run it targets.Audit. Each question is a
wait.*span with categoryhook_confirmationandresult/end_reasoncodes. Hook runs record theaskedeffect and the answer.ACP refusal.
Error.refused_input(prompt,retry_note,injection) is set explicitly by eachuser_prompt_submitgate. Insession_host, a refused prompt or retry note becomes a newRefusedoutcome, which ACP maps torefusalandicode runstill reports as an error. A refused injection refuses only that message: it no longer turns the turn it was sent into into an error.TUI.
HookConfirmationQueueControllerwithHookConfirmationDialog, aConfirmDialogwhose message holds the value boxes. The approval dialog's value boxes (ValueText,LongValueView) move unchanged towidgets/value_box.pyso both dialogs share them.ConfirmDialoggains amessage_alignoption, left for this dialog, and its default stays centered.Docs and i18n
askin brief,confirmation_available,hook_confirmation, what a Hooks tab row shows, and how a refusal looks in an ACP editor and inicode run.app/acp/doc/frontend-api.md: the permission request, re-asks, withdrawal and therefusalresponse.trajectory_wait_manifest.jsonrefreshed.Design decisions and limitations
askdoes nothing. Only blocking hooks apply decisions; anasyncorfire_and_forgetrun that writesaskis recorded, not acted on. Its payloadcarries
confirmation_available: falseand its run record saysnot_applied: nonblocking,so a script can tell before writing. No retroactive blocking, by design.
call; approval rules, modes and defaults are unchanged.
approval.timeout_seconds, anApply.RESTARTsetting, so an engine captures exactly thevalue the process runs with; non-positive waits indefinitely. If that setting ever becomes
reloadable, the service needs a provider instead of a captured value — a follow-up note,
not a defect here.
approval.reuse.canonical()is the strict JSONidentity approval reuse is keyed on;
canonicalize_effective_argumentsis what executionstamps and what this check compares, because it supports validated typed tool arguments.
Payloads use
encode_json_like, so what a hook reads is what the check compared. They arenot interchangeable and are not merged.
managed session. The TUI controller serves the app's questions, which include a workflow
run whose session id differs from the chat's, so it must not filter on the chat session.
cleanup when workflow shutdown fails before resource release starts. It does
not guarantee full lifecycle release after a workflow-drain failure.
Without a later successful close, the session lock and MCP cache may remain
held until process exit. That broader lifecycle gap predates this PR;
closing order, ownership and reentrancy need separate treatment.
Tests
Written from the design, independently of the implementation:
test_confirmation_service.py: answers, timeout, withdrawal, unclaimed requests, cancel-safe close;test_manager_ask.py: the decision chain, rechecks and their bound, run-log records, drains while asking;test_hook_commit.py: commit refusals, nested changes, typed arguments read by a real asking hook.Verified by hand in the TUI and in PyCharm over ACP: continue, cancel, timeout, re-ask, call rejected after rechecks, a sub-agent's tool call, a refused message followed by more chat in the same session, and arguments of about 12 000 and 80 000 characters.