Skip to content

Commit 9d1e85a

Browse files
author
SqlRush
committed
Emit hook progress events
1 parent 823022a commit 9d1e85a

5 files changed

Lines changed: 81 additions & 15 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
| M10 Agents/tasks/worktree/remote | 已有 Task/TaskOutput/KillTask/SendMessage/TeamCreate/TeamDelete/TeamOutput/TeamSendMessage/TeamDispatch/TeamSchedule/TeamAutoSchedule/TeamCoordinate/ResumeTask/Sleep/Brief/ScheduleCron/RemoteTrigger 入口、sidechain metadata/lifecycle、task progress event、显式与 settings 默认 owned worktree 创建/清理、sparse/symlink settings 应用、`run:true` subagent nested tool loop、agent permission mode/allowlist 过滤,以及 session-scoped team/schedule/remote trigger manifest、daemon heartbeat CLI/state/status/stop/tick/start/restart 控制面审计和跨 session state discovery、remote service manifest 与 `/status show remote` discovery、remote registrationUrl/authToken 注册状态文件、remote poll URL/cursor 与 websocket_url 多帧/tick 消息泵、WebSocket 基础重连/backoff/连接计数审计、callback stream primitive 和 daemon 常驻托管接线、ScheduleCron manual trigger/run_due/turn-start due tick、team coordinator_task_id 元数据、TeamOutput coordinator status、TeamSendMessage target routing、TeamDispatch individualized assignments、TeamSchedule deterministic member assignments、TeamAutoSchedule coordinator briefing + member assignments、coordinator briefing、structured handoff brief、remote trigger injection/event_id dedupe、bridge direct `/remote-trigger`/`/remote-service` HTTP endpoint、WebSocket `remote_trigger`/`remote_status`/`hello`/`health`/`manifest` action 和 remote_trigger/remote_service/websocket_protocol manifest capability;完整 CCR 云端 WebSocket 协议 hardening、多 agent 后台调度循环和模型驱动团队自动调度仍未完成 |
3434
| 全量测试 | 当前 `go test ./...` 通过 |
3535

36+
M4 补充:tool executor 会围绕 `PreToolUse``PostToolUse``PermissionDenied` hook 发出 `hook_started``hook_completed``hook_failed``hook_blocked` 进度事件,携带 phase/tool/hook_index 以及阻断、错误、权限行为和 input 更新摘要;conversation runner 已通过现有 tool progress 通道透出这些事件。command-backed hook 执行、PermissionRequest hook 和 hook telemetry 仍未完成。
37+
3638
M10 补充:plugin command/agent 的 allowed tool frontmatter 解析现在只在顶层逗号或空白处分隔,保留括号、方括号和引号内的逗号/空白,避免 `Bash(git commit -m "x,y")` 这类 tool pattern 被误拆。
3739

3840
M10 补充:新增 `TaskOutput`/`AgentOutputTool``KillTask`/`TaskStop` 内置工具;`TaskOutput` 可列出当前 session 的 sidechain task,或按 task/sidechain ID 读取状态、summary、tail 输出和 agent metadata,`KillTask` 会通过 sidechain manager 写入 cancelled lifecycle summary。完整 AgentTool 执行循环、progress event streaming、resume command UI 和 worktree isolation 仍未完成。

docs/claude-code-go-rewrite-plan.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ test/parity/ # golden tests against TS/official behavior
141141
- hook/classifier/permission prompt 接入点。
142142
- sandbox adapter 接口。
143143

144+
当前进度:
145+
146+
- 本轮补充:tool executor 会围绕 `PreToolUse``PostToolUse``PermissionDenied` hook 发出 `hook_started``hook_completed``hook_failed``hook_blocked` 进度事件,携带 phase/tool/hook_index 以及阻断、错误、权限行为和 input 更新摘要;conversation runner 已通过现有 tool progress 通道透出这些事件。command-backed hook 执行、PermissionRequest hook 和 hook telemetry 仍未宣称完成。
147+
144148
验收:
145149

146150
- allow/deny/ask、path rule、MCP rule、agent rule、mode rule 有覆盖。

