detached e6d8408

chore: remove spontaneous panel review plan

Remove the review-only planning document before merging PR #20.

Co-authored-by: Raymond Gasper <raymondgasper@fastmail.com>
commit
e6d8408a4f84005de8ccfb81f415cc2adcd65a3a
author
patriceckhart · · 2026-06-08 17:37 UTC
parent
17fc959
signature
unsigned
1 file · +0 -245
D docs/plans/spontaneous-panel.md +0 -245
@@ -1,245 +0,0 @@
1-# Plan: Spontaneous `open_panel` + Human-in-the-Loop Tool Gate
2-
3-## Problem
4-
5-`open_panel` is only valid as the `action` of a `command_response`, which must
6-be a direct reply to a `command_invoked` frame. A panel can therefore only open
7-when the user types a slash command. There is no way for an extension to open a
8-panel in response to a tool call — making human-in-the-loop approval gates,
9-secret collection, and freeform user-input patterns impossible without awkward
10-workarounds (`/approve` slash commands, `threading.Event` polling, etc.).
11-
12----
13-
14-## What we are building
15-
16-**Part 1 (required):** A new top-level `open_panel` frame an extension can send
17-at any time, uncoupled from any command invocation.
18-
19-**Part 2 (falls out of Part 1 for free):** A blocking tool result pattern where
20-a tool goroutine opens a panel, waits on a Go channel for the user's response,
21-and only then returns a `tool_result`. No new wire frames needed — standard
22-concurrency on the extension side.
23-
24-**Part 3 (separate, required for intercepting built-in tools):** Raise or
25-remove the 5-second `event_intercept_response` timeout so that human-interactive
26-intercept handlers don't time out before the user can respond.
27-
28----
29-
30-## Implementation plan
31-
32-### 1. `packages/agent/extproto/extproto.go`
33-
34-Add one new struct (≈5 lines):
35-
36-```go
37-// OpenPanelFromExt is a spontaneous one-way frame an extension can send
38-// at any time to open an interactive panel without a prior command invocation.
39-type OpenPanelFromExt struct {
40- Type string `json:"type"` // "open_panel"
41- Panel PanelSpec `json:"panel"`
42-}
43-```
44-
45-### 2. `packages/agent/extensions/manager.go`
46-
47-Add one case to the `readLoop` switch (≈5 lines):
48-
49-```go
50-case "open_panel":
51- var op extproto.OpenPanelFromExt
52- if err := json.Unmarshal(line, &op); err == nil {
53- m.hooks.OpenPanel(ext.Manifest.Name, op.Panel)
54- }
55-```
56-
57-`HostHooks.OpenPanel` already exists. `interactive.go` requires **zero changes**.
58-
59-### 3. `packages/agent/ext/ext.go`
60-
61-Add one method on `Extension` (≈5 lines):
62-
63-```go
64-// OpenPanel opens an interactive panel spontaneously from extension code
65-// without requiring a slash command. Safe to call from a tool handler goroutine.
66-func (e *Extension) OpenPanel(id, title string, lines []string, footer string) {
67- _ = e.send(extproto.OpenPanelFromExt{
68- Type: "open_panel",
69- Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer},
70- })
71-}
72-```
73-
74-### 4. Part 3 — intercept timeout (built-in tool gating)
75-
76-Locate the hardcoded 5-second deadline in `packages/agent/extensions/manager.go`
77-(the `InterceptEvent` / `pendingIntercept` timeout). Two options, pick one:
78-
79-- **Option A (preferred):** Add `intercept_timeout_sec` to `Manifest` in
80- `manager.go` and honour it when building the intercept deadline. Zero/absent
81- means keep 5s default.
82-- **Option B:** Add a `timeout_ms` field to `EventInterceptFromHost` in
83- `extproto.go` that the host sets per-call when the target extension declares
84- the `panels` capability.
85-
86-### 5. `docs/extensions.md`
87-
88-- Add `open_panel` to the Extension → host frame table.
89-- Add a short prose section under Phase 4 describing the spontaneous form,
90- the blocking tool pattern, and the secret-collection pattern.
91-- Note the concurrent-panel limitation.
92-
93-### 6. `examples/extensions/` (new example)
94-
95-Add `examples/extensions/approval/` — a minimal extension demonstrating:
96-- An LLM-callable tool that opens an approval panel before proceeding.
97-- A secret-collection variant (masked password input).
98-
99-No changes to any other file.
100-
101----
102-
103-## Example usages
104-
105-### Simple approve / deny
106-
107-```go
108-e.Tool("risky_op", "Performs a risky operation.", schema, func(args json.RawMessage) ext.ToolResult {
109- result := make(chan bool, 1)
110- pid := "approve-" + randomID()
111-
112- e.OnPanelKey(pid, func(key, text string) {
113- switch {
114- case key == "rune" && text == "y":
115- e.ClosePanel(pid); result <- true
116- case key == "rune" && text == "n", key == "esc":
117- e.ClosePanel(pid); result <- false
118- }
119- }, func() { result <- false })
120-
121- e.OpenPanel(pid, "Approve?",
122- []string{"Agent wants to run: " + summary(args), "", " y approve", " n deny"},
123- "y approve n deny esc cancel")
124-
125- if !<-result {
126- return ext.TextErrorResult("user denied")
127- }
128- return doWork(args)
129-})
130-```
131-
132-### Secret / credential collection
133-
134-The secret is used directly inside the extension and never written to any JSON
135-frame or the transcript. The model receives only a success/failure status.
136-
137-```go
138-e.Tool("fetch_authenticated", "Fetch a URL that requires a password.", schema,
139- func(args json.RawMessage) ext.ToolResult {
140- var in struct{ URL string `json:"url"` }
141- json.Unmarshal(args, &in)
142-
143- type result struct{ secret string; ok bool }
144- ch := make(chan result, 1)
145- pid := "secret-" + randomID()
146- var mu sync.Mutex
147- var input string
148-
149- render := func() {
150- mu.Lock(); masked := strings.Repeat("●", len([]rune(input))); mu.Unlock()
151- e.RenderPanel(pid, "Password required",
152- []string{" URL: " + in.URL, "", " Password: " + masked + "▌"},
153- "enter confirm esc cancel")
154- }
155- e.OnPanelKey(pid, func(key, text string) {
156- mu.Lock()
157- switch key {
158- case "rune": input += text
159- case "backspace": if len(input) > 0 { r := []rune(input); input = string(r[:len(r)-1]) }
160- case "enter": secret := input; mu.Unlock(); e.ClosePanel(pid); ch <- result{secret, true}; return
161- case "esc": mu.Unlock(); e.ClosePanel(pid); ch <- result{}; return
162- }
163- mu.Unlock(); render()
164- }, func() { ch <- result{} })
165-
166- e.OpenPanel(pid, "Password required",
167- []string{" URL: " + in.URL, "", " Password: ▌"},
168- "enter confirm esc cancel")
169-
170- r := <-ch
171- if !r.ok { return ext.TextErrorResult("cancelled") }
172- return doFetch(in.URL, r.secret) // secret never leaves the extension process
173- })
174-```
175-
176-### Freeform text / override justification
177-
178-Same pattern as secret collection but without masking and with the result
179-injected into the tool's output rather than used as a credential — e.g. a
180-human-written review comment, an override reason for a blocked action, or a
181-value the model should not control.
182-
183-### Intercepting a built-in tool (requires Part 3)
184-
185-```go
186-e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision {
187- if tool != "bash" { return ext.ToolCallDecision{} }
188- ch := make(chan ext.ToolCallDecision, 1)
189- pid := "guard-" + randomID()
190- e.OnPanelKey(pid, func(key, text string) {
191- if key == "rune" && text == "y" { e.ClosePanel(pid); ch <- ext.ToolCallDecision{} }
192- if key == "rune" && text == "n" || key == "esc" {
193- e.ClosePanel(pid); ch <- ext.ToolCallDecision{Block: true, Reason: "user denied"}
194- }
195- }, func() { ch <- ext.ToolCallDecision{Block: true, Reason: "panel closed"} })
196- e.OpenPanel(pid, "Approve bash?", renderBashLines(args), "y approve n deny")
197- return <-ch // blocks intercept goroutine — requires Part 3 timeout increase
198-})
199-```
200-
201----
202-
203-## Out-of-scope risks
204-
205-**Intercept timeout (5s) blocks Part 3.**
206-The `event_intercept_response` timeout is hardcoded at 5 seconds. Human
207-interaction always exceeds this. Part 3 must be resolved before
208-`InterceptToolCallX` can be used as an approval gate for built-in tools.
209-Extension-registered tools are unaffected (no timeout on the tool goroutine).
210-
211-**Only one panel open at a time.**
212-`extPanelDialog` is a single slot. A second spontaneous `open_panel` while
213-another panel is active will replace it. Extensions that may receive concurrent
214-tool calls must serialise approvals internally. Multi-panel stacking is a
215-separate future concern.
216-
217-**Goroutine leak if panel is abandoned.**
218-If the user quits zot or the process is interrupted while a tool goroutine is
219-blocked on a channel, that goroutine leaks until process exit. Mitigation:
220-extension authors should `select` on a context cancellation channel alongside
221-the result channel. The SDK's `ToolHandler` signature does not currently expose
222-a context — passing one through is a separate improvement.
223-
224-**No panel scrolling / wrapping.**
225-Panel lines are plain strings (ANSI colour permitted). There is no built-in word
226-wrap or scroll. Long prompts must be pre-wrapped by the extension. Adequate for
227-approve/deny and credential collection; insufficient for displaying large
228-structured content.
229-
230-**Panel ID collisions under concurrent tool calls.**
231-If two tool calls for the same tool arrive concurrently, naive panel ID
232-generation could produce the same ID and stomp state. Use the tool-call ID (from
233-`ToolCallFromHost.ID`) as a suffix: `"approve-" + toolCallID`.
234-
235----
236-
237-## Files changed
238-
239-| File | Change |
240-|---|---|
241-| `packages/agent/extproto/extproto.go` | Add `OpenPanelFromExt` struct |
242-| `packages/agent/extensions/manager.go` | Add `case "open_panel":` in `readLoop`; Part 3: raise intercept timeout |
243-| `packages/agent/ext/ext.go` | Add `Extension.OpenPanel(...)` method |
244-| `docs/extensions.md` | Document spontaneous frame, blocking pattern, limitations |
245-| `examples/extensions/approval/` | New example extension (approval + secret collection) |
@@ -1,245 +0,0 @@
1-# Plan: Spontaneous `open_panel` + Human-in-the-Loop Tool Gate
2-
3-## Problem
4-
5-`open_panel` is only valid as the `action` of a `command_response`, which must
6-be a direct reply to a `command_invoked` frame. A panel can therefore only open
7-when the user types a slash command. There is no way for an extension to open a
8-panel in response to a tool call — making human-in-the-loop approval gates,
9-secret collection, and freeform user-input patterns impossible without awkward
10-workarounds (`/approve` slash commands, `threading.Event` polling, etc.).
11-
12----
13-
14-## What we are building
15-
16-**Part 1 (required):** A new top-level `open_panel` frame an extension can send
17-at any time, uncoupled from any command invocation.
18-
19-**Part 2 (falls out of Part 1 for free):** A blocking tool result pattern where
20-a tool goroutine opens a panel, waits on a Go channel for the user's response,
21-and only then returns a `tool_result`. No new wire frames needed — standard
22-concurrency on the extension side.
23-
24-**Part 3 (separate, required for intercepting built-in tools):** Raise or
25-remove the 5-second `event_intercept_response` timeout so that human-interactive
26-intercept handlers don't time out before the user can respond.
27-
28----
29-
30-## Implementation plan
31-
32-### 1. `packages/agent/extproto/extproto.go`
33-
34-Add one new struct (≈5 lines):
35-
36-```go
37-// OpenPanelFromExt is a spontaneous one-way frame an extension can send
38-// at any time to open an interactive panel without a prior command invocation.
39-type OpenPanelFromExt struct {
40- Type string `json:"type"` // "open_panel"
41- Panel PanelSpec `json:"panel"`
42-}
43-```
44-
45-### 2. `packages/agent/extensions/manager.go`
46-
47-Add one case to the `readLoop` switch (≈5 lines):
48-
49-```go
50-case "open_panel":
51- var op extproto.OpenPanelFromExt
52- if err := json.Unmarshal(line, &op); err == nil {
53- m.hooks.OpenPanel(ext.Manifest.Name, op.Panel)
54- }
55-```
56-
57-`HostHooks.OpenPanel` already exists. `interactive.go` requires **zero changes**.
58-
59-### 3. `packages/agent/ext/ext.go`
60-
61-Add one method on `Extension` (≈5 lines):
62-
63-```go
64-// OpenPanel opens an interactive panel spontaneously from extension code
65-// without requiring a slash command. Safe to call from a tool handler goroutine.
66-func (e *Extension) OpenPanel(id, title string, lines []string, footer string) {
67- _ = e.send(extproto.OpenPanelFromExt{
68- Type: "open_panel",
69- Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer},
70- })
71-}
72-```
73-
74-### 4. Part 3 — intercept timeout (built-in tool gating)
75-
76-Locate the hardcoded 5-second deadline in `packages/agent/extensions/manager.go`
77-(the `InterceptEvent` / `pendingIntercept` timeout). Two options, pick one:
78-
79-- **Option A (preferred):** Add `intercept_timeout_sec` to `Manifest` in
80- `manager.go` and honour it when building the intercept deadline. Zero/absent
81- means keep 5s default.
82-- **Option B:** Add a `timeout_ms` field to `EventInterceptFromHost` in
83- `extproto.go` that the host sets per-call when the target extension declares
84- the `panels` capability.
85-
86-### 5. `docs/extensions.md`
87-
88-- Add `open_panel` to the Extension → host frame table.
89-- Add a short prose section under Phase 4 describing the spontaneous form,
90- the blocking tool pattern, and the secret-collection pattern.
91-- Note the concurrent-panel limitation.
92-
93-### 6. `examples/extensions/` (new example)
94-
95-Add `examples/extensions/approval/` — a minimal extension demonstrating:
96-- An LLM-callable tool that opens an approval panel before proceeding.
97-- A secret-collection variant (masked password input).
98-
99-No changes to any other file.
100-
101----
102-
103-## Example usages
104-
105-### Simple approve / deny
106-
107-```go
108-e.Tool("risky_op", "Performs a risky operation.", schema, func(args json.RawMessage) ext.ToolResult {
109- result := make(chan bool, 1)
110- pid := "approve-" + randomID()
111-
112- e.OnPanelKey(pid, func(key, text string) {
113- switch {
114- case key == "rune" && text == "y":
115- e.ClosePanel(pid); result <- true
116- case key == "rune" && text == "n", key == "esc":
117- e.ClosePanel(pid); result <- false
118- }
119- }, func() { result <- false })
120-
121- e.OpenPanel(pid, "Approve?",
122- []string{"Agent wants to run: " + summary(args), "", " y approve", " n deny"},
123- "y approve n deny esc cancel")
124-
125- if !<-result {
126- return ext.TextErrorResult("user denied")
127- }
128- return doWork(args)
129-})
130-```
131-
132-### Secret / credential collection
133-
134-The secret is used directly inside the extension and never written to any JSON
135-frame or the transcript. The model receives only a success/failure status.
136-
137-```go
138-e.Tool("fetch_authenticated", "Fetch a URL that requires a password.", schema,
139- func(args json.RawMessage) ext.ToolResult {
140- var in struct{ URL string `json:"url"` }
141- json.Unmarshal(args, &in)
142-
143- type result struct{ secret string; ok bool }
144- ch := make(chan result, 1)
145- pid := "secret-" + randomID()
146- var mu sync.Mutex
147- var input string
148-
149- render := func() {
150- mu.Lock(); masked := strings.Repeat("●", len([]rune(input))); mu.Unlock()
151- e.RenderPanel(pid, "Password required",
152- []string{" URL: " + in.URL, "", " Password: " + masked + "▌"},
153- "enter confirm esc cancel")
154- }
155- e.OnPanelKey(pid, func(key, text string) {
156- mu.Lock()
157- switch key {
158- case "rune": input += text
159- case "backspace": if len(input) > 0 { r := []rune(input); input = string(r[:len(r)-1]) }
160- case "enter": secret := input; mu.Unlock(); e.ClosePanel(pid); ch <- result{secret, true}; return
161- case "esc": mu.Unlock(); e.ClosePanel(pid); ch <- result{}; return
162- }
163- mu.Unlock(); render()
164- }, func() { ch <- result{} })
165-
166- e.OpenPanel(pid, "Password required",
167- []string{" URL: " + in.URL, "", " Password: ▌"},
168- "enter confirm esc cancel")
169-
170- r := <-ch
171- if !r.ok { return ext.TextErrorResult("cancelled") }
172- return doFetch(in.URL, r.secret) // secret never leaves the extension process
173- })
174-```
175-
176-### Freeform text / override justification
177-
178-Same pattern as secret collection but without masking and with the result
179-injected into the tool's output rather than used as a credential — e.g. a
180-human-written review comment, an override reason for a blocked action, or a
181-value the model should not control.
182-
183-### Intercepting a built-in tool (requires Part 3)
184-
185-```go
186-e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision {
187- if tool != "bash" { return ext.ToolCallDecision{} }
188- ch := make(chan ext.ToolCallDecision, 1)
189- pid := "guard-" + randomID()
190- e.OnPanelKey(pid, func(key, text string) {
191- if key == "rune" && text == "y" { e.ClosePanel(pid); ch <- ext.ToolCallDecision{} }
192- if key == "rune" && text == "n" || key == "esc" {
193- e.ClosePanel(pid); ch <- ext.ToolCallDecision{Block: true, Reason: "user denied"}
194- }
195- }, func() { ch <- ext.ToolCallDecision{Block: true, Reason: "panel closed"} })
196- e.OpenPanel(pid, "Approve bash?", renderBashLines(args), "y approve n deny")
197- return <-ch // blocks intercept goroutine — requires Part 3 timeout increase
198-})
199-```
200-
201----
202-
203-## Out-of-scope risks
204-
205-**Intercept timeout (5s) blocks Part 3.**
206-The `event_intercept_response` timeout is hardcoded at 5 seconds. Human
207-interaction always exceeds this. Part 3 must be resolved before
208-`InterceptToolCallX` can be used as an approval gate for built-in tools.
209-Extension-registered tools are unaffected (no timeout on the tool goroutine).
210-
211-**Only one panel open at a time.**
212-`extPanelDialog` is a single slot. A second spontaneous `open_panel` while
213-another panel is active will replace it. Extensions that may receive concurrent
214-tool calls must serialise approvals internally. Multi-panel stacking is a
215-separate future concern.
216-
217-**Goroutine leak if panel is abandoned.**
218-If the user quits zot or the process is interrupted while a tool goroutine is
219-blocked on a channel, that goroutine leaks until process exit. Mitigation:
220-extension authors should `select` on a context cancellation channel alongside
221-the result channel. The SDK's `ToolHandler` signature does not currently expose
222-a context — passing one through is a separate improvement.
223-
224-**No panel scrolling / wrapping.**
225-Panel lines are plain strings (ANSI colour permitted). There is no built-in word
226-wrap or scroll. Long prompts must be pre-wrapped by the extension. Adequate for
227-approve/deny and credential collection; insufficient for displaying large
228-structured content.
229-
230-**Panel ID collisions under concurrent tool calls.**
231-If two tool calls for the same tool arrive concurrently, naive panel ID
232-generation could produce the same ID and stomp state. Use the tool-call ID (from
233-`ToolCallFromHost.ID`) as a suffix: `"approve-" + toolCallID`.
234-
235----
236-
237-## Files changed
238-
239-| File | Change |
240-|---|---|
241-| `packages/agent/extproto/extproto.go` | Add `OpenPanelFromExt` struct |
242-| `packages/agent/extensions/manager.go` | Add `case "open_panel":` in `readLoop`; Part 3: raise intercept timeout |
243-| `packages/agent/ext/ext.go` | Add `Extension.OpenPanel(...)` method |
244-| `docs/extensions.md` | Document spontaneous frame, blocking pattern, limitations |
245-| `examples/extensions/approval/` | New example extension (approval + secret collection) |

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 477

↑↓ moveenter openesc close