detached 501b41b

Add tool confirmation lifecycle event

commit
501b41bb63361928051458d7d27e27f224520490
author
patriceckhart · · 2026-07-28 17:46 UTC
parent
1d11cc6
signature
unsigned
refs
tag: v0.3.18
7 files · +165 -20
M docs/extensions.md +10 -2
@@ -246,12 +246,18 @@ which it wants to intercept. Send once after `hello`, before `ready`.
246246
247247 ```json
248248 {"type":"subscribe",
249- "events":["session_start","turn_start","tool_call","turn_end","assistant_message"],
249+ "events":["session_start","turn_start","tool_call","tool_confirmation_requested","turn_end","assistant_message"],
250250 "intercept":["tool_call","turn_start","assistant_message"]}
251251 ```
252252
253253 Recognised event names: `session_start`, `turn_start`, `turn_end`,
254-`tool_call`, `assistant_message`.
254+`tool_call`, `tool_confirmation_requested`, `assistant_message`.
255+
256+`tool_confirmation_requested` fires only when zot is about to wait for
257+interactive approval. Calls running in yolo mode, calls covered by a
258+remembered approval, and calls blocked before confirmation do not emit it.
259+The event includes `tool_id`, `tool_name`, and the short `tool_preview`
260+shown in the confirmation dialog.
255261
256262 Interceptable events:
257263
@@ -448,6 +454,8 @@ Lifecycle notification for events the extension subscribed to via
448454 {"type":"event","event":"turn_start","step":1}
449455 {"type":"event","event":"tool_call",
450456 "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}}
457+{"type":"event","event":"tool_confirmation_requested",
458+ "tool_id":"...","tool_name":"read","tool_preview":"foo.go"}
451459 {"type":"event","event":"turn_end","stop":"end_turn"}
452460 ```
453461
@@ -246,12 +246,18 @@ which it wants to intercept. Send once after `hello`, before `ready`.
246 246
247 ```json247 ```json
248 {"type":"subscribe",248 {"type":"subscribe",
249- "events":["session_start","turn_start","tool_call","turn_end","assistant_message"],249+ "events":["session_start","turn_start","tool_call","tool_confirmation_requested","turn_end","assistant_message"],
250 "intercept":["tool_call","turn_start","assistant_message"]}250 "intercept":["tool_call","turn_start","assistant_message"]}
251 ```251 ```
252 252
253 Recognised event names: `session_start`, `turn_start`, `turn_end`,253 Recognised event names: `session_start`, `turn_start`, `turn_end`,
254-`tool_call`, `assistant_message`.254+`tool_call`, `tool_confirmation_requested`, `assistant_message`.
255+
256+`tool_confirmation_requested` fires only when zot is about to wait for
257+interactive approval. Calls running in yolo mode, calls covered by a
258+remembered approval, and calls blocked before confirmation do not emit it.
259+The event includes `tool_id`, `tool_name`, and the short `tool_preview`
260+shown in the confirmation dialog.
255 261
256 Interceptable events:262 Interceptable events:
257 263
@@ -448,6 +454,8 @@ Lifecycle notification for events the extension subscribed to via
448 {"type":"event","event":"turn_start","step":1}454 {"type":"event","event":"turn_start","step":1}
449 {"type":"event","event":"tool_call",455 {"type":"event","event":"tool_call",
450 "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}}456 "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}}
457+{"type":"event","event":"tool_confirmation_requested",
458+ "tool_id":"...","tool_name":"read","tool_preview":"foo.go"}
451 {"type":"event","event":"turn_end","stop":"end_turn"}459 {"type":"event","event":"turn_end","stop":"end_turn"}
452 ```460 ```
453 461
M packages/agent/cli.go +4 -1
@@ -1132,7 +1132,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11321132 // (the BeforeToolExecute closure captures it). SetConfirmer
11331133 // is mutex-guarded on the gate so this is safe.
11341134 if confirmGate != nil {
1135- confirmGate.SetConfirmer(iv)
1135+ confirmGate.SetConfirmer(&confirmationEventConfirmer{
1136+ inner: iv,
1137+ emit: extMgr.EmitEvent,
1138+ })
11361139 }
11371140
11381141 // Signal-driven flush: a SIGTERM / SIGHUP to the zot process
@@ -1132,7 +1132,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1132 // (the BeforeToolExecute closure captures it). SetConfirmer1132 // (the BeforeToolExecute closure captures it). SetConfirmer
1133 // is mutex-guarded on the gate so this is safe.1133 // is mutex-guarded on the gate so this is safe.
1134 if confirmGate != nil {1134 if confirmGate != nil {
1135- confirmGate.SetConfirmer(iv)1135+ confirmGate.SetConfirmer(&confirmationEventConfirmer{
1136+ inner: iv,
1137+ emit: extMgr.EmitEvent,
1138+ })
1136 }1139 }
1137 1140
1138 // Signal-driven flush: a SIGTERM / SIGHUP to the zot process1141 // Signal-driven flush: a SIGTERM / SIGHUP to the zot process
A packages/agent/confirmation_events.go +40 -0
@@ -0,0 +1,40 @@
1+package agent
2+
3+import (
4+ "github.com/patriceckhart/zot/packages/agent/extproto"
5+ "github.com/patriceckhart/zot/packages/core"
6+)
7+
8+// confirmationEventConfirmer decorates an interactive confirmer with an
9+// extension lifecycle event. ConfirmGate only invokes this decorator when a
10+// call actually needs user input, after remembered approvals have been checked.
11+type confirmationEventConfirmer struct {
12+ inner core.Confirmer
13+ emit func(extproto.EventFromHost)
14+}
15+
16+func (c *confirmationEventConfirmer) Confirm(toolName, preview string) core.ConfirmDecision {
17+ call := core.ToolCallConfirmation{Name: toolName, Summary: preview}
18+ c.emitRequest(call)
19+ return c.inner.Confirm(toolName, preview)
20+}
21+
22+func (c *confirmationEventConfirmer) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
23+ c.emitRequest(call)
24+ if detailed, ok := c.inner.(core.ToolCallConfirmer); ok {
25+ return detailed.ConfirmToolCall(call)
26+ }
27+ return c.inner.Confirm(call.Name, call.Summary)
28+}
29+
30+func (c *confirmationEventConfirmer) emitRequest(call core.ToolCallConfirmation) {
31+ if c.emit == nil {
32+ return
33+ }
34+ c.emit(extproto.EventFromHost{
35+ Event: "tool_confirmation_requested",
36+ ToolID: call.ID,
37+ ToolName: call.Name,
38+ ToolPreview: call.Summary,
39+ })
40+}
@@ -0,0 +1,40 @@
1+package agent
2+
3+import (
4+ "github.com/patriceckhart/zot/packages/agent/extproto"
5+ "github.com/patriceckhart/zot/packages/core"
6+)
7+
8+// confirmationEventConfirmer decorates an interactive confirmer with an
9+// extension lifecycle event. ConfirmGate only invokes this decorator when a
10+// call actually needs user input, after remembered approvals have been checked.
11+type confirmationEventConfirmer struct {
12+ inner core.Confirmer
13+ emit func(extproto.EventFromHost)
14+}
15+
16+func (c *confirmationEventConfirmer) Confirm(toolName, preview string) core.ConfirmDecision {
17+ call := core.ToolCallConfirmation{Name: toolName, Summary: preview}
18+ c.emitRequest(call)
19+ return c.inner.Confirm(toolName, preview)
20+}
21+
22+func (c *confirmationEventConfirmer) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
23+ c.emitRequest(call)
24+ if detailed, ok := c.inner.(core.ToolCallConfirmer); ok {
25+ return detailed.ConfirmToolCall(call)
26+ }
27+ return c.inner.Confirm(call.Name, call.Summary)
28+}
29+
30+func (c *confirmationEventConfirmer) emitRequest(call core.ToolCallConfirmation) {
31+ if c.emit == nil {
32+ return
33+ }
34+ c.emit(extproto.EventFromHost{
35+ Event: "tool_confirmation_requested",
36+ ToolID: call.ID,
37+ ToolName: call.Name,
38+ ToolPreview: call.Summary,
39+ })
40+}
A packages/agent/confirmation_events_test.go +63 -0
@@ -0,0 +1,63 @@
1+package agent
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/agent/extproto"
8+ "github.com/patriceckhart/zot/packages/core"
9+)
10+
11+type rememberingDetailedConfirmer struct {
12+ calls []core.ToolCallConfirmation
13+}
14+
15+func (c *rememberingDetailedConfirmer) Confirm(string, string) core.ConfirmDecision {
16+ panic("Confirm called instead of ConfirmToolCall")
17+}
18+
19+func (c *rememberingDetailedConfirmer) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
20+ c.calls = append(c.calls, call)
21+ return core.ConfirmDecision{Allow: true, RememberTool: true}
22+}
23+
24+func TestConfirmationEventConfirmerEmitsOnlyWhenPrompting(t *testing.T) {
25+ inner := &rememberingDetailedConfirmer{}
26+ var events []extproto.EventFromHost
27+ notifier := &confirmationEventConfirmer{
28+ inner: inner,
29+ emit: func(event extproto.EventFromHost) {
30+ events = append(events, event)
31+ },
32+ }
33+ gate := core.NewConfirmGate(notifier)
34+ call := core.ToolCallConfirmation{
35+ ID: "call-1",
36+ Name: "edit",
37+ Summary: "main.go",
38+ Content: "-old\n+new\n",
39+ }
40+
41+ if allowed, _, _ := gate.CheckToolCall(call); !allowed {
42+ t.Fatal("first call should be allowed")
43+ }
44+ if allowed, _, _ := gate.CheckToolCall(core.ToolCallConfirmation{Name: "edit", Summary: "other.go"}); !allowed {
45+ t.Fatal("remembered tool should be allowed")
46+ }
47+
48+ if len(events) != 1 {
49+ t.Fatalf("events = %d, want 1", len(events))
50+ }
51+ want := extproto.EventFromHost{
52+ Event: "tool_confirmation_requested",
53+ ToolID: "call-1",
54+ ToolName: "edit",
55+ ToolPreview: "main.go",
56+ }
57+ if !reflect.DeepEqual(events[0], want) {
58+ t.Fatalf("event = %+v, want %+v", events[0], want)
59+ }
60+ if len(inner.calls) != 1 || inner.calls[0] != call {
61+ t.Fatalf("inner calls = %+v, want [%+v]", inner.calls, call)
62+ }
63+}
@@ -0,0 +1,63 @@
1+package agent
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/agent/extproto"
8+ "github.com/patriceckhart/zot/packages/core"
9+)
10+
11+type rememberingDetailedConfirmer struct {
12+ calls []core.ToolCallConfirmation
13+}
14+
15+func (c *rememberingDetailedConfirmer) Confirm(string, string) core.ConfirmDecision {
16+ panic("Confirm called instead of ConfirmToolCall")
17+}
18+
19+func (c *rememberingDetailedConfirmer) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
20+ c.calls = append(c.calls, call)
21+ return core.ConfirmDecision{Allow: true, RememberTool: true}
22+}
23+
24+func TestConfirmationEventConfirmerEmitsOnlyWhenPrompting(t *testing.T) {
25+ inner := &rememberingDetailedConfirmer{}
26+ var events []extproto.EventFromHost
27+ notifier := &confirmationEventConfirmer{
28+ inner: inner,
29+ emit: func(event extproto.EventFromHost) {
30+ events = append(events, event)
31+ },
32+ }
33+ gate := core.NewConfirmGate(notifier)
34+ call := core.ToolCallConfirmation{
35+ ID: "call-1",
36+ Name: "edit",
37+ Summary: "main.go",
38+ Content: "-old\n+new\n",
39+ }
40+
41+ if allowed, _, _ := gate.CheckToolCall(call); !allowed {
42+ t.Fatal("first call should be allowed")
43+ }
44+ if allowed, _, _ := gate.CheckToolCall(core.ToolCallConfirmation{Name: "edit", Summary: "other.go"}); !allowed {
45+ t.Fatal("remembered tool should be allowed")
46+ }
47+
48+ if len(events) != 1 {
49+ t.Fatalf("events = %d, want 1", len(events))
50+ }
51+ want := extproto.EventFromHost{
52+ Event: "tool_confirmation_requested",
53+ ToolID: "call-1",
54+ ToolName: "edit",
55+ ToolPreview: "main.go",
56+ }
57+ if !reflect.DeepEqual(events[0], want) {
58+ t.Fatalf("event = %+v, want %+v", events[0], want)
59+ }
60+ if len(inner.calls) != 1 || inner.calls[0] != call {
61+ t.Fatalf("inner calls = %+v, want [%+v]", inner.calls, call)
62+ }
63+}
M packages/agent/ext/ext.go +10 -8
@@ -68,9 +68,10 @@ type EventHandler func(ev Event)
6868 //
6969 // session_start : (no extra fields)
7070 // turn_start : Step
71-// turn_end : Stop, optional Error
72-// tool_call : ToolID, ToolName, ToolArgs
73-// assistant_message: Text
71+// turn_end : Stop, optional Error
72+// tool_call : ToolID, ToolName, ToolArgs
73+// tool_confirmation_requested: ToolID, ToolName, ToolPreview
74+// assistant_message : Text
7475 type Event struct {
7576 Name string
7677
@@ -78,9 +79,10 @@ type Event struct {
7879 Stop string
7980 Error string
8081
81- ToolID string
82- ToolName string
83- ToolArgs json.RawMessage
82+ ToolID string
83+ ToolName string
84+ ToolArgs json.RawMessage
85+ ToolPreview string
8486
8587 Text string
8688 }
@@ -410,7 +412,7 @@ func (e *Extension) registerTool(name, description string, schema json.RawMessag
410412 // notification; the same name can only have one handler (later
411413 // registrations replace earlier ones). Recognised names:
412414 // session_start, turn_start, turn_end, tool_call,
413-// assistant_message.
415+// tool_confirmation_requested, assistant_message.
414416 func (e *Extension) On(name string, fn EventHandler) {
415417 e.mu.Lock()
416418 if _, exists := e.eventHandlers[name]; !exists {
@@ -642,7 +644,7 @@ func (e *Extension) Run() error {
642644 handler(Event{
643645 Name: ef.Event, Step: ef.Step, Stop: ef.Stop,
644646 Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName,
645- ToolArgs: ef.ToolArgs, Text: ef.Text,
647+ ToolArgs: ef.ToolArgs, ToolPreview: ef.ToolPreview, Text: ef.Text,
646648 })
647649 }()
648650 }
@@ -68,9 +68,10 @@ type EventHandler func(ev Event)
68 //68 //
69 // session_start : (no extra fields)69 // session_start : (no extra fields)
70 // turn_start : Step70 // turn_start : Step
71-// turn_end : Stop, optional Error71+// turn_end : Stop, optional Error
72-// tool_call : ToolID, ToolName, ToolArgs72+// tool_call : ToolID, ToolName, ToolArgs
73-// assistant_message: Text73+// tool_confirmation_requested: ToolID, ToolName, ToolPreview
74+// assistant_message : Text
74 type Event struct {75 type Event struct {
75 Name string76 Name string
76 77
@@ -78,9 +79,10 @@ type Event struct {
78 Stop string79 Stop string
79 Error string80 Error string
80 81
81- ToolID string82+ ToolID string
82- ToolName string83+ ToolName string
83- ToolArgs json.RawMessage84+ ToolArgs json.RawMessage
85+ ToolPreview string
84 86
85 Text string87 Text string
86 }88 }
@@ -410,7 +412,7 @@ func (e *Extension) registerTool(name, description string, schema json.RawMessag
410 // notification; the same name can only have one handler (later412 // notification; the same name can only have one handler (later
411 // registrations replace earlier ones). Recognised names:413 // registrations replace earlier ones). Recognised names:
412 // session_start, turn_start, turn_end, tool_call,414 // session_start, turn_start, turn_end, tool_call,
413-// assistant_message.415+// tool_confirmation_requested, assistant_message.
414 func (e *Extension) On(name string, fn EventHandler) {416 func (e *Extension) On(name string, fn EventHandler) {
415 e.mu.Lock()417 e.mu.Lock()
416 if _, exists := e.eventHandlers[name]; !exists {418 if _, exists := e.eventHandlers[name]; !exists {
@@ -642,7 +644,7 @@ func (e *Extension) Run() error {
642 handler(Event{644 handler(Event{
643 Name: ef.Event, Step: ef.Step, Stop: ef.Stop,645 Name: ef.Event, Step: ef.Step, Stop: ef.Stop,
644 Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName,646 Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName,
645- ToolArgs: ef.ToolArgs, Text: ef.Text,647+ ToolArgs: ef.ToolArgs, ToolPreview: ef.ToolPreview, Text: ef.Text,
646 })648 })
647 }()649 }()
648 }650 }
M packages/agent/ext/ext_test.go +28 -0
@@ -220,6 +220,34 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
220220 h.hostW.Close()
221221 }
222222
223+func TestToolConfirmationRequestedEvent(t *testing.T) {
224+ h := newHarness("confirmation-events")
225+ received := make(chan Event, 1)
226+ h.ext.On("tool_confirmation_requested", func(event Event) {
227+ received <- event
228+ })
229+
230+ go h.ext.Run()
231+ h.handshake(t)
232+ h.sendToExt(t, extproto.EventFromHost{
233+ Type: "event",
234+ Event: "tool_confirmation_requested",
235+ ToolID: "call-1",
236+ ToolName: "bash",
237+ ToolPreview: "go test ./...",
238+ })
239+
240+ select {
241+ case event := <-received:
242+ if event.Name != "tool_confirmation_requested" || event.ToolID != "call-1" || event.ToolName != "bash" || event.ToolPreview != "go test ./..." {
243+ t.Fatalf("event = %+v", event)
244+ }
245+ case <-time.After(2 * time.Second):
246+ t.Fatal("timeout waiting for confirmation event")
247+ }
248+ h.hostW.Close()
249+}
250+
223251 func TestDeferredToolRegistrationAndActivation(t *testing.T) {
224252 h := newHarness("deferred-ext")
225253 h.ext.DeferredTool("weather", "weather lookup", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
@@ -220,6 +220,34 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
220 h.hostW.Close()220 h.hostW.Close()
221 }221 }
222 222
223+func TestToolConfirmationRequestedEvent(t *testing.T) {
224+ h := newHarness("confirmation-events")
225+ received := make(chan Event, 1)
226+ h.ext.On("tool_confirmation_requested", func(event Event) {
227+ received <- event
228+ })
229+
230+ go h.ext.Run()
231+ h.handshake(t)
232+ h.sendToExt(t, extproto.EventFromHost{
233+ Type: "event",
234+ Event: "tool_confirmation_requested",
235+ ToolID: "call-1",
236+ ToolName: "bash",
237+ ToolPreview: "go test ./...",
238+ })
239+
240+ select {
241+ case event := <-received:
242+ if event.Name != "tool_confirmation_requested" || event.ToolID != "call-1" || event.ToolName != "bash" || event.ToolPreview != "go test ./..." {
243+ t.Fatalf("event = %+v", event)
244+ }
245+ case <-time.After(2 * time.Second):
246+ t.Fatal("timeout waiting for confirmation event")
247+ }
248+ h.hostW.Close()
249+}
250+
223 func TestDeferredToolRegistrationAndActivation(t *testing.T) {251 func TestDeferredToolRegistrationAndActivation(t *testing.T) {
224 h := newHarness("deferred-ext")252 h := newHarness("deferred-ext")
225 h.ext.DeferredTool("weather", "weather lookup", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {253 h.ext.DeferredTool("weather", "weather lookup", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
M packages/agent/extproto/extproto.go +10 -9
@@ -183,15 +183,16 @@ type ToolCallFromHost struct {
183183 }
184184
185185 type EventFromHost struct {
186- Type string `json:"type"`
187- Event string `json:"event"`
188- Step int `json:"step,omitempty"`
189- Stop string `json:"stop,omitempty"`
190- Error string `json:"error,omitempty"`
191- ToolID string `json:"tool_id,omitempty"`
192- ToolName string `json:"tool_name,omitempty"`
193- ToolArgs json.RawMessage `json:"tool_args,omitempty"`
194- Text string `json:"text,omitempty"`
186+ Type string `json:"type"`
187+ Event string `json:"event"`
188+ Step int `json:"step,omitempty"`
189+ Stop string `json:"stop,omitempty"`
190+ Error string `json:"error,omitempty"`
191+ ToolID string `json:"tool_id,omitempty"`
192+ ToolName string `json:"tool_name,omitempty"`
193+ ToolArgs json.RawMessage `json:"tool_args,omitempty"`
194+ ToolPreview string `json:"tool_preview,omitempty"`
195+ Text string `json:"text,omitempty"`
195196 }
196197
197198 type EventInterceptFromHost struct {
@@ -183,15 +183,16 @@ type ToolCallFromHost struct {
183 }183 }
184 184
185 type EventFromHost struct {185 type EventFromHost struct {
186- Type string `json:"type"`186+ Type string `json:"type"`
187- Event string `json:"event"`187+ Event string `json:"event"`
188- Step int `json:"step,omitempty"`188+ Step int `json:"step,omitempty"`
189- Stop string `json:"stop,omitempty"`189+ Stop string `json:"stop,omitempty"`
190- Error string `json:"error,omitempty"`190+ Error string `json:"error,omitempty"`
191- ToolID string `json:"tool_id,omitempty"`191+ ToolID string `json:"tool_id,omitempty"`
192- ToolName string `json:"tool_name,omitempty"`192+ ToolName string `json:"tool_name,omitempty"`
193- ToolArgs json.RawMessage `json:"tool_args,omitempty"`193+ ToolArgs json.RawMessage `json:"tool_args,omitempty"`
194- Text string `json:"text,omitempty"`194+ ToolPreview string `json:"tool_preview,omitempty"`
195+ Text string `json:"text,omitempty"`
195 }196 }
196 197
197 type EventInterceptFromHost struct {198 type EventInterceptFromHost struct {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close