docs/first-second-parity-audit.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,14 @@ Anthropic API 和 conversation:
8585
- OAuth support now includes production OAuth config, scope parsing, Claude.ai scope detection, auth URL construction, PKCE verifier/challenge, state generation, and expiry checks.
8686
- Session/history support now includes CC-compatible prompt history references, pasted text/image placeholder parsing, paste-cache hashing and retrieval, `history.jsonl` append/load, current-session-first up-arrow ordering, ctrl+r-style deduped timestamped history, `CLAUDE_CODE_SKIP_PROMPT_HISTORY`, remote session event pagination helpers, lenient transcript loading, legacy progress parent-bridge recovery, compact-boundary pruning, snip removal/relink replay, metadata entry collection, leaf UUID calculation, conversation-chain reconstruction, orphaned parallel tool-result recovery, content-replacement record loading/reconstruction, tombstone metadata delete/relink replay, and tombstone-style transcript message removal with a size guard.
8787
- Anthropic API layer now covers streaming accumulation, usage update/accumulation semantics, non-streaming max token cap, thinking-budget adjustment, retry/backoff with `Retry-After` and `x-should-retry`, context-overflow `max_tokens` retry adjustment, beta-header dedupe, custom request headers, basic prompt cache breakpoint/cache-reference/cache-edits placement, prompt dump JSONL capture for init/new user messages/non-streaming responses/stream chunks, and CC-compatible USD cost calculation for known Claude models including cache read/write, web search requests, and Opus 4.6 fast-tier pricing.
88-
- Tool runtime now includes concurrency partitioning, ordered concurrent execution, interrupt behavior/defaults, max result size metadata, oversized result persistence, pre/post/permission-denied hook dispatch, hook-driven input updates/blocking, lifecycle progress events, and pre-call cancellation checks.
88+
- Tool runtime now includes concurrency partitioning, ordered concurrent execution, interrupt behavior/defaults, max result size metadata, oversized result persistence, pre/post/permission-denied hook dispatch, hook-driven input updates/blocking, executor hook phase progress events, lifecycle progress events, and pre-call cancellation checks.
8989
- Conversation runner can now use streaming clients, aggregate stream events into assistant messages, run tool calls through the orchestrator, preserve transcript append behavior, and apply CC-style per-message aggregate tool-result budget replacement before API requests with persisted replacement records for resume.
9090

9191
## Still Missing For 100% Compatibility
9292

9393
The following items remain incomplete and must not be treated as done:
9494

95-
- Full permission hook flow: `PreToolUse`, `PermissionRequest`, `PostToolUse`, blocking hook results, hook progress, hook telemetry.
95+
- Full permission hook flow gaps that remain: command-backed hook execution/runtime policy, `PermissionRequest` hook parity, richer hook telemetry, and full UI/SDK surfacing beyond the current executor hook phase progress events.
9696
- Auto mode / YOLO classifier: transcript construction, two-stage classifier, XML/tool-use parsing, prompt dump, denial circuit breaker, model gating, and fallback behavior.
9797
- Interactive permission prompt flow: REPL dialogs, bridge/channel/swarm permission relays, user feedback images, prompt race handling, cancellation.
9898
- Full filesystem permission parity gaps that remain: skill-scope allow suggestions, complete local/local-jsx slash-command execution wiring, command permission UI/SDK surfacing, forked/remote/MCP/plugin/bundled SkillTool behavior, skill prompt shell injection, complete auto-memory override policy, and deeper platform-specific Windows/WSL bypass handling.

