Skip to content

feat(hooks): let hooks ask the user to confirm - #119

Merged
openjiuwenai merged 2 commits into
openJiuwen-ai:mainfrom
Miaoranmmm:feat/hook-ask-confirm
Oct 27, 2026
Merged

openjiuwenai merged 2 commits into
openJiuwen-ai:mainfrom
Miaoranmmm:feat/hook-ask-confirm

Conversation

@Miaoranmmm

@Miaoranmmm Miaoranmmm commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Paired: GitHub #119 ↔ GitCode !86

Summary

A blocking before_tool_call or user_prompt_submit hook 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's timeout_seconds, and the hook holds no max_parallel_hooks slot meanwhile. Hooks can read confirmation_available in their payload to decide what to do when nobody can be asked.

What the user sees

  • TUI: a dialog naming the hook, whether it comes from the project or the global configuration, its reason and the tool, then each argument (or the user's message) whole in a box under its name, as the approval dialog shows a tool call; a long value scrolls inside its own box and the buttons stay on screen. Continue and Cancel. One question shows at a time. A question whose operation ended first closes by itself: the turn ended, the user interrupted or withdrew a queued message, or the session or build changed.
  • Asked again: when a later hook changes a tool call's arguments after the user continued, the same call is checked again from the first hook. The repeated question says so and lists each changed argument as 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.
  • A cancelled message stays in the input box, as a blocked message already did. A refused tool call tells the model it did not run and whether calling it again can help (declined: not unless the user asks; no answer: ask the user first; nobody to ask: it would be refused again), so a timeout no longer leads to the same question right away. After-tool hooks see hook_confirmation: declined, timed_out or unavailable.
  • Hooks tab: each row shows the number, an icon, the hook's name and its time; the event, tool, agent and outcome are in the run's details. An asking run shows ? until the user answers; the details then say user continued, user cancelled, no answer in time, and so on, or call rejected for the run whose argument change got a call rejected. A running row shows a still ●, as a running background task does.
  • ACP: the question is a standard session/request_permission with its own toolCallId (hook-confirm-<id>) and allow_once / reject_once. When the backend stops waiting first, the server sends a tool_call_update with status failed. 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 to rawInput for the whole arguments. A prompt or retry note that the hooks refuse now ends with stopReason: "refusal" (reason and code in _meta.chrys), preceded by one Input 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_operation is 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:

    • tool calls commit in HookCommitMiddleware, innermost in every function chain;
    • input commits where each entry submits.

    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 every HookManager by SessionHookFactory. It publishes HookConfirmationRequest, and the first frontend to claim it answers with HookConfirmationResponse. The service owns the pending questions and their timeout, and withdraws a question (HookConfirmationWithdrawn) when its operation is invalidated. Close and cancellation drain through OnceClose / 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. PendingInputOperations tracks 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 category hook_confirmation and result / end_reason codes. Hook runs record the asked effect and the answer.

  • ACP refusal. Error.refused_input (prompt, retry_note, injection) is set explicitly by each user_prompt_submit gate. In session_host, a refused prompt or retry note becomes a new Refused outcome, which ACP maps to refusal and icode run still 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. HookConfirmationQueueController with HookConfirmationDialog, a ConfirmDialog whose message holds the value boxes. The approval dialog's value boxes (ValueText, LongValueView) move unchanged to widgets/value_box.py so both dialogs share them. ConfirmDialog gains a message_align option, left for this dialog, and its default stays centered.

Docs and i18n

  • Hooks reference and guide (en, zh-Hans): an example of asking before an operation, the rules for ask in brief, confirmation_available, hook_confirmation, what a Hooks tab row shows, and how a refusal looks in an ACP editor and in icode run.
  • app/acp/doc/frontend-api.md: the permission request, re-asks, withdrawal and the refusal response.
  • 30 new strings, translated for zh-Hans; catalog oracle updated (2441). trajectory_wait_manifest.json refreshed.

Design decisions and limitations

  • A non-blocking hook's ask does nothing. Only blocking hooks apply decisions; an
    async or fire_and_forget run that writes ask is recorded, not acted on. Its payload
    carries confirmation_available: false and its run record says not_applied: nonblocking,
    so a script can tell before writing. No retroactive blocking, by design.
  • This is not tool approval. The two gates are independent and may both ask about one
    call; approval rules, modes and defaults are unchanged.
  • The confirmation timeout is the process's value. It comes from
    approval.timeout_seconds, an Apply.RESTART setting, so an engine captures exactly the
    value 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.
  • Two argument normalizations, on purpose. approval.reuse.canonical() is the strict JSON
    identity approval reuse is keyed on; canonicalize_effective_arguments is what execution
    stamps 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 are
    not interchangeable and are not merged.
  • The TUI queue and the ACP adapter differ in session scope. The ACP component serves one
    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.
  • Shutdown after a failed workflow drain. This PR adds confirmation-service
    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:

  • Service:
    • 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.
  • Engine and host:
    • main-agent, sub-agent and workflow-node tool calls, including a node's sub-agent;
    • new, injected and retry-note input; headless and unclaimed requests;
    • interrupt during an input's question;
    • refused prompts (blocked, declined, nobody to ask) followed by a normal prompt;
    • a refused injection leaving the turn's own outcome.
  • ACP: the permission request and its withdrawal; re-ask content; the refusal response and notice for prompt and retry; errors still mapped as before.
  • TUI: the question queue; the real dialog's answers, alignment, re-ask content, and long arguments and messages shown whole in boxes with the buttons on screen; panel rows and run details. The approval dialog's tests now use the shared value boxes.

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.

@openjiuwen-collaboration-bot

Copy link
Copy Markdown
Contributor

head_sha: 49c47aa045f7be1253efaa9ac35cf8d18b19b5cf

🤖 正在生成合并请求摘要,请稍候…

@openjiuwen-collaboration-bot

Copy link
Copy Markdown
Contributor

head_sha: 49c47aa045f7be1253efaa9ac35cf8d18b19b5cf

🤖 AI 代码检视正在进行中,请稍候…

@openjiuwen-collaboration-bot

Copy link
Copy Markdown
Contributor

head_sha: 49c47aa045f7be1253efaa9ac35cf8d18b19b5cf

⚠️ 本次变更过大(78 个可审文件、6108 行,预估需 6 轮分片审查),已超出 AI 代码评审的处理范围,本次跳过。建议拆分为更小的 PR 以获得有效评审。

1 similar comment
@openjiuwen-collaboration-bot

Copy link
Copy Markdown
Contributor

head_sha: 49c47aa045f7be1253efaa9ac35cf8d18b19b5cf

⚠️ 本次变更过大(78 个可审文件、6108 行,预估需 6 轮分片审查),已超出 AI 代码评审的处理范围,本次跳过。建议拆分为更小的 PR 以获得有效评审。

@openjiuwen-collaboration-bot

Copy link
Copy Markdown
Contributor

head_sha: 49c47aa045f7be1253efaa9ac35cf8d18b19b5cf

任务名称 结果 日志操作
静态检查 ✅SUCCESS 点此跳转
禁用词扫描 ✅SUCCESS 点此跳转
防投毒检查 ✅SUCCESS 点此跳转
开源合规检查 ✅SUCCESS 点此跳转
UT测试 ✅SUCCESS 点此跳转
ST测试 N/A N/A
build 编译包 N/A N/A
ruff codecheck ✅SUCCESS N/A
issue关联检查 N/A N/A
pr size检查 N/A N/A

@openjiuwen-collaboration-bot

Copy link
Copy Markdown
Contributor

head_sha: b21176c7825a1cc99905d0ac6af1fb03cf0b5f1e

任务名称 结果 日志操作
静态检查 ✅SUCCESS 点此跳转
禁用词扫描 ✅SUCCESS 点此跳转
防投毒检查 ✅SUCCESS 点此跳转
开源合规检查 ✅SUCCESS 点此跳转
UT测试 ✅SUCCESS 点此跳转
ST测试 N/A N/A
build 编译包 N/A N/A
ruff codecheck ✅SUCCESS N/A
issue关联检查 N/A N/A
pr size检查 N/A N/A

@openjiuwen-collaboration-bot

Copy link
Copy Markdown
Contributor

head_sha: 525ad794e0170670d50d699e9826ed7a7b90347d

任务名称 结果 日志操作
静态检查 ✅SUCCESS 点此跳转
禁用词扫描 ✅SUCCESS 点此跳转
防投毒检查 ✅SUCCESS 点此跳转
开源合规检查 ✅SUCCESS 点此跳转
UT测试 ✅SUCCESS 点此跳转
ST测试 N/A N/A
build 编译包 N/A N/A
ruff codecheck ✅SUCCESS N/A
issue关联检查 N/A N/A
pr size检查 N/A N/A

@openjiuwen-collaboration-bot

Copy link
Copy Markdown
Contributor

head_sha: e1f248daa2dd28083651cacba2af8d2a9fd6e3a8

任务名称 结果 日志操作
静态检查 ✅SUCCESS 点此跳转
禁用词扫描 ✅SUCCESS 点此跳转
防投毒检查 ✅SUCCESS 点此跳转
开源合规检查 ✅SUCCESS 点此跳转
UT测试 ✅SUCCESS 点此跳转
ST测试 N/A N/A
build 编译包 N/A N/A
ruff codecheck ✅SUCCESS N/A
issue关联检查 N/A N/A
pr size检查 N/A N/A

@openjiuwen-collaboration-bot

Copy link
Copy Markdown
Contributor

head_sha: ffa5df283b3fef27801ebe67db7308c80a83e138

任务名称 结果 日志操作
静态检查 ✅SUCCESS 点此跳转
禁用词扫描 ✅SUCCESS 点此跳转
防投毒检查 ✅SUCCESS 点此跳转
开源合规检查 ✅SUCCESS 点此跳转
UT测试 ✅SUCCESS 点此跳转
ST测试 N/A N/A
build 编译包 N/A N/A
ruff codecheck ✅SUCCESS N/A
issue关联检查 N/A N/A
pr size检查 N/A N/A

@openjiuwen-collaboration-bot

Copy link
Copy Markdown
Contributor

head_sha: 0cbffcbe381d7874916723a7c41d12802b4d218e

任务名称 结果 日志操作
静态检查 ✅SUCCESS 点此跳转
禁用词扫描 ✅SUCCESS 点此跳转
防投毒检查 ✅SUCCESS 点此跳转
开源合规检查 ✅SUCCESS 点此跳转
UT测试 ✅SUCCESS 点此跳转
ST测试 N/A N/A
build 编译包 N/A N/A
ruff codecheck ✅SUCCESS N/A
issue关联检查 N/A N/A
pr size检查 N/A N/A

@openjiuwen-collaboration-bot

Copy link
Copy Markdown
Contributor

head_sha: cd190a26e94e63615bee033b859c3c43976758a8

任务名称 结果 日志操作
静态检查 ✅SUCCESS 点此跳转
禁用词扫描 ✅SUCCESS 点此跳转
防投毒检查 ✅SUCCESS 点此跳转
开源合规检查 ✅SUCCESS 点此跳转
UT测试 ✅SUCCESS 点此跳转
ST测试 N/A N/A
build 编译包 N/A N/A
ruff codecheck ✅SUCCESS N/A
issue关联检查 N/A N/A
pr size检查 N/A N/A

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.
@openjiuwen-collaboration-bot

Copy link
Copy Markdown
Contributor

head_sha: 75cb688548131fdbd9b9e6c9518324e7a0f83b1c

任务名称 结果 日志操作
静态检查 ✅SUCCESS 点此跳转
禁用词扫描 ✅SUCCESS 点此跳转
防投毒检查 ✅SUCCESS 点此跳转
开源合规检查 ✅SUCCESS 点此跳转
UT测试 ✅SUCCESS 点此跳转
ST测试 N/A N/A
build 编译包 N/A N/A
ruff codecheck ✅SUCCESS N/A
issue关联检查 N/A N/A
pr size检查 N/A N/A

…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.
@openjiuwenai
openjiuwenai merged commit 8e6e428 into openJiuwen-ai:main Oct 27, 2026
18 checks passed
@openjiuwen-release-bot

Copy link
Copy Markdown
Contributor

🤖 This pull request has been merged, and the target branch is up to date.

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.

3 participants