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) | | |||