internal/tool/executor.go

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (e Executor) Execute(ctx Context, use contracts.ToolUse, sink ProgressSink)
5757
if err := t.Validate(ctx, raw); err != nil {
5858
return ErrorResult(use, err), err
5959
}
60-
raw, err := e.runPreHooks(ctx, use, t, raw)
60+
raw, err := e.runPreHooks(ctx, use, t, raw, sink)
6161
if err != nil {
6262
return ErrorResult(use, err), err
6363
}
@@ -83,7 +83,7 @@ func (e Executor) Execute(ctx Context, use contracts.ToolUse, sink ProgressSink)
8383
"permission": decision,
8484
},
8585
}
86-
result = e.runPermissionDeniedHooks(ctx, use, t, raw, decision, result, permissionErr)
86+
result = e.runPermissionDeniedHooks(ctx, use, t, raw, decision, result, permissionErr, sink)
8787
_ = SendProgress(sink, use.ID, "permission_denied", map[string]any{"tool": t.Name(), "behavior": string(decision.Behavior)})
8888
return result, permissionErr
8989
}
@@ -97,7 +97,7 @@ func (e Executor) Execute(ctx Context, use contracts.ToolUse, sink ProgressSink)
9797
"permission": decision,
9898
},
9999
}
100-
result = e.runPermissionDeniedHooks(ctx, use, t, raw, decision, result, permissionErr)
100+
result = e.runPermissionDeniedHooks(ctx, use, t, raw, decision, result, permissionErr, sink)
101101
_ = SendProgress(sink, use.ID, "permission_denied", map[string]any{"tool": t.Name(), "behavior": string(decision.Behavior)})
102102
return result, permissionErr
103103
}
@@ -112,7 +112,7 @@ func (e Executor) Execute(ctx Context, use contracts.ToolUse, sink ProgressSink)
112112
if err == nil {
113113
result = e.limitResult(t, use, result)
114114
}
115-
result, hookErr := e.runPostHooks(ctx, use, t, raw, result, err)
115+
result, hookErr := e.runPostHooks(ctx, use, t, raw, result, err, sink)
116116
if hookErr != nil && err == nil {
117117
err = hookErr
118118
}
@@ -143,11 +143,13 @@ func (s defaultToolUseProgressSink) Send(progress contracts.ToolProgress) error
143143
return s.sink.Send(progress)
144144
}
145145

146-
func (e Executor) runPreHooks(ctx Context, use contracts.ToolUse, t Tool, raw json.RawMessage) (json.RawMessage, error) {
146+
func (e Executor) runPreHooks(ctx Context, use contracts.ToolUse, t Tool, raw json.RawMessage, sink ProgressSink) (json.RawMessage, error) {
147147
current := normalizeRawInput(raw)
148-
for _, hook := range e.Hooks {
148+
for idx, hook := range e.Hooks {
149+
_ = e.sendHookProgress(sink, use.ID, t, HookPreToolUse, idx, "hook_started", nil)
149150
result, err := hook.RunToolHook(ctx, HookEvent{Phase: HookPreToolUse, ToolUse: use, ToolName: t.Name(), Input: current})
150151
if err != nil {
152+
_ = e.sendHookProgress(sink, use.ID, t, HookPreToolUse, idx, "hook_failed", map[string]any{"error": err.Error()})
151153
return current, err
152154
}
153155
if len(result.UpdatedInput) > 0 {
@@ -157,20 +159,31 @@ func (e Executor) runPreHooks(ctx Context, use contracts.ToolUse, t Tool, raw js
157159
if result.Message == "" {
158160
result.Message = "blocked by PreToolUse hook"
159161
}
162+
_ = e.sendHookProgress(sink, use.ID, t, HookPreToolUse, idx, "hook_blocked", map[string]any{"message": result.Message})
160163
return current, HookBlockedError{Phase: HookPreToolUse, Message: result.Message, Metadata: result.Metadata}
161164
}
165+
data := map[string]any{}
166+
if len(result.UpdatedInput) > 0 {
167+
data["updated_input"] = true
168+
}
169+
if result.Message != "" {
170+
data["message"] = result.Message
171+
}
172+
_ = e.sendHookProgress(sink, use.ID, t, HookPreToolUse, idx, "hook_completed", data)
162173
}
163174
return current, nil
164175
}
165176

166-
func (e Executor) runPermissionDeniedHooks(ctx Context, use contracts.ToolUse, t Tool, raw json.RawMessage, decision contracts.PermissionDecision, result contracts.ToolResult, originalErr error) contracts.ToolResult {
167-
for _, hook := range e.Hooks {
177+
func (e Executor) runPermissionDeniedHooks(ctx Context, use contracts.ToolUse, t Tool, raw json.RawMessage, decision contracts.PermissionDecision, result contracts.ToolResult, originalErr error, sink ProgressSink) contracts.ToolResult {
178+
for idx, hook := range e.Hooks {
179+
_ = e.sendHookProgress(sink, use.ID, t, HookPermissionDenied, idx, "hook_started", map[string]any{"behavior": string(decision.Behavior)})
168180
hookResult, err := hook.RunToolHook(ctx, HookEvent{Phase: HookPermissionDenied, ToolUse: use, ToolName: t.Name(), Input: raw, Decision: &decision, Result: &result, Error: originalErr.Error()})
169181
if result.Meta == nil {
170182
result.Meta = map[string]any{}
171183
}
172184
if err != nil {
173185
result.Meta["permission_denied_hook_error"] = err.Error()
186+
_ = e.sendHookProgress(sink, use.ID, t, HookPermissionDenied, idx, "hook_failed", map[string]any{"behavior": string(decision.Behavior), "error": err.Error()})
174187
continue
175188
}
176189
if hookResult.Message != "" {
@@ -179,24 +192,32 @@ func (e Executor) runPermissionDeniedHooks(ctx Context, use contracts.ToolUse, t
179192
if len(hookResult.Metadata) > 0 {
180193
result.Meta["permission_denied_hook"] = hookResult.Metadata
181194
}
195+
data := map[string]any{"behavior": string(decision.Behavior)}
196+
if hookResult.Message != "" {
197+
data["message"] = hookResult.Message
198+
}
199+
_ = e.sendHookProgress(sink, use.ID, t, HookPermissionDenied, idx, "hook_completed", data)
182200
}
183201
return result
184202
}
185203

186-
func (e Executor) runPostHooks(ctx Context, use contracts.ToolUse, t Tool, raw json.RawMessage, result contracts.ToolResult, callErr error) (contracts.ToolResult, error) {
204+
func (e Executor) runPostHooks(ctx Context, use contracts.ToolUse, t Tool, raw json.RawMessage, result contracts.ToolResult, callErr error, sink ProgressSink) (contracts.ToolResult, error) {
187205
var errText string
188206
if callErr != nil {
189207
errText = callErr.Error()
190208
}
191-
for _, hook := range e.Hooks {
209+
for idx, hook := range e.Hooks {
210+
_ = e.sendHookProgress(sink, use.ID, t, HookPostToolUse, idx, "hook_started", nil)
192211
hookResult, err := hook.RunToolHook(ctx, HookEvent{Phase: HookPostToolUse, ToolUse: use, ToolName: t.Name(), Input: raw, Result: &result, Error: errText})
193212
if err != nil {
213+
_ = e.sendHookProgress(sink, use.ID, t, HookPostToolUse, idx, "hook_failed", map[string]any{"error": err.Error()})
194214
return result, err
195215
}
196216
if hookResult.Block {
197217
if hookResult.Message == "" {
198218
hookResult.Message = "blocked by PostToolUse hook"
199219
}
220+
_ = e.sendHookProgress(sink, use.ID, t, HookPostToolUse, idx, "hook_blocked", map[string]any{"message": hookResult.Message})
200221
return result, HookBlockedError{Phase: HookPostToolUse, Message: hookResult.Message, Metadata: hookResult.Metadata}
201222
}
202223
if len(hookResult.Metadata) > 0 {
@@ -205,10 +226,25 @@ func (e Executor) runPostHooks(ctx Context, use contracts.ToolUse, t Tool, raw j
205226
}
206227
result.Meta["post_tool_use_hook"] = hookResult.Metadata
207228
}
229+
data := map[string]any{}
230+
if hookResult.Message != "" {
231+
data["message"] = hookResult.Message
232+
}
233+
_ = e.sendHookProgress(sink, use.ID, t, HookPostToolUse, idx, "hook_completed", data)
208234
}
209235
return result, nil
210236
}
211237

238+
func (e Executor) sendHookProgress(sink ProgressSink, toolUseID contracts.ID, t Tool, phase string, index int, progressType string, data map[string]any) error {
239+
if data == nil {
240+
data = map[string]any{}
241+
}
242+
data["tool"] = t.Name()
243+
data["phase"] = phase
244+
data["hook_index"] = index
245+
return SendProgress(sink, toolUseID, progressType, data)
246+
}
247+
212248
type HookBlockedError struct {
213249
Phase string
214250
Message string

internal/tool/tool_test.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -391,17 +391,22 @@ func TestExecutorHooksCanUpdateInputAndEmitProgress(t *testing.T) {
391391
if result.Meta["post_tool_use_hook"].(map[string]any)["ok"] != true {
392392
t.Fatalf("meta = %#v", result.Meta)
393393
}
394-
if got := progressTypes(progress); strings.Join(got, ",") != "started,custom,completed" {
394+
wantProgress := "hook_started,hook_completed,hook_started,hook_completed,started,custom,hook_started,hook_completed,hook_started,hook_completed,completed"
395+
if got := progressTypes(progress); strings.Join(got, ",") != wantProgress {
395396
t.Fatalf("progress = %#v", got)
396397
}
397398
for _, item := range progress {
398399
if item.ToolUseID != "toolu_hook" {
399400
t.Fatalf("progress tool use id = %#v", progress)
400401
}
401402
}
403+
if progress[0].Data["phase"] != HookPreToolUse || progress[1].Data["updated_input"] != true || progress[6].Data["phase"] != HookPostToolUse {
404+
t.Fatalf("hook progress data = %#v", progress)
405+
}
402406
}
403407

404408
func TestExecutorPreHookCanBlock(t *testing.T) {
409+
var progress []contracts.ToolProgress
405410
registry, err := NewRegistry(FuncTool{
406411
DefinitionValue: contracts.ToolDefinition{Name: "Read", ReadOnly: true},
407412
CallFunc: func(ctx Context, raw json.RawMessage, sink ProgressSink) (contracts.ToolResult, error) {
@@ -417,11 +422,20 @@ func TestExecutorPreHookCanBlock(t *testing.T) {
417422
Hooks: []Hook{HookFunc(func(ctx Context, event HookEvent) (HookResult, error) {
418423
return HookResult{Block: true, Message: "blocked"}, nil
419424
})},
420-
}).Execute(Context{Context: context.Background()}, contracts.ToolUse{ID: "toolu_block", Name: "Read"}, nil)
425+
}).Execute(Context{Context: context.Background()}, contracts.ToolUse{ID: "toolu_block", Name: "Read"}, ProgressFunc(func(p contracts.ToolProgress) error {
426+
progress = append(progress, p)
427+
return nil
428+
}))
421429
var blocked HookBlockedError
422430
if !errors.As(err, &blocked) || blocked.Phase != HookPreToolUse {
423431
t.Fatalf("error = %#v", err)
424432
}
433+
if got := progressTypes(progress); strings.Join(got, ",") != "hook_started,hook_blocked" {
434+
t.Fatalf("progress = %#v", got)
435+
}
436+
if progress[1].Data["phase"] != HookPreToolUse || progress[1].Data["message"] != "blocked" {
437+
t.Fatalf("progress data = %#v", progress)
438+
}
425439
}
426440

427441
func TestExecutorReturnsPermissionError(t *testing.T) {
@@ -524,6 +538,7 @@ func TestEnginePermissionDeciderSurfacesSandboxOverride(t *testing.T) {
524538
func TestExecutorRunsPermissionDeniedHook(t *testing.T) {
525539
engine := permissions.NewEngine(contracts.PermissionContext{Mode: contracts.PermissionDontAsk})
526540
hookCalled := false
541+
var progress []contracts.ToolProgress
527542
registry, err := NewRegistry(FuncTool{
528543
DefinitionValue: contracts.ToolDefinition{Name: "Bash", Destructive: true},
529544
CallFunc: func(ctx Context, raw json.RawMessage, sink ProgressSink) (contracts.ToolResult, error) {
@@ -546,14 +561,23 @@ func TestExecutorRunsPermissionDeniedHook(t *testing.T) {
546561
}).Execute(Context{
547562
Context: context.Background(),
548563
Permissions: NewEnginePermissionDecider(engine),
549-
}, contracts.ToolUse{ID: "toolu_denied", Name: "Bash"}, nil)
564+
}, contracts.ToolUse{ID: "toolu_denied", Name: "Bash"}, ProgressFunc(func(p contracts.ToolProgress) error {
565+
progress = append(progress, p)
566+
return nil
567+
}))
550568
var permissionErr PermissionError
551569
if !errors.As(err, &permissionErr) {
552570
t.Fatalf("error = %v, want PermissionError", err)
553571
}
554572
if !hookCalled || result.Meta["permission_denied_hook_message"] != "logged" {
555573
t.Fatalf("hookCalled=%v result=%#v", hookCalled, result)
556574
}
575+
if got := progressTypes(progress); strings.Join(got, ",") != "hook_started,hook_completed,started,hook_started,hook_completed,permission_denied" {
576+
t.Fatalf("progress = %#v", got)
577+
}
578+
if progress[3].Data["phase"] != HookPermissionDenied || progress[4].Data["message"] != "logged" || progress[4].Data["behavior"] != string(contracts.PermissionDeny) {
579+
t.Fatalf("progress data = %#v", progress)
580+
}
557581
}
558582

559583
func TestExecutorHonorsCancelledContextBeforeCall(t *testing.T) {

0 commit comments

Comments
 (0)