detached ae5a835

Merge pull request #3 from bnema/feature/agent-session-bell

feat(agent): add reusable terminal alerts
commit
ae5a8353a113faa9a3bd4e0df3b4bb2eea1d0e79
author
bnema · · 2026-08-04 09:24 UTC
committer
GitHub · · 2026-08-04 09:24 UTC
merge of
0a849d0 + 714773f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
22 files · +936 -49
M README.md +2 -1
@@ -289,7 +289,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
289289 | `/unjail` | Allow tools to touch paths outside again. |
290290 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
291291 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
292-| `/settings` | Change persistent settings, including inline images, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. |
292+| `/settings` | Change persistent settings, including inline images, terminal alerts, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. |
293293 | `/clear` | Clear the chat transcript. |
294294 | `/exit` | Exit zot. |
295295
@@ -390,6 +390,7 @@ zot discovers the common markdown/frontmatter profile layout from `~/.agents/age
390390 Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings:
391391
392392 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
393+- **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.
393394 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details.
394395 - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
395396 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
@@ -289,7 +289,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
289 | `/unjail` | Allow tools to touch paths outside again. |289 | `/unjail` | Allow tools to touch paths outside again. |
290 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |290 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
291 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |291 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
292-| `/settings` | Change persistent settings, including inline images, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. |292+| `/settings` | Change persistent settings, including inline images, terminal alerts, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. |
293 | `/clear` | Clear the chat transcript. |293 | `/clear` | Clear the chat transcript. |
294 | `/exit` | Exit zot. |294 | `/exit` | Exit zot. |
295 295
@@ -390,6 +390,7 @@ zot discovers the common markdown/frontmatter profile layout from `~/.agents/age
390 Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings:390 Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings:
391 391
392 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.392 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
393+- **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.
393 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details.394 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details.
394 - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.395 - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
395 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.396 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
M docs/extensions.md +31 -9
@@ -8,7 +8,7 @@ Rust, shell with `jq`, anything.
88
99 Four phases shipped so far:
1010
11-- **Phase 1**: slash commands + chat notifications.
11+- **Phase 1**: slash commands, chat notifications, and host alerts.
1212 - **Phase 2**: tools the LLM can call.
1313 - **Phase 3**: lifecycle event subscriptions + tool-call interception
1414 for guardrail extensions.
@@ -149,10 +149,10 @@ manifest tells zot how to launch it:
149149 (logged in the extension's own log file).
150150 5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames
151151 when the user runs a registered command; the extension responds
152- with `command_response`. Extensions can also push `notify` frames
153- during runtime. Panel-capable extensions may open an interactive
154- panel, receive key events, and push redraws while the panel is
155- focused.
152+ with `command_response`. Extensions can also push `notify` and
153+ structured `alert` frames during runtime. Panel-capable extensions
154+ may open an interactive panel, receive key events, and push redraws
155+ while the panel is focused.
156156 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
157157 2s for the extension to send `shutdown_ack`. Holdouts are
158158 SIGTERM'd, then SIGKILL'd.
@@ -166,8 +166,8 @@ restarted.
166166 All frames are one JSON object per line. Top-level `type` is the
167167 discriminator. Optional `id` correlates request frames with their
168168 responses. The canonical startup order is `hello`, `hello_ack`,
169-registration frames, then `ready`. Do not send `notify`, logs, or any
170-other stdout frame before `hello`.
169+registration frames, then `ready`. Do not send `notify`, `alert`, logs,
170+or any other stdout frame before `hello`.
171171
172172 ### Extension → host
173173
@@ -175,7 +175,7 @@ other stdout frame before `hello`.
175175
176176 ```json
177177 {"type":"hello","name":"weather","version":"1.0.0",
178- "capabilities":["commands","tools","panels"]}
178+ "capabilities":["commands","tools","alerts","panels"]}
179179 ```
180180
181181 #### `register_command`
@@ -381,6 +381,28 @@ up below the transcript with the extension's name in brackets. Notes
381381 are one-shot: they clear automatically when the user sends their next
382382 prompt (and on `esc` / `/clear`).
383383
384+#### `alert` (one-way, any time)
385+
386+Requests a host-owned structured alert. The first supported kind is
387+`bell`, which asks the interactive terminal to emit one terminal bell.
388+`reason` is semantic metadata and is not rendered as terminal text.
389+The shared terminal-alert setting in `/settings` applies to extension
390+alerts too; disabled or non-interactive hosts may ignore the request.
391+
392+```json
393+{"type":"alert","kind":"bell","reason":"question_ready"}
394+```
395+
396+The Go SDK sends the same frame with:
397+
398+```go
399+e.Alert(ext.AlertRequest{Kind: ext.AlertKindBell, Reason: "question_ready"})
400+```
401+
402+Extensions must not write BEL, ANSI, or other terminal bytes to stdout;
403+stdout is reserved for protocol frames. In `--mode rpc`, this becomes an
404+`ext_alert` JSON event for the RPC client to interpret.
405+
384406 #### `clear_notes` (one-way, any time)
385407
386408 Removes every note this extension previously pushed via `notify` /
@@ -682,7 +704,7 @@ trust or run zot under your platform's sandboxing tool (`bwrap` /
682704 Phase 1 (shipped):
683705 - [x] subprocess lifecycle + hello handshake
684706 - [x] `register_command` + `command_invoked`
685-- [x] `notify` + `clear_notes`
707+- [x] `notify` + `clear_notes` + structured terminal alerts
686708 - [x] `zot ext` CLI
687709
688710 Phase 2 (shipped):
@@ -8,7 +8,7 @@ Rust, shell with `jq`, anything.
8 8
9 Four phases shipped so far:9 Four phases shipped so far:
10 10
11-- **Phase 1**: slash commands + chat notifications.11+- **Phase 1**: slash commands, chat notifications, and host alerts.
12 - **Phase 2**: tools the LLM can call.12 - **Phase 2**: tools the LLM can call.
13 - **Phase 3**: lifecycle event subscriptions + tool-call interception13 - **Phase 3**: lifecycle event subscriptions + tool-call interception
14 for guardrail extensions.14 for guardrail extensions.
@@ -149,10 +149,10 @@ manifest tells zot how to launch it:
149 (logged in the extension's own log file).149 (logged in the extension's own log file).
150 5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames150 5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames
151 when the user runs a registered command; the extension responds151 when the user runs a registered command; the extension responds
152- with `command_response`. Extensions can also push `notify` frames152+ with `command_response`. Extensions can also push `notify` and
153- during runtime. Panel-capable extensions may open an interactive153+ structured `alert` frames during runtime. Panel-capable extensions
154- panel, receive key events, and push redraws while the panel is154+ may open an interactive panel, receive key events, and push redraws
155- focused.155+ while the panel is focused.
156 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to156 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
157 2s for the extension to send `shutdown_ack`. Holdouts are157 2s for the extension to send `shutdown_ack`. Holdouts are
158 SIGTERM'd, then SIGKILL'd.158 SIGTERM'd, then SIGKILL'd.
@@ -166,8 +166,8 @@ restarted.
166 All frames are one JSON object per line. Top-level `type` is the166 All frames are one JSON object per line. Top-level `type` is the
167 discriminator. Optional `id` correlates request frames with their167 discriminator. Optional `id` correlates request frames with their
168 responses. The canonical startup order is `hello`, `hello_ack`,168 responses. The canonical startup order is `hello`, `hello_ack`,
169-registration frames, then `ready`. Do not send `notify`, logs, or any169+registration frames, then `ready`. Do not send `notify`, `alert`, logs,
170-other stdout frame before `hello`.170+or any other stdout frame before `hello`.
171 171
172 ### Extension → host172 ### Extension → host
173 173
@@ -175,7 +175,7 @@ other stdout frame before `hello`.
175 175
176 ```json176 ```json
177 {"type":"hello","name":"weather","version":"1.0.0",177 {"type":"hello","name":"weather","version":"1.0.0",
178- "capabilities":["commands","tools","panels"]}178+ "capabilities":["commands","tools","alerts","panels"]}
179 ```179 ```
180 180
181 #### `register_command`181 #### `register_command`
@@ -381,6 +381,28 @@ up below the transcript with the extension's name in brackets. Notes
381 are one-shot: they clear automatically when the user sends their next381 are one-shot: they clear automatically when the user sends their next
382 prompt (and on `esc` / `/clear`).382 prompt (and on `esc` / `/clear`).
383 383
384+#### `alert` (one-way, any time)
385+
386+Requests a host-owned structured alert. The first supported kind is
387+`bell`, which asks the interactive terminal to emit one terminal bell.
388+`reason` is semantic metadata and is not rendered as terminal text.
389+The shared terminal-alert setting in `/settings` applies to extension
390+alerts too; disabled or non-interactive hosts may ignore the request.
391+
392+```json
393+{"type":"alert","kind":"bell","reason":"question_ready"}
394+```
395+
396+The Go SDK sends the same frame with:
397+
398+```go
399+e.Alert(ext.AlertRequest{Kind: ext.AlertKindBell, Reason: "question_ready"})
400+```
401+
402+Extensions must not write BEL, ANSI, or other terminal bytes to stdout;
403+stdout is reserved for protocol frames. In `--mode rpc`, this becomes an
404+`ext_alert` JSON event for the RPC client to interpret.
405+
384 #### `clear_notes` (one-way, any time)406 #### `clear_notes` (one-way, any time)
385 407
386 Removes every note this extension previously pushed via `notify` /408 Removes every note this extension previously pushed via `notify` /
@@ -682,7 +704,7 @@ trust or run zot under your platform's sandboxing tool (`bwrap` /
682 Phase 1 (shipped):704 Phase 1 (shipped):
683 - [x] subprocess lifecycle + hello handshake705 - [x] subprocess lifecycle + hello handshake
684 - [x] `register_command` + `command_invoked`706 - [x] `register_command` + `command_invoked`
685-- [x] `notify` + `clear_notes`707+- [x] `notify` + `clear_notes` + structured terminal alerts
686 - [x] `zot ext` CLI708 - [x] `zot ext` CLI
687 709
688 Phase 2 (shipped):710 Phase 2 (shipped):
M docs/rpc.md +1 -0
@@ -202,6 +202,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
202202 | `done` | (none) | The whole prompt/compact completed (success or error) |
203203 | `error` | `message` | Non-fatal error message |
204204 | `compact_done` | `summary` | Compaction finished, summary text included |
205+| `ext_alert` | `extension`, `kind`, optional `reason` | An extension requested a host alert; RPC clients can map it to their own notification UI. |
205206
206207 ## Message shape
207208
@@ -202,6 +202,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
202 | `done` | (none) | The whole prompt/compact completed (success or error) |202 | `done` | (none) | The whole prompt/compact completed (success or error) |
203 | `error` | `message` | Non-fatal error message |203 | `error` | `message` | Non-fatal error message |
204 | `compact_done` | `summary` | Compaction finished, summary text included |204 | `compact_done` | `summary` | Compaction finished, summary text included |
205+| `ext_alert` | `extension`, `kind`, optional `reason` | An extension requested a host alert; RPC clients can map it to their own notification UI. |
205 206
206 ## Message shape207 ## Message shape
207 208
A docs/superpowers/specs/2026-08-04-terminal-alerts-design.md +77 -0
@@ -0,0 +1,77 @@
1+# Terminal Alerts and Extension Bells
2+
3+## Goal
4+
5+Provide a reusable host alert boundary for the interactive terminal. The first alert kind is a terminal bell, emitted as `\a` only by the interactive host.
6+
7+The same alert policy serves the main agent session and trusted, user-installed extensions that need to draw attention to an interactive question or panel.
8+
9+## User Experience
10+
11+The main interactive session emits one bell after a submitted main-agent run becomes idle and the paced final response has finished rendering when the outcome needs attention:
12+
13+- normal final answer
14+- output-limit/truncated response
15+- non-recoverable provider error
16+- rescue dialog opened after a recoverable provider error
17+
18+The bell is suppressed for explicit cancellation, automatic compaction/retry, and work immediately followed by another queued turn. `/btw`, Telegram, SDK, JSON, print, stream, RPC, and swarm-agent modes do not write raw terminal bell bytes.
19+
20+Users can disable both main-agent and extension terminal alerts from `/settings`. Alerts are enabled by default when the configuration field is missing.
21+
22+## Public Extension Surface
23+
24+Extensions send a fire-and-forget structured alert frame:
25+
26+```json
27+{"type":"alert","kind":"bell","reason":"question_ready"}
28+```
29+
30+The Go extension SDK exposes the equivalent `Extension.Alert(AlertRequest)` method. The host-side alert hook is optional so existing `HostHooks` embedders remain compatible. `kind` is initially `bell`; `reason` is semantic metadata and is not rendered as terminal text. Extensions never provide raw terminal bytes, ANSI sequences, counts, or sound patterns.
31+
32+The host owns the output policy. Interactive mode writes a standalone BEL byte through its injected `tui.Terminal`; non-interactive hosts remain byte-clean and may ignore or translate the request.
33+
34+## Configuration
35+
36+Persist the shared policy in `$ZOT_HOME/config.json`:
37+
38+```json
39+{"terminal_alerts_enabled": true}
40+```
41+
42+The optional boolean resolves as follows:
43+
44+- missing or `null`: enabled
45+- `true`: enabled
46+- `false`: disabled
47+
48+The setting applies immediately to the current interactive session and persists for future sessions.
49+
50+## Lifecycle Boundary
51+
52+The main-agent alert is scheduled from the interactive `startTurnRequest` completion path, not from `core.Agent` events. `EvTurnEnd` is one model step and `EvDone` is not emitted on every error path. The final outcome is tracked from both the last turn event and the returned `Prompt`/`Continue` error.
53+
54+A pending main-agent alert waits for the streaming pacer to drain `streamPending`. If a new queued/recovery turn begins before the drain completes, the pending alert is discarded. The bell is emitted only after the host is truly idle.
55+
56+## Extension Routing
57+
58+`packages/agent/extproto` defines the alert wire frame. `packages/agent/extensions` routes it through the optional `AlertHostHooks` extension to `HostHooks`. Interactive mode applies the shared setting and terminal output. RPC mode translates extension alerts into an `ext_alert` JSON event; print, stream, JSON, Telegram, and other non-interactive hosts do not write BEL to their data streams.
59+
60+Unknown alert kinds are ignored by the interactive renderer. The wire shape is additive and permits future host alert kinds without exposing terminal implementation details.
61+
62+## Tests
63+
64+Cover:
65+
66+- BEL writes as a standalone byte through an injected writer
67+- extension SDK emits the structured alert frame
68+- extension manager routes alert frames to host hooks
69+- disabled terminal alerts suppress both main and extension alerts
70+- missing configuration enables alerts
71+- main completion alerts wait for paced output to drain
72+- cancellation, recovery, queue, and side-chat paths do not produce an unwanted main bell
73+- RPC alert translation does not emit raw BEL
74+
75+## Documentation
76+
77+Document the `/settings` terminal-alert toggle and the extension alert frame/SDK method in `README.md` and `docs/extensions.md`. State that terminal bells depend on terminal emulator settings and may be audible, visual, or suppressed by the host terminal.
@@ -0,0 +1,77 @@
1+# Terminal Alerts and Extension Bells
2+
3+## Goal
4+
5+Provide a reusable host alert boundary for the interactive terminal. The first alert kind is a terminal bell, emitted as `\a` only by the interactive host.
6+
7+The same alert policy serves the main agent session and trusted, user-installed extensions that need to draw attention to an interactive question or panel.
8+
9+## User Experience
10+
11+The main interactive session emits one bell after a submitted main-agent run becomes idle and the paced final response has finished rendering when the outcome needs attention:
12+
13+- normal final answer
14+- output-limit/truncated response
15+- non-recoverable provider error
16+- rescue dialog opened after a recoverable provider error
17+
18+The bell is suppressed for explicit cancellation, automatic compaction/retry, and work immediately followed by another queued turn. `/btw`, Telegram, SDK, JSON, print, stream, RPC, and swarm-agent modes do not write raw terminal bell bytes.
19+
20+Users can disable both main-agent and extension terminal alerts from `/settings`. Alerts are enabled by default when the configuration field is missing.
21+
22+## Public Extension Surface
23+
24+Extensions send a fire-and-forget structured alert frame:
25+
26+```json
27+{"type":"alert","kind":"bell","reason":"question_ready"}
28+```
29+
30+The Go extension SDK exposes the equivalent `Extension.Alert(AlertRequest)` method. The host-side alert hook is optional so existing `HostHooks` embedders remain compatible. `kind` is initially `bell`; `reason` is semantic metadata and is not rendered as terminal text. Extensions never provide raw terminal bytes, ANSI sequences, counts, or sound patterns.
31+
32+The host owns the output policy. Interactive mode writes a standalone BEL byte through its injected `tui.Terminal`; non-interactive hosts remain byte-clean and may ignore or translate the request.
33+
34+## Configuration
35+
36+Persist the shared policy in `$ZOT_HOME/config.json`:
37+
38+```json
39+{"terminal_alerts_enabled": true}
40+```
41+
42+The optional boolean resolves as follows:
43+
44+- missing or `null`: enabled
45+- `true`: enabled
46+- `false`: disabled
47+
48+The setting applies immediately to the current interactive session and persists for future sessions.
49+
50+## Lifecycle Boundary
51+
52+The main-agent alert is scheduled from the interactive `startTurnRequest` completion path, not from `core.Agent` events. `EvTurnEnd` is one model step and `EvDone` is not emitted on every error path. The final outcome is tracked from both the last turn event and the returned `Prompt`/`Continue` error.
53+
54+A pending main-agent alert waits for the streaming pacer to drain `streamPending`. If a new queued/recovery turn begins before the drain completes, the pending alert is discarded. The bell is emitted only after the host is truly idle.
55+
56+## Extension Routing
57+
58+`packages/agent/extproto` defines the alert wire frame. `packages/agent/extensions` routes it through the optional `AlertHostHooks` extension to `HostHooks`. Interactive mode applies the shared setting and terminal output. RPC mode translates extension alerts into an `ext_alert` JSON event; print, stream, JSON, Telegram, and other non-interactive hosts do not write BEL to their data streams.
59+
60+Unknown alert kinds are ignored by the interactive renderer. The wire shape is additive and permits future host alert kinds without exposing terminal implementation details.
61+
62+## Tests
63+
64+Cover:
65+
66+- BEL writes as a standalone byte through an injected writer
67+- extension SDK emits the structured alert frame
68+- extension manager routes alert frames to host hooks
69+- disabled terminal alerts suppress both main and extension alerts
70+- missing configuration enables alerts
71+- main completion alerts wait for paced output to drain
72+- cancellation, recovery, queue, and side-chat paths do not produce an unwanted main bell
73+- RPC alert translation does not emit raw BEL
74+
75+## Documentation
76+
77+Document the `/settings` terminal-alert toggle and the extension alert frame/SDK method in `README.md` and `docs/extensions.md`. State that terminal bells depend on terminal emulator settings and may be audible, visual, or suppressed by the host terminal.
M packages/agent/cli.go +53 -8
@@ -30,17 +30,43 @@ import (
3030
3131 // interactiveExtHooks is a tiny adapter that lets the extension
3232 // manager call back into the Interactive instance built later in
33-// runInteractive. The forward-declared *modes.Interactive is filled
34-// in immediately after manager construction.
33+// runInteractive. Alerts are buffered until the instance is attached so
34+// an extension that signals attention during startup is not silently lost.
35+// Keep the startup buffer bounded because extensions can flood their host.
36+const maxBufferedInteractiveAlerts = 64
37+
3538 type interactiveExtHooks struct {
36- ivPtr **modes.Interactive
39+ mu sync.Mutex
40+ interactive *modes.Interactive
41+ pendingAlerts []interactiveAlert
42+}
43+
44+type interactiveAlert struct {
45+ extName string
46+ alert extproto.AlertRequest
3747 }
3848
3949 func (h *interactiveExtHooks) iv() *modes.Interactive {
40- if h == nil || h.ivPtr == nil {
50+ if h == nil {
4151 return nil
4252 }
43- return *h.ivPtr
53+ h.mu.Lock()
54+ defer h.mu.Unlock()
55+ return h.interactive
56+}
57+
58+func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) {
59+ if h == nil || iv == nil {
60+ return
61+ }
62+ h.mu.Lock()
63+ h.interactive = iv
64+ pending := h.pendingAlerts
65+ h.pendingAlerts = nil
66+ h.mu.Unlock()
67+ for _, item := range pending {
68+ iv.Alert(item.extName, item.alert)
69+ }
4470 }
4571
4672 func (h *interactiveExtHooks) Notify(extName, level, message string) {
@@ -48,6 +74,22 @@ func (h *interactiveExtHooks) Notify(extName, level, message string) {
4874 iv.Notify(extName, level, message)
4975 }
5076 }
77+func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest) {
78+ if h == nil {
79+ return
80+ }
81+ h.mu.Lock()
82+ if h.interactive == nil {
83+ if len(h.pendingAlerts) < maxBufferedInteractiveAlerts {
84+ h.pendingAlerts = append(h.pendingAlerts, interactiveAlert{extName: extName, alert: alert})
85+ }
86+ h.mu.Unlock()
87+ return
88+ }
89+ iv := h.interactive
90+ h.mu.Unlock()
91+ iv.Alert(extName, alert)
92+}
5193 func (h *interactiveExtHooks) Submit(text string) {
5294 if iv := h.iv(); iv != nil {
5395 iv.SubmitOrQueue(text, nil)
@@ -273,6 +315,7 @@ type nonInteractiveExtHooks struct{}
273315 func (nonInteractiveExtHooks) Notify(ext, level, message string) {
274316 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
275317 }
318+func (nonInteractiveExtHooks) Alert(string, extproto.AlertRequest) {}
276319 func (nonInteractiveExtHooks) Submit(string) {}
277320 func (nonInteractiveExtHooks) SubmitSlash(string) {}
278321 func (nonInteractiveExtHooks) Insert(string) {}
@@ -653,10 +696,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
653696 sharedSandbox := r.Sandbox
654697
655698 // Build the extension manager BEFORE the agent so we can fold
656- // extension-defined tools into the registry. Forward-declare iv so
657- // the host hooks adapter can dereference it after construction.
699+ // extension-defined tools into the registry. Attach the interactive
700+ // host after constructing it below so startup alerts can be buffered.
658701 var iv *modes.Interactive
659- extHooks := &interactiveExtHooks{ivPtr: &iv}
702+ extHooks := &interactiveExtHooks{}
660703 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
661704 var startupExtensionErrors []string
662705 // --ext paths first so they win against installed extensions of
@@ -1286,6 +1329,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12861329 Terminal: term,
12871330 Theme: theme,
12881331 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1332+ TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
12891333 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
12901334 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
12911335 JailByDefault: initialCfg.JailByDefault,
@@ -1440,6 +1484,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14401484 }
14411485 },
14421486 })
1487+ extHooks.attachInteractive(iv)
14431488
14441489 // Bind the interactive TUI as the Confirmer. We deferred this
14451490 // until now because the gate is constructed before the TUI
@@ -30,17 +30,43 @@ import (
30 30
31 // interactiveExtHooks is a tiny adapter that lets the extension31 // interactiveExtHooks is a tiny adapter that lets the extension
32 // manager call back into the Interactive instance built later in32 // manager call back into the Interactive instance built later in
33-// runInteractive. The forward-declared *modes.Interactive is filled33+// runInteractive. Alerts are buffered until the instance is attached so
34-// in immediately after manager construction.34+// an extension that signals attention during startup is not silently lost.
35+// Keep the startup buffer bounded because extensions can flood their host.
36+const maxBufferedInteractiveAlerts = 64
37+
35 type interactiveExtHooks struct {38 type interactiveExtHooks struct {
36- ivPtr **modes.Interactive39+ mu sync.Mutex
40+ interactive *modes.Interactive
41+ pendingAlerts []interactiveAlert
42+}
43+
44+type interactiveAlert struct {
45+ extName string
46+ alert extproto.AlertRequest
37 }47 }
38 48
39 func (h *interactiveExtHooks) iv() *modes.Interactive {49 func (h *interactiveExtHooks) iv() *modes.Interactive {
40- if h == nil || h.ivPtr == nil {50+ if h == nil {
41 return nil51 return nil
42 }52 }
43- return *h.ivPtr53+ h.mu.Lock()
54+ defer h.mu.Unlock()
55+ return h.interactive
56+}
57+
58+func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) {
59+ if h == nil || iv == nil {
60+ return
61+ }
62+ h.mu.Lock()
63+ h.interactive = iv
64+ pending := h.pendingAlerts
65+ h.pendingAlerts = nil
66+ h.mu.Unlock()
67+ for _, item := range pending {
68+ iv.Alert(item.extName, item.alert)
69+ }
44 }70 }
45 71
46 func (h *interactiveExtHooks) Notify(extName, level, message string) {72 func (h *interactiveExtHooks) Notify(extName, level, message string) {
@@ -48,6 +74,22 @@ func (h *interactiveExtHooks) Notify(extName, level, message string) {
48 iv.Notify(extName, level, message)74 iv.Notify(extName, level, message)
49 }75 }
50 }76 }
77+func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest) {
78+ if h == nil {
79+ return
80+ }
81+ h.mu.Lock()
82+ if h.interactive == nil {
83+ if len(h.pendingAlerts) < maxBufferedInteractiveAlerts {
84+ h.pendingAlerts = append(h.pendingAlerts, interactiveAlert{extName: extName, alert: alert})
85+ }
86+ h.mu.Unlock()
87+ return
88+ }
89+ iv := h.interactive
90+ h.mu.Unlock()
91+ iv.Alert(extName, alert)
92+}
51 func (h *interactiveExtHooks) Submit(text string) {93 func (h *interactiveExtHooks) Submit(text string) {
52 if iv := h.iv(); iv != nil {94 if iv := h.iv(); iv != nil {
53 iv.SubmitOrQueue(text, nil)95 iv.SubmitOrQueue(text, nil)
@@ -273,6 +315,7 @@ type nonInteractiveExtHooks struct{}
273 func (nonInteractiveExtHooks) Notify(ext, level, message string) {315 func (nonInteractiveExtHooks) Notify(ext, level, message string) {
274 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)316 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
275 }317 }
318+func (nonInteractiveExtHooks) Alert(string, extproto.AlertRequest) {}
276 func (nonInteractiveExtHooks) Submit(string) {}319 func (nonInteractiveExtHooks) Submit(string) {}
277 func (nonInteractiveExtHooks) SubmitSlash(string) {}320 func (nonInteractiveExtHooks) SubmitSlash(string) {}
278 func (nonInteractiveExtHooks) Insert(string) {}321 func (nonInteractiveExtHooks) Insert(string) {}
@@ -653,10 +696,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
653 sharedSandbox := r.Sandbox696 sharedSandbox := r.Sandbox
654 697
655 // Build the extension manager BEFORE the agent so we can fold698 // Build the extension manager BEFORE the agent so we can fold
656- // extension-defined tools into the registry. Forward-declare iv so699+ // extension-defined tools into the registry. Attach the interactive
657- // the host hooks adapter can dereference it after construction.700+ // host after constructing it below so startup alerts can be buffered.
658 var iv *modes.Interactive701 var iv *modes.Interactive
659- extHooks := &interactiveExtHooks{ivPtr: &iv}702+ extHooks := &interactiveExtHooks{}
660 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)703 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
661 var startupExtensionErrors []string704 var startupExtensionErrors []string
662 // --ext paths first so they win against installed extensions of705 // --ext paths first so they win against installed extensions of
@@ -1286,6 +1329,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1286 Terminal: term,1329 Terminal: term,
1287 Theme: theme,1330 Theme: theme,
1288 InlineImagesEnabled: initialCfg.InlineImagesEnabled,1331 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1332+ TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
1289 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,1333 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1290 AutoCompactThreshold: initialCfg.AutoCompactThreshold,1334 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1291 JailByDefault: initialCfg.JailByDefault,1335 JailByDefault: initialCfg.JailByDefault,
@@ -1440,6 +1484,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1440 }1484 }
1441 },1485 },
1442 })1486 })
1487+ extHooks.attachInteractive(iv)
1443 1488
1444 // Bind the interactive TUI as the Confirmer. We deferred this1489 // Bind the interactive TUI as the Confirmer. We deferred this
1445 // until now because the gate is constructed before the TUI1490 // until now because the gate is constructed before the TUI
A packages/agent/cli_alert_test.go +33 -0
@@ -0,0 +1,33 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/agent/extproto"
7+ "github.com/patriceckhart/zot/packages/agent/modes"
8+)
9+
10+func TestInteractiveExtHooksBufferStartupAlerts(t *testing.T) {
11+ hooks := &interactiveExtHooks{}
12+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
13+ for n := 0; n < maxBufferedInteractiveAlerts+8; n++ {
14+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "flood"})
15+ }
16+
17+ hooks.mu.Lock()
18+ pending := len(hooks.pendingAlerts)
19+ hooks.mu.Unlock()
20+ if pending != maxBufferedInteractiveAlerts {
21+ t.Fatalf("pending startup alerts = %d, want bounded capacity %d", pending, maxBufferedInteractiveAlerts)
22+ }
23+
24+ iv := &modes.Interactive{}
25+ hooks.attachInteractive(iv)
26+ hooks.mu.Lock()
27+ pending = len(hooks.pendingAlerts)
28+ attached := hooks.interactive == iv
29+ hooks.mu.Unlock()
30+ if pending != 0 || !attached {
31+ t.Fatalf("after attach: pending=%d attached=%v, want no pending alert and attached host", pending, attached)
32+ }
33+}
@@ -0,0 +1,33 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/agent/extproto"
7+ "github.com/patriceckhart/zot/packages/agent/modes"
8+)
9+
10+func TestInteractiveExtHooksBufferStartupAlerts(t *testing.T) {
11+ hooks := &interactiveExtHooks{}
12+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
13+ for n := 0; n < maxBufferedInteractiveAlerts+8; n++ {
14+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "flood"})
15+ }
16+
17+ hooks.mu.Lock()
18+ pending := len(hooks.pendingAlerts)
19+ hooks.mu.Unlock()
20+ if pending != maxBufferedInteractiveAlerts {
21+ t.Fatalf("pending startup alerts = %d, want bounded capacity %d", pending, maxBufferedInteractiveAlerts)
22+ }
23+
24+ iv := &modes.Interactive{}
25+ hooks.attachInteractive(iv)
26+ hooks.mu.Lock()
27+ pending = len(hooks.pendingAlerts)
28+ attached := hooks.interactive == iv
29+ hooks.mu.Unlock()
30+ if pending != 0 || !attached {
31+ t.Fatalf("after attach: pending=%d attached=%v, want no pending alert and attached host", pending, attached)
32+ }
33+}
M packages/agent/config.go +5 -0
@@ -53,6 +53,11 @@ type Config struct {
5353 // detected protocol when available.
5454 InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"`
5555
56+ // TerminalAlertsEnabled controls interactive terminal alerts from the
57+ // main agent and extensions. nil/missing means enabled; false disables
58+ // alerts. Toggle from /settings.
59+ TerminalAlertsEnabled *bool `json:"terminal_alerts_enabled,omitempty"`
60+
5661 // AutoSwarmEnabled lets the main agent spawn background sub-agents
5762 // for parallel sub-tasks via a built-in swarm_spawn tool. Off by
5863 // default; nil/missing means disabled. Toggle from /settings.
@@ -53,6 +53,11 @@ type Config struct {
53 // detected protocol when available.53 // detected protocol when available.
54 InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"`54 InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"`
55 55
56+ // TerminalAlertsEnabled controls interactive terminal alerts from the
57+ // main agent and extensions. nil/missing means enabled; false disables
58+ // alerts. Toggle from /settings.
59+ TerminalAlertsEnabled *bool `json:"terminal_alerts_enabled,omitempty"`
60+
56 // AutoSwarmEnabled lets the main agent spawn background sub-agents61 // AutoSwarmEnabled lets the main agent spawn background sub-agents
57 // for parallel sub-tasks via a built-in swarm_spawn tool. Off by62 // for parallel sub-tasks via a built-in swarm_spawn tool. Off by
58 // default; nil/missing means disabled. Toggle from /settings.63 // default; nil/missing means disabled. Toggle from /settings.
M packages/agent/ext/ext.go +16 -1
@@ -158,6 +158,12 @@ type ToolResult struct {
158158 ActivateTools []string
159159 }
160160
161+// AlertRequest asks the host to surface a structured alert. The host owns
162+// the rendering policy; extensions never write terminal bytes directly.
163+type AlertRequest = extproto.AlertRequest
164+
165+const AlertKindBell = extproto.AlertKindBell
166+
161167 // ToolContent is one block of tool output. Either Text is set, or
162168 // MimeType+Data (base64-encoded). Use the Text/Image helpers below.
163169 type ToolContent struct {
@@ -311,7 +317,7 @@ func New(name, version string) *Extension {
311317 eventHandlers: map[string]EventHandler{},
312318 panelKeys: map[string]func(key, text string){},
313319 panelCloses: map[string]func(){},
314- caps: []string{"commands", "tools", "events", "panels"},
320+ caps: []string{"commands", "tools", "events", "alerts", "panels"},
315321 }
316322 }
317323
@@ -479,6 +485,15 @@ func (e *Extension) Notify(level, message string) {
479485 })
480486 }
481487
488+// Alert asks the host to surface a structured alert. It is fire-and-forget;
489+// hosts that do not support the requested kind may ignore it.
490+func (e *Extension) Alert(alert AlertRequest) {
491+ _ = e.send(extproto.AlertFromExt{
492+ Type: "alert",
493+ AlertRequest: alert,
494+ })
495+}
496+
482497 // Run starts the protocol loop. Blocks until stdin closes (zot has
483498 // shut us down). Returns the first fatal error, or nil on clean exit.
484499 func (e *Extension) Run() error {
@@ -158,6 +158,12 @@ type ToolResult struct {
158 ActivateTools []string158 ActivateTools []string
159 }159 }
160 160
161+// AlertRequest asks the host to surface a structured alert. The host owns
162+// the rendering policy; extensions never write terminal bytes directly.
163+type AlertRequest = extproto.AlertRequest
164+
165+const AlertKindBell = extproto.AlertKindBell
166+
161 // ToolContent is one block of tool output. Either Text is set, or167 // ToolContent is one block of tool output. Either Text is set, or
162 // MimeType+Data (base64-encoded). Use the Text/Image helpers below.168 // MimeType+Data (base64-encoded). Use the Text/Image helpers below.
163 type ToolContent struct {169 type ToolContent struct {
@@ -311,7 +317,7 @@ func New(name, version string) *Extension {
311 eventHandlers: map[string]EventHandler{},317 eventHandlers: map[string]EventHandler{},
312 panelKeys: map[string]func(key, text string){},318 panelKeys: map[string]func(key, text string){},
313 panelCloses: map[string]func(){},319 panelCloses: map[string]func(){},
314- caps: []string{"commands", "tools", "events", "panels"},320+ caps: []string{"commands", "tools", "events", "alerts", "panels"},
315 }321 }
316 }322 }
317 323
@@ -479,6 +485,15 @@ func (e *Extension) Notify(level, message string) {
479 })485 })
480 }486 }
481 487
488+// Alert asks the host to surface a structured alert. It is fire-and-forget;
489+// hosts that do not support the requested kind may ignore it.
490+func (e *Extension) Alert(alert AlertRequest) {
491+ _ = e.send(extproto.AlertFromExt{
492+ Type: "alert",
493+ AlertRequest: alert,
494+ })
495+}
496+
482 // Run starts the protocol loop. Blocks until stdin closes (zot has497 // Run starts the protocol loop. Blocks until stdin closes (zot has
483 // shut us down). Returns the first fatal error, or nil on clean exit.498 // shut us down). Returns the first fatal error, or nil on clean exit.
484 func (e *Extension) Run() error {499 func (e *Extension) Run() error {
M packages/agent/ext/ext_test.go +29 -0
@@ -220,6 +220,35 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
220220 h.hostW.Close()
221221 }
222222
223+func TestAlertEmitsStructuredFrame(t *testing.T) {
224+ h := newHarness("alert-ext")
225+ runDone := make(chan error, 1)
226+ go func() { runDone <- h.ext.Run() }()
227+ t.Cleanup(func() {
228+ _ = h.hostW.Close()
229+ select {
230+ case err := <-runDone:
231+ if err != nil {
232+ t.Errorf("extension Run: %v", err)
233+ }
234+ case <-time.After(2 * time.Second):
235+ t.Error("extension Run did not exit after closing host input")
236+ }
237+ })
238+ h.handshake(t)
239+
240+ go h.ext.Alert(AlertRequest{Kind: AlertKindBell, Reason: "question_ready"})
241+ f := h.drainUntil(t, "alert")
242+
243+ var alert extproto.AlertFromExt
244+ if err := json.Unmarshal(f.raw, &alert); err != nil {
245+ t.Fatalf("unmarshal alert: %v", err)
246+ }
247+ if alert.Kind != AlertKindBell || alert.Reason != "question_ready" {
248+ t.Fatalf("alert = %+v, want bell/question_ready", alert)
249+ }
250+}
251+
223252 func TestToolConfirmationRequestedEvent(t *testing.T) {
224253 h := newHarness("confirmation-events")
225254 received := make(chan Event, 1)
@@ -220,6 +220,35 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
220 h.hostW.Close()220 h.hostW.Close()
221 }221 }
222 222
223+func TestAlertEmitsStructuredFrame(t *testing.T) {
224+ h := newHarness("alert-ext")
225+ runDone := make(chan error, 1)
226+ go func() { runDone <- h.ext.Run() }()
227+ t.Cleanup(func() {
228+ _ = h.hostW.Close()
229+ select {
230+ case err := <-runDone:
231+ if err != nil {
232+ t.Errorf("extension Run: %v", err)
233+ }
234+ case <-time.After(2 * time.Second):
235+ t.Error("extension Run did not exit after closing host input")
236+ }
237+ })
238+ h.handshake(t)
239+
240+ go h.ext.Alert(AlertRequest{Kind: AlertKindBell, Reason: "question_ready"})
241+ f := h.drainUntil(t, "alert")
242+
243+ var alert extproto.AlertFromExt
244+ if err := json.Unmarshal(f.raw, &alert); err != nil {
245+ t.Fatalf("unmarshal alert: %v", err)
246+ }
247+ if alert.Kind != AlertKindBell || alert.Reason != "question_ready" {
248+ t.Fatalf("alert = %+v, want bell/question_ready", alert)
249+ }
250+}
251+
223 func TestToolConfirmationRequestedEvent(t *testing.T) {252 func TestToolConfirmationRequestedEvent(t *testing.T) {
224 h := newHarness("confirmation-events")253 h := newHarness("confirmation-events")
225 received := make(chan Event, 1)254 received := make(chan Event, 1)
M packages/agent/extcmd.go +1 -0
@@ -133,6 +133,7 @@ func extList() error {
133133 type extDoctorHooks struct{}
134134
135135 func (extDoctorHooks) Notify(string, string, string) {}
136+func (extDoctorHooks) Alert(string, extproto.AlertRequest) {}
136137 func (extDoctorHooks) Submit(string) {}
137138 func (extDoctorHooks) SubmitSlash(string) {}
138139 func (extDoctorHooks) Insert(string) {}
@@ -133,6 +133,7 @@ func extList() error {
133 type extDoctorHooks struct{}133 type extDoctorHooks struct{}
134 134
135 func (extDoctorHooks) Notify(string, string, string) {}135 func (extDoctorHooks) Notify(string, string, string) {}
136+func (extDoctorHooks) Alert(string, extproto.AlertRequest) {}
136 func (extDoctorHooks) Submit(string) {}137 func (extDoctorHooks) Submit(string) {}
137 func (extDoctorHooks) SubmitSlash(string) {}138 func (extDoctorHooks) SubmitSlash(string) {}
138 func (extDoctorHooks) Insert(string) {}139 func (extDoctorHooks) Insert(string) {}
M packages/agent/extensions/manager.go +14 -0
@@ -132,6 +132,13 @@ type HostHooks interface {
132132 ClosePanel(extName, panelID string)
133133 }
134134
135+// AlertHostHooks is an optional extension to HostHooks. Hosts that support
136+// structured alerts implement it without breaking older embedders that only
137+// implement the original HostHooks surface.
138+type AlertHostHooks interface {
139+ Alert(extName string, alert extproto.AlertRequest)
140+}
141+
135142 type commandRegistration struct {
136143 ext *Extension
137144 name string
@@ -845,6 +852,13 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
845852 if err := json.Unmarshal(line, &n); err == nil {
846853 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
847854 }
855+ case "alert":
856+ var a extproto.AlertFromExt
857+ if err := json.Unmarshal(line, &a); err == nil {
858+ if hooks, ok := m.hooks.(AlertHostHooks); ok {
859+ hooks.Alert(ext.Manifest.Name, a.AlertRequest)
860+ }
861+ }
848862 case "clear_notes":
849863 m.hooks.ClearNotes(ext.Manifest.Name)
850864 case "submit":
@@ -132,6 +132,13 @@ type HostHooks interface {
132 ClosePanel(extName, panelID string)132 ClosePanel(extName, panelID string)
133 }133 }
134 134
135+// AlertHostHooks is an optional extension to HostHooks. Hosts that support
136+// structured alerts implement it without breaking older embedders that only
137+// implement the original HostHooks surface.
138+type AlertHostHooks interface {
139+ Alert(extName string, alert extproto.AlertRequest)
140+}
141+
135 type commandRegistration struct {142 type commandRegistration struct {
136 ext *Extension143 ext *Extension
137 name string144 name string
@@ -845,6 +852,13 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
845 if err := json.Unmarshal(line, &n); err == nil {852 if err := json.Unmarshal(line, &n); err == nil {
846 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)853 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
847 }854 }
855+ case "alert":
856+ var a extproto.AlertFromExt
857+ if err := json.Unmarshal(line, &a); err == nil {
858+ if hooks, ok := m.hooks.(AlertHostHooks); ok {
859+ hooks.Alert(ext.Manifest.Name, a.AlertRequest)
860+ }
861+ }
848 case "clear_notes":862 case "clear_notes":
849 m.hooks.ClearNotes(ext.Manifest.Name)863 m.hooks.ClearNotes(ext.Manifest.Name)
850 case "submit":864 case "submit":
M packages/agent/extensions/manager_test.go +73 -0
@@ -19,6 +19,8 @@ type stubHooks struct {
1919 mu sync.Mutex
2020 notifies []string
2121 displays []string
22+ alerts []extproto.AlertRequest
23+ alertExts []string
2224 submits []string
2325 submitSlash []string
2426 clearNotes []string
@@ -31,6 +33,12 @@ func (s *stubHooks) Notify(name, level, message string) {
3133 defer s.mu.Unlock()
3234 s.notifies = append(s.notifies, name+":"+level+":"+message)
3335 }
36+func (s *stubHooks) Alert(name string, alert extproto.AlertRequest) {
37+ s.mu.Lock()
38+ defer s.mu.Unlock()
39+ s.alertExts = append(s.alertExts, name)
40+ s.alerts = append(s.alerts, alert)
41+}
3442 func (s *stubHooks) Submit(text string) {
3543 s.mu.Lock()
3644 defer s.mu.Unlock()
@@ -462,6 +470,71 @@ done
462470 }
463471 }
464472
473+// TestSpontaneousAlert verifies that an extension sending an alert frame
474+// causes the manager to call hooks.Alert with the structured request.
475+func TestSpontaneousAlert(t *testing.T) {
476+ if runtime.GOOS == "windows" {
477+ t.Skip("mock extension uses /bin/sh; skip on windows")
478+ }
479+
480+ tmp := t.TempDir()
481+ extDir := filepath.Join(tmp, "extensions", "alert-mock")
482+ if err := os.MkdirAll(extDir, 0o755); err != nil {
483+ t.Fatal(err)
484+ }
485+
486+ script := `#!/bin/sh
487+printf '%s\n' '{"type":"hello","name":"alert-mock","version":"0.1","capabilities":["alerts"]}'
488+printf '%s\n' '{"type":"ready"}'
489+printf '%s\n' '{"type":"alert","kind":"bell","reason":"question_ready"}'
490+while IFS= read -r line; do
491+ case "$line" in
492+ *'"type":"shutdown"'*)
493+ printf '%s\n' '{"type":"shutdown_ack"}'
494+ exit 0
495+ ;;
496+ esac
497+done
498+`
499+ if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
500+ t.Fatal(err)
501+ }
502+ manifest, _ := json.Marshal(map[string]any{"name": "alert-mock", "exec": "./run.sh"})
503+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), manifest, 0o644); err != nil {
504+ t.Fatal(err)
505+ }
506+
507+ hooks := &stubHooks{}
508+ mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-test", hooks)
509+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
510+ t.Fatalf("discover errors: %v", errs)
511+ }
512+ defer mgr.Stop(2 * time.Second)
513+
514+ deadline := time.Now().Add(2 * time.Second)
515+ for time.Now().Before(deadline) {
516+ hooks.mu.Lock()
517+ n := len(hooks.alerts)
518+ hooks.mu.Unlock()
519+ if n > 0 {
520+ break
521+ }
522+ time.Sleep(20 * time.Millisecond)
523+ }
524+
525+ hooks.mu.Lock()
526+ defer hooks.mu.Unlock()
527+ if len(hooks.alerts) != 1 || len(hooks.alertExts) != 1 {
528+ t.Fatalf("alerts = %#v, extensions = %#v; want one alert", hooks.alerts, hooks.alertExts)
529+ }
530+ if hooks.alertExts[0] != "alert-mock" {
531+ t.Fatalf("alert extension = %q, want alert-mock", hooks.alertExts[0])
532+ }
533+ if got := hooks.alerts[0]; got.Kind != extproto.AlertKindBell || got.Reason != "question_ready" {
534+ t.Fatalf("alert = %+v, want bell/question_ready", got)
535+ }
536+}
537+
465538 // TestSpontaneousOpenPanel verifies that an extension sending an
466539 // open_panel frame outside of any command response causes the manager
467540 // to call hooks.OpenPanel with the correct PanelSpec fields.
@@ -19,6 +19,8 @@ type stubHooks struct {
19 mu sync.Mutex19 mu sync.Mutex
20 notifies []string20 notifies []string
21 displays []string21 displays []string
22+ alerts []extproto.AlertRequest
23+ alertExts []string
22 submits []string24 submits []string
23 submitSlash []string25 submitSlash []string
24 clearNotes []string26 clearNotes []string
@@ -31,6 +33,12 @@ func (s *stubHooks) Notify(name, level, message string) {
31 defer s.mu.Unlock()33 defer s.mu.Unlock()
32 s.notifies = append(s.notifies, name+":"+level+":"+message)34 s.notifies = append(s.notifies, name+":"+level+":"+message)
33 }35 }
36+func (s *stubHooks) Alert(name string, alert extproto.AlertRequest) {
37+ s.mu.Lock()
38+ defer s.mu.Unlock()
39+ s.alertExts = append(s.alertExts, name)
40+ s.alerts = append(s.alerts, alert)
41+}
34 func (s *stubHooks) Submit(text string) {42 func (s *stubHooks) Submit(text string) {
35 s.mu.Lock()43 s.mu.Lock()
36 defer s.mu.Unlock()44 defer s.mu.Unlock()
@@ -462,6 +470,71 @@ done
462 }470 }
463 }471 }
464 472
473+// TestSpontaneousAlert verifies that an extension sending an alert frame
474+// causes the manager to call hooks.Alert with the structured request.
475+func TestSpontaneousAlert(t *testing.T) {
476+ if runtime.GOOS == "windows" {
477+ t.Skip("mock extension uses /bin/sh; skip on windows")
478+ }
479+
480+ tmp := t.TempDir()
481+ extDir := filepath.Join(tmp, "extensions", "alert-mock")
482+ if err := os.MkdirAll(extDir, 0o755); err != nil {
483+ t.Fatal(err)
484+ }
485+
486+ script := `#!/bin/sh
487+printf '%s\n' '{"type":"hello","name":"alert-mock","version":"0.1","capabilities":["alerts"]}'
488+printf '%s\n' '{"type":"ready"}'
489+printf '%s\n' '{"type":"alert","kind":"bell","reason":"question_ready"}'
490+while IFS= read -r line; do
491+ case "$line" in
492+ *'"type":"shutdown"'*)
493+ printf '%s\n' '{"type":"shutdown_ack"}'
494+ exit 0
495+ ;;
496+ esac
497+done
498+`
499+ if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
500+ t.Fatal(err)
501+ }
502+ manifest, _ := json.Marshal(map[string]any{"name": "alert-mock", "exec": "./run.sh"})
503+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), manifest, 0o644); err != nil {
504+ t.Fatal(err)
505+ }
506+
507+ hooks := &stubHooks{}
508+ mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-test", hooks)
509+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
510+ t.Fatalf("discover errors: %v", errs)
511+ }
512+ defer mgr.Stop(2 * time.Second)
513+
514+ deadline := time.Now().Add(2 * time.Second)
515+ for time.Now().Before(deadline) {
516+ hooks.mu.Lock()
517+ n := len(hooks.alerts)
518+ hooks.mu.Unlock()
519+ if n > 0 {
520+ break
521+ }
522+ time.Sleep(20 * time.Millisecond)
523+ }
524+
525+ hooks.mu.Lock()
526+ defer hooks.mu.Unlock()
527+ if len(hooks.alerts) != 1 || len(hooks.alertExts) != 1 {
528+ t.Fatalf("alerts = %#v, extensions = %#v; want one alert", hooks.alerts, hooks.alertExts)
529+ }
530+ if hooks.alertExts[0] != "alert-mock" {
531+ t.Fatalf("alert extension = %q, want alert-mock", hooks.alertExts[0])
532+ }
533+ if got := hooks.alerts[0]; got.Kind != extproto.AlertKindBell || got.Reason != "question_ready" {
534+ t.Fatalf("alert = %+v, want bell/question_ready", got)
535+ }
536+}
537+
465 // TestSpontaneousOpenPanel verifies that an extension sending an538 // TestSpontaneousOpenPanel verifies that an extension sending an
466 // open_panel frame outside of any command response causes the manager539 // open_panel frame outside of any command response causes the manager
467 // to call hooks.OpenPanel with the correct PanelSpec fields.540 // to call hooks.OpenPanel with the correct PanelSpec fields.
M packages/agent/extproto/extproto.go +16 -0
@@ -121,6 +121,22 @@ type PanelCloseFromExt struct {
121121 PanelID string `json:"panel_id"`
122122 }
123123
124+const AlertKindBell = "bell"
125+
126+// AlertRequest is a host-owned alert request. Extensions provide semantic
127+// metadata; the host decides whether and how to render it.
128+type AlertRequest struct {
129+ Kind string `json:"kind"`
130+ Reason string `json:"reason,omitempty"`
131+}
132+
133+// AlertFromExt is a spontaneous one-way frame an extension can send at any
134+// time to request a host alert.
135+type AlertFromExt struct {
136+ Type string `json:"type"`
137+ AlertRequest
138+}
139+
124140 type NotifyFromExt struct {
125141 Type string `json:"type"`
126142 Level string `json:"level"`
@@ -121,6 +121,22 @@ type PanelCloseFromExt struct {
121 PanelID string `json:"panel_id"`121 PanelID string `json:"panel_id"`
122 }122 }
123 123
124+const AlertKindBell = "bell"
125+
126+// AlertRequest is a host-owned alert request. Extensions provide semantic
127+// metadata; the host decides whether and how to render it.
128+type AlertRequest struct {
129+ Kind string `json:"kind"`
130+ Reason string `json:"reason,omitempty"`
131+}
132+
133+// AlertFromExt is a spontaneous one-way frame an extension can send at any
134+// time to request a host alert.
135+type AlertFromExt struct {
136+ Type string `json:"type"`
137+ AlertRequest
138+}
139+
124 type NotifyFromExt struct {140 type NotifyFromExt struct {
125 Type string `json:"type"`141 Type string `json:"type"`
126 Level string `json:"level"`142 Level string `json:"level"`
A packages/agent/modes/alert_test.go +259 -0
@@ -0,0 +1,259 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/agent/extproto"
12+ "github.com/patriceckhart/zot/packages/core"
13+ "github.com/patriceckhart/zot/packages/provider"
14+ "github.com/patriceckhart/zot/packages/tui"
15+)
16+
17+type alertTestTerminal struct {
18+ mu sync.Mutex
19+ data []byte
20+}
21+
22+func (t *alertTestTerminal) Write(p []byte) (int, error) {
23+ t.mu.Lock()
24+ defer t.mu.Unlock()
25+ t.data = append(t.data, p...)
26+ return len(p), nil
27+}
28+func (t *alertTestTerminal) WriteString(s string) (int, error) {
29+ t.mu.Lock()
30+ defer t.mu.Unlock()
31+ t.data = append(t.data, s...)
32+ return len(s), nil
33+}
34+func (t *alertTestTerminal) String() string {
35+ t.mu.Lock()
36+ defer t.mu.Unlock()
37+ return string(t.data)
38+}
39+
40+func (*alertTestTerminal) Size() (int, int) { return 80, 24 }
41+func (*alertTestTerminal) OnResize(func()) {}
42+func (*alertTestTerminal) EnterRaw() (func() error, error) {
43+ return func() error { return nil }, nil
44+}
45+func (*alertTestTerminal) ReadByte() (byte, error) { return 0, io.EOF }
46+func (*alertTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) {
47+ return 0, false, nil
48+}
49+func (*alertTestTerminal) SetNonblock(bool) error { return nil }
50+
51+func TestInteractiveAlertUsesSharedTerminalPolicy(t *testing.T) {
52+ term := &alertTestTerminal{}
53+ i := &Interactive{cfg: InteractiveConfig{Terminal: term}}
54+
55+ i.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
56+ if got := term.String(); got != "\a" {
57+ t.Fatalf("alert output = %q, want standalone BEL", got)
58+ }
59+}
60+
61+func TestInteractiveAlertRespectsDisabledPolicy(t *testing.T) {
62+ term := &alertTestTerminal{}
63+ disabled := false
64+ i := &Interactive{cfg: InteractiveConfig{Terminal: term, TerminalAlertsEnabled: &disabled}}
65+
66+ i.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
67+ if got := term.String(); got != "" {
68+ t.Fatalf("disabled alert output = %q, want empty", got)
69+ }
70+}
71+
72+type alertBlockingClient struct{}
73+
74+func (alertBlockingClient) Name() string { return "alert-blocking" }
75+func (alertBlockingClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
76+ out := make(chan provider.Event, 1)
77+ go func() {
78+ defer close(out)
79+ <-ctx.Done()
80+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
81+ }()
82+ return out, nil
83+}
84+
85+func TestCancelledMainTurnDoesNotBell(t *testing.T) {
86+ term := &alertTestTerminal{}
87+ ag := core.NewAgent(alertBlockingClient{}, "test-model", "", nil)
88+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
89+ i.startTurn(context.Background(), "question")
90+
91+ deadline := time.Now().Add(time.Second)
92+ started := false
93+ for time.Now().Before(deadline) {
94+ i.mu.Lock()
95+ busy := i.busy
96+ i.mu.Unlock()
97+ if busy {
98+ started = true
99+ break
100+ }
101+ time.Sleep(time.Millisecond)
102+ }
103+ if !started {
104+ t.Fatal("turn did not enter busy state")
105+ }
106+ i.CancelTurn()
107+
108+ deadline = time.Now().Add(time.Second)
109+ stopped := false
110+ for time.Now().Before(deadline) {
111+ i.mu.Lock()
112+ busy := i.busy
113+ i.mu.Unlock()
114+ if !busy {
115+ stopped = true
116+ break
117+ }
118+ time.Sleep(time.Millisecond)
119+ }
120+ if !stopped {
121+ t.Fatal("turn did not finish after cancellation")
122+ }
123+ if got := term.String(); got != "" {
124+ t.Fatalf("cancelled main turn alert output = %q, want empty", got)
125+ }
126+}
127+
128+func TestMainTurnEmitsBellAfterVisibleResponse(t *testing.T) {
129+ term := &alertTestTerminal{}
130+ client := &streamTestClient{events: []provider.Event{
131+ provider.EventTextDelta{Delta: "answer"},
132+ provider.EventDone{
133+ Stop: provider.StopEnd,
134+ Message: provider.Message{
135+ Role: provider.RoleAssistant,
136+ Content: []provider.Content{provider.TextBlock{Text: "answer"}},
137+ },
138+ },
139+ }}
140+ ag := core.NewAgent(client, "test-model", "", nil)
141+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
142+ i.rend = tui.NewRenderer(io.Discard)
143+ ctx, cancel := context.WithCancel(context.Background())
144+ defer cancel()
145+ i.runCtx = ctx
146+ go i.runStreamPacer(ctx)
147+ i.startTurn(ctx, "question")
148+
149+ deadline := time.Now().Add(time.Second)
150+ for time.Now().Before(deadline) {
151+ i.mu.Lock()
152+ ready := i.pendingAlert != nil && !i.streamFlushPending && len(i.streamPending) == 0
153+ i.mu.Unlock()
154+ if ready {
155+ i.redraw()
156+ break
157+ }
158+ time.Sleep(time.Millisecond)
159+ }
160+ if got := term.String(); got != "\a" {
161+ t.Fatalf("main turn alert output = %q, want BEL after response redraw", got)
162+ }
163+}
164+
165+func TestMainAlertWaitsForPacedText(t *testing.T) {
166+ term := &alertTestTerminal{}
167+ i := NewInteractive(InteractiveConfig{Terminal: term})
168+ i.rend = tui.NewRenderer(io.Discard)
169+ i.streamPending = []rune("final answer")
170+ i.streamFlushPending = true
171+ i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"}
172+ ctx, cancel := context.WithCancel(context.Background())
173+ defer cancel()
174+ go i.runStreamPacer(ctx)
175+
176+ deadline := time.Now().Add(time.Second)
177+ for time.Now().Before(deadline) {
178+ i.mu.Lock()
179+ ready := !i.streamFlushPending && len(i.streamPending) == 0 && i.pendingAlert != nil
180+ i.mu.Unlock()
181+ if ready {
182+ i.redraw()
183+ break
184+ }
185+ time.Sleep(time.Millisecond)
186+ }
187+ if got := term.String(); got != "\a" {
188+ t.Fatalf("paced alert output = %q, want BEL after final redraw", got)
189+ }
190+ i.mu.Lock()
191+ on, pending, alert := i.streamOn, len(i.streamPending), i.pendingAlert
192+ i.mu.Unlock()
193+ if on || pending != 0 || alert != nil {
194+ t.Fatalf("stream state after alert: on=%v pending=%d alert=%v", on, pending, alert)
195+ }
196+}
197+
198+func TestMainAlertReasonPolicy(t *testing.T) {
199+ cancelled, cancel := context.WithCancel(context.Background())
200+ cancel()
201+
202+ tests := []struct {
203+ name string
204+ ctx context.Context
205+ err error
206+ turnErr error
207+ stop provider.StopReason
208+ awaiting bool
209+ queued bool
210+ rescue bool
211+ recovering bool
212+ auto bool
213+ want string
214+ }{
215+ {name: "normal", ctx: context.Background(), stop: provider.StopEnd, want: "agent_done"},
216+ {name: "length", ctx: context.Background(), stop: provider.StopLength, want: "response_truncated"},
217+ {name: "returned error", ctx: context.Background(), err: errors.New("boom"), stop: provider.StopError, want: "agent_error"},
218+ {name: "guard error", ctx: context.Background(), stop: provider.StopError, want: "agent_error"},
219+ {name: "rescue", ctx: context.Background(), err: errors.New("temporary"), stop: provider.StopError, rescue: true, want: "rescue_required"},
220+ {name: "cancelled", ctx: cancelled, stop: provider.StopAborted},
221+ {name: "aborted", ctx: context.Background(), stop: provider.StopAborted},
222+ {name: "queued", ctx: context.Background(), stop: provider.StopEnd, queued: true},
223+ {name: "recovering", ctx: context.Background(), stop: provider.StopError, recovering: true},
224+ {name: "auto compacting", ctx: context.Background(), stop: provider.StopEnd, auto: true},
225+ {name: "startup pre", ctx: context.Background(), stop: provider.StopEnd, awaiting: true},
226+ }
227+ for _, tt := range tests {
228+ t.Run(tt.name, func(t *testing.T) {
229+ got := mainAlertReason(tt.ctx, tt.err, tt.turnErr, tt.stop, tt.awaiting, tt.queued, tt.rescue, tt.recovering, tt.auto)
230+ if got != tt.want {
231+ t.Fatalf("mainAlertReason() = %q, want %q", got, tt.want)
232+ }
233+ })
234+ }
235+}
236+
237+func TestTerminalAlertsSettingDefaultsEnabledAndToggles(t *testing.T) {
238+ i := NewInteractive(InteractiveConfig{Terminal: &alertTestTerminal{}})
239+ i.openSettingsDialog()
240+ var item *settingsItem
241+ for idx := range i.settingsDialog.items {
242+ if i.settingsDialog.items[idx].key == "terminal_alerts_enabled" {
243+ item = &i.settingsDialog.items[idx]
244+ break
245+ }
246+ }
247+ if item == nil || !item.value {
248+ t.Fatal("terminal alerts setting should be present and enabled by default")
249+ }
250+
251+ i.applySettingToggle("terminal_alerts_enabled", false)
252+ if i.cfg.TerminalAlertsEnabled == nil || *i.cfg.TerminalAlertsEnabled {
253+ t.Fatal("terminal alerts setting did not disable")
254+ }
255+ i.applySettingToggle("terminal_alerts_enabled", true)
256+ if i.cfg.TerminalAlertsEnabled == nil || !*i.cfg.TerminalAlertsEnabled {
257+ t.Fatal("terminal alerts setting did not re-enable")
258+ }
259+}
@@ -0,0 +1,259 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/agent/extproto"
12+ "github.com/patriceckhart/zot/packages/core"
13+ "github.com/patriceckhart/zot/packages/provider"
14+ "github.com/patriceckhart/zot/packages/tui"
15+)
16+
17+type alertTestTerminal struct {
18+ mu sync.Mutex
19+ data []byte
20+}
21+
22+func (t *alertTestTerminal) Write(p []byte) (int, error) {
23+ t.mu.Lock()
24+ defer t.mu.Unlock()
25+ t.data = append(t.data, p...)
26+ return len(p), nil
27+}
28+func (t *alertTestTerminal) WriteString(s string) (int, error) {
29+ t.mu.Lock()
30+ defer t.mu.Unlock()
31+ t.data = append(t.data, s...)
32+ return len(s), nil
33+}
34+func (t *alertTestTerminal) String() string {
35+ t.mu.Lock()
36+ defer t.mu.Unlock()
37+ return string(t.data)
38+}
39+
40+func (*alertTestTerminal) Size() (int, int) { return 80, 24 }
41+func (*alertTestTerminal) OnResize(func()) {}
42+func (*alertTestTerminal) EnterRaw() (func() error, error) {
43+ return func() error { return nil }, nil
44+}
45+func (*alertTestTerminal) ReadByte() (byte, error) { return 0, io.EOF }
46+func (*alertTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) {
47+ return 0, false, nil
48+}
49+func (*alertTestTerminal) SetNonblock(bool) error { return nil }
50+
51+func TestInteractiveAlertUsesSharedTerminalPolicy(t *testing.T) {
52+ term := &alertTestTerminal{}
53+ i := &Interactive{cfg: InteractiveConfig{Terminal: term}}
54+
55+ i.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
56+ if got := term.String(); got != "\a" {
57+ t.Fatalf("alert output = %q, want standalone BEL", got)
58+ }
59+}
60+
61+func TestInteractiveAlertRespectsDisabledPolicy(t *testing.T) {
62+ term := &alertTestTerminal{}
63+ disabled := false
64+ i := &Interactive{cfg: InteractiveConfig{Terminal: term, TerminalAlertsEnabled: &disabled}}
65+
66+ i.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
67+ if got := term.String(); got != "" {
68+ t.Fatalf("disabled alert output = %q, want empty", got)
69+ }
70+}
71+
72+type alertBlockingClient struct{}
73+
74+func (alertBlockingClient) Name() string { return "alert-blocking" }
75+func (alertBlockingClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
76+ out := make(chan provider.Event, 1)
77+ go func() {
78+ defer close(out)
79+ <-ctx.Done()
80+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
81+ }()
82+ return out, nil
83+}
84+
85+func TestCancelledMainTurnDoesNotBell(t *testing.T) {
86+ term := &alertTestTerminal{}
87+ ag := core.NewAgent(alertBlockingClient{}, "test-model", "", nil)
88+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
89+ i.startTurn(context.Background(), "question")
90+
91+ deadline := time.Now().Add(time.Second)
92+ started := false
93+ for time.Now().Before(deadline) {
94+ i.mu.Lock()
95+ busy := i.busy
96+ i.mu.Unlock()
97+ if busy {
98+ started = true
99+ break
100+ }
101+ time.Sleep(time.Millisecond)
102+ }
103+ if !started {
104+ t.Fatal("turn did not enter busy state")
105+ }
106+ i.CancelTurn()
107+
108+ deadline = time.Now().Add(time.Second)
109+ stopped := false
110+ for time.Now().Before(deadline) {
111+ i.mu.Lock()
112+ busy := i.busy
113+ i.mu.Unlock()
114+ if !busy {
115+ stopped = true
116+ break
117+ }
118+ time.Sleep(time.Millisecond)
119+ }
120+ if !stopped {
121+ t.Fatal("turn did not finish after cancellation")
122+ }
123+ if got := term.String(); got != "" {
124+ t.Fatalf("cancelled main turn alert output = %q, want empty", got)
125+ }
126+}
127+
128+func TestMainTurnEmitsBellAfterVisibleResponse(t *testing.T) {
129+ term := &alertTestTerminal{}
130+ client := &streamTestClient{events: []provider.Event{
131+ provider.EventTextDelta{Delta: "answer"},
132+ provider.EventDone{
133+ Stop: provider.StopEnd,
134+ Message: provider.Message{
135+ Role: provider.RoleAssistant,
136+ Content: []provider.Content{provider.TextBlock{Text: "answer"}},
137+ },
138+ },
139+ }}
140+ ag := core.NewAgent(client, "test-model", "", nil)
141+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
142+ i.rend = tui.NewRenderer(io.Discard)
143+ ctx, cancel := context.WithCancel(context.Background())
144+ defer cancel()
145+ i.runCtx = ctx
146+ go i.runStreamPacer(ctx)
147+ i.startTurn(ctx, "question")
148+
149+ deadline := time.Now().Add(time.Second)
150+ for time.Now().Before(deadline) {
151+ i.mu.Lock()
152+ ready := i.pendingAlert != nil && !i.streamFlushPending && len(i.streamPending) == 0
153+ i.mu.Unlock()
154+ if ready {
155+ i.redraw()
156+ break
157+ }
158+ time.Sleep(time.Millisecond)
159+ }
160+ if got := term.String(); got != "\a" {
161+ t.Fatalf("main turn alert output = %q, want BEL after response redraw", got)
162+ }
163+}
164+
165+func TestMainAlertWaitsForPacedText(t *testing.T) {
166+ term := &alertTestTerminal{}
167+ i := NewInteractive(InteractiveConfig{Terminal: term})
168+ i.rend = tui.NewRenderer(io.Discard)
169+ i.streamPending = []rune("final answer")
170+ i.streamFlushPending = true
171+ i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"}
172+ ctx, cancel := context.WithCancel(context.Background())
173+ defer cancel()
174+ go i.runStreamPacer(ctx)
175+
176+ deadline := time.Now().Add(time.Second)
177+ for time.Now().Before(deadline) {
178+ i.mu.Lock()
179+ ready := !i.streamFlushPending && len(i.streamPending) == 0 && i.pendingAlert != nil
180+ i.mu.Unlock()
181+ if ready {
182+ i.redraw()
183+ break
184+ }
185+ time.Sleep(time.Millisecond)
186+ }
187+ if got := term.String(); got != "\a" {
188+ t.Fatalf("paced alert output = %q, want BEL after final redraw", got)
189+ }
190+ i.mu.Lock()
191+ on, pending, alert := i.streamOn, len(i.streamPending), i.pendingAlert
192+ i.mu.Unlock()
193+ if on || pending != 0 || alert != nil {
194+ t.Fatalf("stream state after alert: on=%v pending=%d alert=%v", on, pending, alert)
195+ }
196+}
197+
198+func TestMainAlertReasonPolicy(t *testing.T) {
199+ cancelled, cancel := context.WithCancel(context.Background())
200+ cancel()
201+
202+ tests := []struct {
203+ name string
204+ ctx context.Context
205+ err error
206+ turnErr error
207+ stop provider.StopReason
208+ awaiting bool
209+ queued bool
210+ rescue bool
211+ recovering bool
212+ auto bool
213+ want string
214+ }{
215+ {name: "normal", ctx: context.Background(), stop: provider.StopEnd, want: "agent_done"},
216+ {name: "length", ctx: context.Background(), stop: provider.StopLength, want: "response_truncated"},
217+ {name: "returned error", ctx: context.Background(), err: errors.New("boom"), stop: provider.StopError, want: "agent_error"},
218+ {name: "guard error", ctx: context.Background(), stop: provider.StopError, want: "agent_error"},
219+ {name: "rescue", ctx: context.Background(), err: errors.New("temporary"), stop: provider.StopError, rescue: true, want: "rescue_required"},
220+ {name: "cancelled", ctx: cancelled, stop: provider.StopAborted},
221+ {name: "aborted", ctx: context.Background(), stop: provider.StopAborted},
222+ {name: "queued", ctx: context.Background(), stop: provider.StopEnd, queued: true},
223+ {name: "recovering", ctx: context.Background(), stop: provider.StopError, recovering: true},
224+ {name: "auto compacting", ctx: context.Background(), stop: provider.StopEnd, auto: true},
225+ {name: "startup pre", ctx: context.Background(), stop: provider.StopEnd, awaiting: true},
226+ }
227+ for _, tt := range tests {
228+ t.Run(tt.name, func(t *testing.T) {
229+ got := mainAlertReason(tt.ctx, tt.err, tt.turnErr, tt.stop, tt.awaiting, tt.queued, tt.rescue, tt.recovering, tt.auto)
230+ if got != tt.want {
231+ t.Fatalf("mainAlertReason() = %q, want %q", got, tt.want)
232+ }
233+ })
234+ }
235+}
236+
237+func TestTerminalAlertsSettingDefaultsEnabledAndToggles(t *testing.T) {
238+ i := NewInteractive(InteractiveConfig{Terminal: &alertTestTerminal{}})
239+ i.openSettingsDialog()
240+ var item *settingsItem
241+ for idx := range i.settingsDialog.items {
242+ if i.settingsDialog.items[idx].key == "terminal_alerts_enabled" {
243+ item = &i.settingsDialog.items[idx]
244+ break
245+ }
246+ }
247+ if item == nil || !item.value {
248+ t.Fatal("terminal alerts setting should be present and enabled by default")
249+ }
250+
251+ i.applySettingToggle("terminal_alerts_enabled", false)
252+ if i.cfg.TerminalAlertsEnabled == nil || *i.cfg.TerminalAlertsEnabled {
253+ t.Fatal("terminal alerts setting did not disable")
254+ }
255+ i.applySettingToggle("terminal_alerts_enabled", true)
256+ if i.cfg.TerminalAlertsEnabled == nil || !*i.cfg.TerminalAlertsEnabled {
257+ t.Fatal("terminal alerts setting did not re-enable")
258+ }
259+}
M packages/agent/modes/interactive.go +105 -0
@@ -59,6 +59,10 @@ type InteractiveConfig struct {
5959 // the detected protocol when available.
6060 InlineImagesEnabled *bool
6161
62+ // TerminalAlertsEnabled controls interactive alerts from the main
63+ // agent and extensions. nil means enabled; false disables them.
64+ TerminalAlertsEnabled *bool
65+
6266 // AutoSwarmEnabled mirrors the persisted config flag at startup so
6367 // the /settings dialog can render the current state without
6468 // re-reading config.json on every open.
@@ -354,6 +358,10 @@ type SettingsStore interface {
354358 SetTheme(name string) error
355359 }
356360
361+type terminalAlertsSettingsStore interface {
362+ SetTerminalAlertsEnabled(enabled bool) error
363+}
364+
357365 type showInstructionsSettingsStore interface {
358366 SetShowInstructionsAtStartup(enabled bool) error
359367 }
@@ -376,6 +384,7 @@ type Interactive struct {
376384 managedAutoSwarmAddenda []string
377385 streaming strings.Builder // what's currently painted on screen
378386 streamOn bool
387+ pendingAlert *extproto.AlertRequest
379388
380389 // streamPending is the runes buffered after each EvTextDelta that
381390 // haven't yet been promoted into `streaming` for rendering. It
@@ -1755,6 +1764,11 @@ func (i *Interactive) redraw() {
17551764 }
17561765 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
17571766 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1767+ if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
1768+ alert := *i.pendingAlert
1769+ i.pendingAlert = nil
1770+ i.emitAlertLocked(alert)
1771+ }
17581772 }
17591773
17601774 func hasImageEscape(line string) bool {
@@ -2978,6 +2992,41 @@ func (i *Interactive) Notify(extName, level, message string) {
29782992 i.invalidate()
29792993 }
29802994
2995+// Alert is the manager's AlertFromExt entry point. Alerts are emitted
2996+// through the same terminal writer as redraws while holding the TUI mutex,
2997+// so a BEL cannot be interleaved with a frame update.
2998+func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) {
2999+ i.mu.Lock()
3000+ i.emitAlertLocked(alert)
3001+ i.mu.Unlock()
3002+}
3003+
3004+func terminalAlertsEnabled(enabled *bool) bool {
3005+ return enabled == nil || *enabled
3006+}
3007+
3008+// emitAlertLocked applies the shared terminal-alert policy. The caller must
3009+// hold i.mu because terminal writes share the renderer's output boundary.
3010+func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) {
3011+ if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil {
3012+ return
3013+ }
3014+ _ = tui.WriteBell(i.cfg.Terminal)
3015+}
3016+
3017+// scheduleMainAlert defers a main-session alert until the next redraw
3018+// commits the final frame. Keeping this deferred even when no paced text is
3019+// pending avoids racing the pacer's final state transition.
3020+func (i *Interactive) scheduleMainAlert(reason string) {
3021+ if reason == "" {
3022+ return
3023+ }
3024+ i.mu.Lock()
3025+ i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
3026+ i.mu.Unlock()
3027+ i.invalidate()
3028+}
3029+
29813030 // ClearNotes removes every note line owned by extName from the
29823031 // bottom-sticky ext-notes block. Extensions use this to retract a
29833032 // transient status line (e.g. an approval prompt) once it no longer
@@ -3350,6 +3399,7 @@ func (i *Interactive) openSettingsDialog() {
33503399 imgHint = "terminal supports " + imageProtocolName(detected)
33513400 }
33523401
3402+ terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
33533403 autoSwarm := false
33543404 if i.cfg.AutoSwarmEnabled != nil {
33553405 autoSwarm = *i.cfg.AutoSwarmEnabled
@@ -3459,6 +3509,12 @@ func (i *Interactive) openSettingsDialog() {
34593509 disabled: imgDisabled,
34603510 hint: imgHint,
34613511 },
3512+ {
3513+ key: "terminal_alerts_enabled",
3514+ label: "terminal alerts",
3515+ desc: "ring the terminal when the main session needs attention or an extension raises an alert",
3516+ value: terminalAlerts,
3517+ },
34623518 {
34633519 key: "auto_swarm_enabled",
34643520 label: "auto-swarm",
@@ -3794,6 +3850,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
37943850 i.statusOK = "inline image rendering " + onOff(value)
37953851 i.statusErr = ""
37963852 i.mu.Unlock()
3853+ case "terminal_alerts_enabled":
3854+ val := value
3855+ i.mu.Lock()
3856+ i.cfg.TerminalAlertsEnabled = &val
3857+ i.mu.Unlock()
3858+ if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok {
3859+ if err := store.SetTerminalAlertsEnabled(value); err != nil {
3860+ i.mu.Lock()
3861+ i.statusErr = "settings: " + err.Error()
3862+ i.mu.Unlock()
3863+ return
3864+ }
3865+ }
3866+ i.mu.Lock()
3867+ i.statusOK = "terminal alerts " + onOff(value)
3868+ i.statusErr = ""
3869+ i.mu.Unlock()
37973870 case "auto_swarm_enabled":
37983871 val := value
37993872 i.cfg.AutoSwarmEnabled = &val
@@ -5695,6 +5768,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
56955768 i.statusOK = ""
56965769 i.streaming.Reset()
56975770 i.streamOn = true
5771+ i.pendingAlert = nil
56985772 i.toolCalls = map[string]*tui.ToolCallView{}
56995773 i.toolOrder = nil
57005774 i.toolGate = map[string]int{}
@@ -5727,7 +5801,15 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
57275801 i.mu.Unlock()
57285802 i.invalidate()
57295803
5804+ var (
5805+ lastStop provider.StopReason
5806+ lastTurnErr error
5807+ )
57305808 sink := func(ev core.AgentEvent) {
5809+ if e, ok := ev.(core.EvTurnEnd); ok {
5810+ lastStop = e.Stop
5811+ lastTurnErr = e.Err
5812+ }
57315813 i.handleEvent(ev)
57325814 i.invalidate()
57335815 }
@@ -5829,7 +5911,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
58295911 agentQueued = i.agent.QueuedMessageCount()
58305912 }
58315913 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
5914+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
58325915 i.mu.Unlock()
5916+ if alertReason != "" {
5917+ i.scheduleMainAlert(alertReason)
5918+ }
58335919 i.invalidate()
58345920 parent := i.runCtx
58355921 if parent == nil {
@@ -5852,6 +5938,25 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
58525938 }()
58535939 }
58545940
5941+func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
5942+ if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
5943+ return ""
5944+ }
5945+ if rescue {
5946+ return "rescue_required"
5947+ }
5948+ if stop == provider.StopAborted {
5949+ return ""
5950+ }
5951+ if err != nil || turnErr != nil || stop == provider.StopError {
5952+ return "agent_error"
5953+ }
5954+ if stop == provider.StopLength {
5955+ return "response_truncated"
5956+ }
5957+ return "agent_done"
5958+}
5959+
58555960 // openRescueDialog surfaces the rescue model picker after a recoverable
58565961 // provider failure. The pending prompt + images are stashed on the
58575962 // Interactive so a later applyRescueSelection can re-run the same turn
@@ -59,6 +59,10 @@ type InteractiveConfig struct {
59 // the detected protocol when available.59 // the detected protocol when available.
60 InlineImagesEnabled *bool60 InlineImagesEnabled *bool
61 61
62+ // TerminalAlertsEnabled controls interactive alerts from the main
63+ // agent and extensions. nil means enabled; false disables them.
64+ TerminalAlertsEnabled *bool
65+
62 // AutoSwarmEnabled mirrors the persisted config flag at startup so66 // AutoSwarmEnabled mirrors the persisted config flag at startup so
63 // the /settings dialog can render the current state without67 // the /settings dialog can render the current state without
64 // re-reading config.json on every open.68 // re-reading config.json on every open.
@@ -354,6 +358,10 @@ type SettingsStore interface {
354 SetTheme(name string) error358 SetTheme(name string) error
355 }359 }
356 360
361+type terminalAlertsSettingsStore interface {
362+ SetTerminalAlertsEnabled(enabled bool) error
363+}
364+
357 type showInstructionsSettingsStore interface {365 type showInstructionsSettingsStore interface {
358 SetShowInstructionsAtStartup(enabled bool) error366 SetShowInstructionsAtStartup(enabled bool) error
359 }367 }
@@ -376,6 +384,7 @@ type Interactive struct {
376 managedAutoSwarmAddenda []string384 managedAutoSwarmAddenda []string
377 streaming strings.Builder // what's currently painted on screen385 streaming strings.Builder // what's currently painted on screen
378 streamOn bool386 streamOn bool
387+ pendingAlert *extproto.AlertRequest
379 388
380 // streamPending is the runes buffered after each EvTextDelta that389 // streamPending is the runes buffered after each EvTextDelta that
381 // haven't yet been promoted into `streaming` for rendering. It390 // haven't yet been promoted into `streaming` for rendering. It
@@ -1755,6 +1764,11 @@ func (i *Interactive) redraw() {
1755 }1764 }
1756 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.1765 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
1757 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)1766 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1767+ if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
1768+ alert := *i.pendingAlert
1769+ i.pendingAlert = nil
1770+ i.emitAlertLocked(alert)
1771+ }
1758 }1772 }
1759 1773
1760 func hasImageEscape(line string) bool {1774 func hasImageEscape(line string) bool {
@@ -2978,6 +2992,41 @@ func (i *Interactive) Notify(extName, level, message string) {
2978 i.invalidate()2992 i.invalidate()
2979 }2993 }
2980 2994
2995+// Alert is the manager's AlertFromExt entry point. Alerts are emitted
2996+// through the same terminal writer as redraws while holding the TUI mutex,
2997+// so a BEL cannot be interleaved with a frame update.
2998+func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) {
2999+ i.mu.Lock()
3000+ i.emitAlertLocked(alert)
3001+ i.mu.Unlock()
3002+}
3003+
3004+func terminalAlertsEnabled(enabled *bool) bool {
3005+ return enabled == nil || *enabled
3006+}
3007+
3008+// emitAlertLocked applies the shared terminal-alert policy. The caller must
3009+// hold i.mu because terminal writes share the renderer's output boundary.
3010+func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) {
3011+ if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil {
3012+ return
3013+ }
3014+ _ = tui.WriteBell(i.cfg.Terminal)
3015+}
3016+
3017+// scheduleMainAlert defers a main-session alert until the next redraw
3018+// commits the final frame. Keeping this deferred even when no paced text is
3019+// pending avoids racing the pacer's final state transition.
3020+func (i *Interactive) scheduleMainAlert(reason string) {
3021+ if reason == "" {
3022+ return
3023+ }
3024+ i.mu.Lock()
3025+ i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
3026+ i.mu.Unlock()
3027+ i.invalidate()
3028+}
3029+
2981 // ClearNotes removes every note line owned by extName from the3030 // ClearNotes removes every note line owned by extName from the
2982 // bottom-sticky ext-notes block. Extensions use this to retract a3031 // bottom-sticky ext-notes block. Extensions use this to retract a
2983 // transient status line (e.g. an approval prompt) once it no longer3032 // transient status line (e.g. an approval prompt) once it no longer
@@ -3350,6 +3399,7 @@ func (i *Interactive) openSettingsDialog() {
3350 imgHint = "terminal supports " + imageProtocolName(detected)3399 imgHint = "terminal supports " + imageProtocolName(detected)
3351 }3400 }
3352 3401
3402+ terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
3353 autoSwarm := false3403 autoSwarm := false
3354 if i.cfg.AutoSwarmEnabled != nil {3404 if i.cfg.AutoSwarmEnabled != nil {
3355 autoSwarm = *i.cfg.AutoSwarmEnabled3405 autoSwarm = *i.cfg.AutoSwarmEnabled
@@ -3459,6 +3509,12 @@ func (i *Interactive) openSettingsDialog() {
3459 disabled: imgDisabled,3509 disabled: imgDisabled,
3460 hint: imgHint,3510 hint: imgHint,
3461 },3511 },
3512+ {
3513+ key: "terminal_alerts_enabled",
3514+ label: "terminal alerts",
3515+ desc: "ring the terminal when the main session needs attention or an extension raises an alert",
3516+ value: terminalAlerts,
3517+ },
3462 {3518 {
3463 key: "auto_swarm_enabled",3519 key: "auto_swarm_enabled",
3464 label: "auto-swarm",3520 label: "auto-swarm",
@@ -3794,6 +3850,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
3794 i.statusOK = "inline image rendering " + onOff(value)3850 i.statusOK = "inline image rendering " + onOff(value)
3795 i.statusErr = ""3851 i.statusErr = ""
3796 i.mu.Unlock()3852 i.mu.Unlock()
3853+ case "terminal_alerts_enabled":
3854+ val := value
3855+ i.mu.Lock()
3856+ i.cfg.TerminalAlertsEnabled = &val
3857+ i.mu.Unlock()
3858+ if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok {
3859+ if err := store.SetTerminalAlertsEnabled(value); err != nil {
3860+ i.mu.Lock()
3861+ i.statusErr = "settings: " + err.Error()
3862+ i.mu.Unlock()
3863+ return
3864+ }
3865+ }
3866+ i.mu.Lock()
3867+ i.statusOK = "terminal alerts " + onOff(value)
3868+ i.statusErr = ""
3869+ i.mu.Unlock()
3797 case "auto_swarm_enabled":3870 case "auto_swarm_enabled":
3798 val := value3871 val := value
3799 i.cfg.AutoSwarmEnabled = &val3872 i.cfg.AutoSwarmEnabled = &val
@@ -5695,6 +5768,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5695 i.statusOK = ""5768 i.statusOK = ""
5696 i.streaming.Reset()5769 i.streaming.Reset()
5697 i.streamOn = true5770 i.streamOn = true
5771+ i.pendingAlert = nil
5698 i.toolCalls = map[string]*tui.ToolCallView{}5772 i.toolCalls = map[string]*tui.ToolCallView{}
5699 i.toolOrder = nil5773 i.toolOrder = nil
5700 i.toolGate = map[string]int{}5774 i.toolGate = map[string]int{}
@@ -5727,7 +5801,15 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5727 i.mu.Unlock()5801 i.mu.Unlock()
5728 i.invalidate()5802 i.invalidate()
5729 5803
5804+ var (
5805+ lastStop provider.StopReason
5806+ lastTurnErr error
5807+ )
5730 sink := func(ev core.AgentEvent) {5808 sink := func(ev core.AgentEvent) {
5809+ if e, ok := ev.(core.EvTurnEnd); ok {
5810+ lastStop = e.Stop
5811+ lastTurnErr = e.Err
5812+ }
5731 i.handleEvent(ev)5813 i.handleEvent(ev)
5732 i.invalidate()5814 i.invalidate()
5733 }5815 }
@@ -5829,7 +5911,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5829 agentQueued = i.agent.QueuedMessageCount()5911 agentQueued = i.agent.QueuedMessageCount()
5830 }5912 }
5831 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()5913 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
5914+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
5832 i.mu.Unlock()5915 i.mu.Unlock()
5916+ if alertReason != "" {
5917+ i.scheduleMainAlert(alertReason)
5918+ }
5833 i.invalidate()5919 i.invalidate()
5834 parent := i.runCtx5920 parent := i.runCtx
5835 if parent == nil {5921 if parent == nil {
@@ -5852,6 +5938,25 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5852 }()5938 }()
5853 }5939 }
5854 5940
5941+func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
5942+ if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
5943+ return ""
5944+ }
5945+ if rescue {
5946+ return "rescue_required"
5947+ }
5948+ if stop == provider.StopAborted {
5949+ return ""
5950+ }
5951+ if err != nil || turnErr != nil || stop == provider.StopError {
5952+ return "agent_error"
5953+ }
5954+ if stop == provider.StopLength {
5955+ return "response_truncated"
5956+ }
5957+ return "agent_done"
5958+}
5959+
5855 // openRescueDialog surfaces the rescue model picker after a recoverable5960 // openRescueDialog surfaces the rescue model picker after a recoverable
5856 // provider failure. The pending prompt + images are stashed on the5961 // provider failure. The pending prompt + images are stashed on the
5857 // Interactive so a later applyRescueSelection can re-run the same turn5962 // Interactive so a later applyRescueSelection can re-run the same turn
M packages/agent/rpc.go +102 -26
@@ -49,10 +49,22 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
4949 return err
5050 }
5151
52+ // Construct the RPC event sink before extension startup. Alerts can
53+ // arrive as soon as an extension is ready; rpcServer queues them until
54+ // the command loop has started and, when configured, authenticated.
55+ server := &rpcServer{
56+ ctx: ctx,
57+ args: args,
58+ provider: r.Provider,
59+ model: r.Model,
60+ out: os.Stdout,
61+ version: version,
62+ }
63+
5264 // Extensions: same lifecycle as interactive mode, minus the
5365 // host-hooks integration. Notify/Display calls from extensions
5466 // emit RPC events instead of TUI lines so any consumer can react.
55- extHooks := &rpcExtHooks{}
67+ extHooks := &rpcExtHooks{server: server}
5668 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
5769 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
5870 fmt.Fprintln(os.Stderr, "extension load:", e)
@@ -67,6 +79,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
6779 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
6880
6981 ag := r.NewAgent()
82+ server.agent = ag
7083 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
7184 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
7285 if r.Block {
@@ -103,16 +116,6 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
103116
104117 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
105118
106- server := &rpcServer{
107- ctx: ctx,
108- args: args,
109- agent: ag,
110- provider: r.Provider,
111- model: r.Model,
112- out: os.Stdout,
113- version: version,
114- }
115- extHooks.server = server
116119 return server.run(os.Stdin)
117120 }
118121
@@ -126,7 +129,7 @@ type rpcExtHooks struct {
126129
127130 func (h *rpcExtHooks) Notify(extName, level, message string) {
128131 if h.server != nil {
129- h.server.writeEvent(map[string]any{
132+ h.server.writeExtensionEvent(map[string]any{
130133 "type": "ext_notify",
131134 "extension": extName,
132135 "level": level,
@@ -134,9 +137,19 @@ func (h *rpcExtHooks) Notify(extName, level, message string) {
134137 })
135138 }
136139 }
140+func (h *rpcExtHooks) Alert(extName string, alert extproto.AlertRequest) {
141+ if h.server != nil {
142+ h.server.writeExtensionEvent(map[string]any{
143+ "type": "ext_alert",
144+ "extension": extName,
145+ "kind": alert.Kind,
146+ "reason": alert.Reason,
147+ })
148+ }
149+}
137150 func (h *rpcExtHooks) Display(extName, text string) {
138151 if h.server != nil {
139- h.server.writeEvent(map[string]any{
152+ h.server.writeExtensionEvent(map[string]any{
140153 "type": "ext_display",
141154 "extension": extName,
142155 "text": text,
@@ -145,7 +158,7 @@ func (h *rpcExtHooks) Display(extName, text string) {
145158 }
146159 func (h *rpcExtHooks) ClearNotes(extName string) {
147160 if h.server != nil {
148- h.server.writeEvent(map[string]any{
161+ h.server.writeExtensionEvent(map[string]any{
149162 "type": "ext_clear_notes",
150163 "extension": extName,
151164 })
@@ -158,6 +171,8 @@ func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}
158171 func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}
159172 func (h *rpcExtHooks) ClosePanel(string, string) {}
160173
174+const maxPendingRPCExtEvents = 64
175+
161176 type rpcServer struct {
162177 ctx context.Context
163178 args Args
@@ -167,10 +182,12 @@ type rpcServer struct {
167182 out io.Writer
168183 version string
169184
170- writeMu sync.Mutex
171- turnMu sync.Mutex // serialises one prompt at a time
172- activeCancel context.CancelFunc
173- authed bool
185+ writeMu sync.Mutex
186+ turnMu sync.Mutex // serialises one prompt at a time
187+ activeCancel context.CancelFunc
188+ started bool
189+ authed bool
190+ pendingExtEvents [][]byte
174191
175192 // inFlight tracks long-running command goroutines so run() can
176193 // wait for them before returning when stdin closes. Without this,
@@ -186,7 +203,13 @@ type rpcServer struct {
186203 // still produces full output before the process exits.
187204 func (s *rpcServer) run(in io.Reader) error {
188205 requireToken := os.Getenv("ZOTCORE_RPC_TOKEN") != ""
206+ s.writeMu.Lock()
207+ s.started = true
189208 s.authed = !requireToken
209+ if s.authed {
210+ s.flushPendingExtEventsLocked()
211+ }
212+ s.writeMu.Unlock()
190213
191214 sc := bufio.NewScanner(in)
192215 sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
@@ -203,7 +226,7 @@ func (s *rpcServer) run(in io.Reader) error {
203226 s.writeError("", "", fmt.Sprintf("malformed json: %v", err))
204227 continue
205228 }
206- if !s.authed {
229+ if !s.isAuthed() {
207230 if head.Type != "hello" {
208231 s.writeError(head.ID, head.Type, "auth required: send hello with token first")
209232 continue
@@ -216,13 +239,7 @@ func (s *rpcServer) run(in io.Reader) error {
216239 s.writeError(head.ID, head.Type, "invalid token")
217240 return fmt.Errorf("rpc: bad auth token")
218241 }
219- s.authed = true
220- s.writeResponse(head.ID, head.Type, map[string]any{
221- "protocol_version": 1,
222- "version": s.version,
223- "provider": s.provider,
224- "model": s.model,
225- })
242+ s.authenticate(head.ID, head.Type)
226243 continue
227244 }
228245 s.dispatch(head.Type, head.ID, []byte(line))
@@ -463,6 +480,61 @@ func (s *rpcServer) writeEvent(payload map[string]any) {
463480 s.write(payload)
464481 }
465482
483+func (s *rpcServer) writeExtensionEvent(payload map[string]any) {
484+ b, err := json.Marshal(payload)
485+ if err != nil {
486+ return
487+ }
488+ s.writeMu.Lock()
489+ defer s.writeMu.Unlock()
490+ if !s.started || !s.authed {
491+ if len(s.pendingExtEvents) < maxPendingRPCExtEvents {
492+ s.pendingExtEvents = append(s.pendingExtEvents, b)
493+ }
494+ return
495+ }
496+ s.writeLocked(b)
497+}
498+
499+func (s *rpcServer) flushPendingExtEventsLocked() {
500+ for _, b := range s.pendingExtEvents {
501+ s.writeLocked(b)
502+ }
503+ s.pendingExtEvents = nil
504+}
505+
506+func (s *rpcServer) isAuthed() bool {
507+ s.writeMu.Lock()
508+ defer s.writeMu.Unlock()
509+ return s.authed
510+}
511+
512+func (s *rpcServer) authenticate(id, cmd string) {
513+ frame := map[string]any{
514+ "type": "response",
515+ "command": cmd,
516+ "success": true,
517+ "data": map[string]any{
518+ "protocol_version": 1,
519+ "version": s.version,
520+ "provider": s.provider,
521+ "model": s.model,
522+ },
523+ }
524+ if id != "" {
525+ frame["id"] = id
526+ }
527+ b, err := json.Marshal(frame)
528+ if err != nil {
529+ return
530+ }
531+ s.writeMu.Lock()
532+ defer s.writeMu.Unlock()
533+ s.authed = true
534+ s.writeLocked(b)
535+ s.flushPendingExtEventsLocked()
536+}
537+
466538 func (s *rpcServer) write(v any) {
467539 b, err := json.Marshal(v)
468540 if err != nil {
@@ -470,6 +542,10 @@ func (s *rpcServer) write(v any) {
470542 }
471543 s.writeMu.Lock()
472544 defer s.writeMu.Unlock()
545+ s.writeLocked(b)
546+}
547+
548+func (s *rpcServer) writeLocked(b []byte) {
473549 _, _ = s.out.Write(b)
474550 _, _ = s.out.Write([]byte("\n"))
475551 }
@@ -49,10 +49,22 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
49 return err49 return err
50 }50 }
51 51
52+ // Construct the RPC event sink before extension startup. Alerts can
53+ // arrive as soon as an extension is ready; rpcServer queues them until
54+ // the command loop has started and, when configured, authenticated.
55+ server := &rpcServer{
56+ ctx: ctx,
57+ args: args,
58+ provider: r.Provider,
59+ model: r.Model,
60+ out: os.Stdout,
61+ version: version,
62+ }
63+
52 // Extensions: same lifecycle as interactive mode, minus the64 // Extensions: same lifecycle as interactive mode, minus the
53 // host-hooks integration. Notify/Display calls from extensions65 // host-hooks integration. Notify/Display calls from extensions
54 // emit RPC events instead of TUI lines so any consumer can react.66 // emit RPC events instead of TUI lines so any consumer can react.
55- extHooks := &rpcExtHooks{}67+ extHooks := &rpcExtHooks{server: server}
56 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)68 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
57 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {69 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
58 fmt.Fprintln(os.Stderr, "extension load:", e)70 fmt.Fprintln(os.Stderr, "extension load:", e)
@@ -67,6 +79,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
67 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})79 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
68 80
69 ag := r.NewAgent()81 ag := r.NewAgent()
82+ server.agent = ag
70 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {83 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
71 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)84 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
72 if r.Block {85 if r.Block {
@@ -103,16 +116,6 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
103 116
104 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})117 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
105 118
106- server := &rpcServer{
107- ctx: ctx,
108- args: args,
109- agent: ag,
110- provider: r.Provider,
111- model: r.Model,
112- out: os.Stdout,
113- version: version,
114- }
115- extHooks.server = server
116 return server.run(os.Stdin)119 return server.run(os.Stdin)
117 }120 }
118 121
@@ -126,7 +129,7 @@ type rpcExtHooks struct {
126 129
127 func (h *rpcExtHooks) Notify(extName, level, message string) {130 func (h *rpcExtHooks) Notify(extName, level, message string) {
128 if h.server != nil {131 if h.server != nil {
129- h.server.writeEvent(map[string]any{132+ h.server.writeExtensionEvent(map[string]any{
130 "type": "ext_notify",133 "type": "ext_notify",
131 "extension": extName,134 "extension": extName,
132 "level": level,135 "level": level,
@@ -134,9 +137,19 @@ func (h *rpcExtHooks) Notify(extName, level, message string) {
134 })137 })
135 }138 }
136 }139 }
140+func (h *rpcExtHooks) Alert(extName string, alert extproto.AlertRequest) {
141+ if h.server != nil {
142+ h.server.writeExtensionEvent(map[string]any{
143+ "type": "ext_alert",
144+ "extension": extName,
145+ "kind": alert.Kind,
146+ "reason": alert.Reason,
147+ })
148+ }
149+}
137 func (h *rpcExtHooks) Display(extName, text string) {150 func (h *rpcExtHooks) Display(extName, text string) {
138 if h.server != nil {151 if h.server != nil {
139- h.server.writeEvent(map[string]any{152+ h.server.writeExtensionEvent(map[string]any{
140 "type": "ext_display",153 "type": "ext_display",
141 "extension": extName,154 "extension": extName,
142 "text": text,155 "text": text,
@@ -145,7 +158,7 @@ func (h *rpcExtHooks) Display(extName, text string) {
145 }158 }
146 func (h *rpcExtHooks) ClearNotes(extName string) {159 func (h *rpcExtHooks) ClearNotes(extName string) {
147 if h.server != nil {160 if h.server != nil {
148- h.server.writeEvent(map[string]any{161+ h.server.writeExtensionEvent(map[string]any{
149 "type": "ext_clear_notes",162 "type": "ext_clear_notes",
150 "extension": extName,163 "extension": extName,
151 })164 })
@@ -158,6 +171,8 @@ func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}
158 func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}171 func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}
159 func (h *rpcExtHooks) ClosePanel(string, string) {}172 func (h *rpcExtHooks) ClosePanel(string, string) {}
160 173
174+const maxPendingRPCExtEvents = 64
175+
161 type rpcServer struct {176 type rpcServer struct {
162 ctx context.Context177 ctx context.Context
163 args Args178 args Args
@@ -167,10 +182,12 @@ type rpcServer struct {
167 out io.Writer182 out io.Writer
168 version string183 version string
169 184
170- writeMu sync.Mutex185+ writeMu sync.Mutex
171- turnMu sync.Mutex // serialises one prompt at a time186+ turnMu sync.Mutex // serialises one prompt at a time
172- activeCancel context.CancelFunc187+ activeCancel context.CancelFunc
173- authed bool188+ started bool
189+ authed bool
190+ pendingExtEvents [][]byte
174 191
175 // inFlight tracks long-running command goroutines so run() can192 // inFlight tracks long-running command goroutines so run() can
176 // wait for them before returning when stdin closes. Without this,193 // wait for them before returning when stdin closes. Without this,
@@ -186,7 +203,13 @@ type rpcServer struct {
186 // still produces full output before the process exits.203 // still produces full output before the process exits.
187 func (s *rpcServer) run(in io.Reader) error {204 func (s *rpcServer) run(in io.Reader) error {
188 requireToken := os.Getenv("ZOTCORE_RPC_TOKEN") != ""205 requireToken := os.Getenv("ZOTCORE_RPC_TOKEN") != ""
206+ s.writeMu.Lock()
207+ s.started = true
189 s.authed = !requireToken208 s.authed = !requireToken
209+ if s.authed {
210+ s.flushPendingExtEventsLocked()
211+ }
212+ s.writeMu.Unlock()
190 213
191 sc := bufio.NewScanner(in)214 sc := bufio.NewScanner(in)
192 sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)215 sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
@@ -203,7 +226,7 @@ func (s *rpcServer) run(in io.Reader) error {
203 s.writeError("", "", fmt.Sprintf("malformed json: %v", err))226 s.writeError("", "", fmt.Sprintf("malformed json: %v", err))
204 continue227 continue
205 }228 }
206- if !s.authed {229+ if !s.isAuthed() {
207 if head.Type != "hello" {230 if head.Type != "hello" {
208 s.writeError(head.ID, head.Type, "auth required: send hello with token first")231 s.writeError(head.ID, head.Type, "auth required: send hello with token first")
209 continue232 continue
@@ -216,13 +239,7 @@ func (s *rpcServer) run(in io.Reader) error {
216 s.writeError(head.ID, head.Type, "invalid token")239 s.writeError(head.ID, head.Type, "invalid token")
217 return fmt.Errorf("rpc: bad auth token")240 return fmt.Errorf("rpc: bad auth token")
218 }241 }
219- s.authed = true242+ s.authenticate(head.ID, head.Type)
220- s.writeResponse(head.ID, head.Type, map[string]any{
221- "protocol_version": 1,
222- "version": s.version,
223- "provider": s.provider,
224- "model": s.model,
225- })
226 continue243 continue
227 }244 }
228 s.dispatch(head.Type, head.ID, []byte(line))245 s.dispatch(head.Type, head.ID, []byte(line))
@@ -463,6 +480,61 @@ func (s *rpcServer) writeEvent(payload map[string]any) {
463 s.write(payload)480 s.write(payload)
464 }481 }
465 482
483+func (s *rpcServer) writeExtensionEvent(payload map[string]any) {
484+ b, err := json.Marshal(payload)
485+ if err != nil {
486+ return
487+ }
488+ s.writeMu.Lock()
489+ defer s.writeMu.Unlock()
490+ if !s.started || !s.authed {
491+ if len(s.pendingExtEvents) < maxPendingRPCExtEvents {
492+ s.pendingExtEvents = append(s.pendingExtEvents, b)
493+ }
494+ return
495+ }
496+ s.writeLocked(b)
497+}
498+
499+func (s *rpcServer) flushPendingExtEventsLocked() {
500+ for _, b := range s.pendingExtEvents {
501+ s.writeLocked(b)
502+ }
503+ s.pendingExtEvents = nil
504+}
505+
506+func (s *rpcServer) isAuthed() bool {
507+ s.writeMu.Lock()
508+ defer s.writeMu.Unlock()
509+ return s.authed
510+}
511+
512+func (s *rpcServer) authenticate(id, cmd string) {
513+ frame := map[string]any{
514+ "type": "response",
515+ "command": cmd,
516+ "success": true,
517+ "data": map[string]any{
518+ "protocol_version": 1,
519+ "version": s.version,
520+ "provider": s.provider,
521+ "model": s.model,
522+ },
523+ }
524+ if id != "" {
525+ frame["id"] = id
526+ }
527+ b, err := json.Marshal(frame)
528+ if err != nil {
529+ return
530+ }
531+ s.writeMu.Lock()
532+ defer s.writeMu.Unlock()
533+ s.authed = true
534+ s.writeLocked(b)
535+ s.flushPendingExtEventsLocked()
536+}
537+
466 func (s *rpcServer) write(v any) {538 func (s *rpcServer) write(v any) {
467 b, err := json.Marshal(v)539 b, err := json.Marshal(v)
468 if err != nil {540 if err != nil {
@@ -470,6 +542,10 @@ func (s *rpcServer) write(v any) {
470 }542 }
471 s.writeMu.Lock()543 s.writeMu.Lock()
472 defer s.writeMu.Unlock()544 defer s.writeMu.Unlock()
545+ s.writeLocked(b)
546+}
547+
548+func (s *rpcServer) writeLocked(b []byte) {
473 _, _ = s.out.Write(b)549 _, _ = s.out.Write(b)
474 _, _ = s.out.Write([]byte("\n"))550 _, _ = s.out.Write([]byte("\n"))
475 }551 }
A packages/agent/rpc_alert_test.go +47 -0
@@ -0,0 +1,47 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/agent/extproto"
10+)
11+
12+func TestRPCExtAlertIsJSONOnly(t *testing.T) {
13+ var out bytes.Buffer
14+ hooks := &rpcExtHooks{server: &rpcServer{out: &out, started: true, authed: true}}
15+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
16+
17+ if strings.ContainsRune(out.String(), '\a') {
18+ t.Fatalf("RPC alert contains raw BEL: %q", out.String())
19+ }
20+ var frame map[string]any
21+ if err := json.Unmarshal(out.Bytes(), &frame); err != nil {
22+ t.Fatalf("decode RPC alert: %v", err)
23+ }
24+ if frame["type"] != "ext_alert" || frame["extension"] != "question-ext" || frame["kind"] != extproto.AlertKindBell || frame["reason"] != "question_ready" {
25+ t.Fatalf("RPC alert frame = %#v", frame)
26+ }
27+}
28+
29+func TestRPCExtAlertWaitsForAuthentication(t *testing.T) {
30+ t.Setenv("ZOTCORE_RPC_TOKEN", "synthetic-token")
31+
32+ var out bytes.Buffer
33+ server := &rpcServer{out: &out, provider: "test", model: "test-model", version: "test-version"}
34+ hooks := &rpcExtHooks{server: server}
35+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
36+ if out.Len() != 0 {
37+ t.Fatalf("pre-auth RPC output = %q, want empty", out.String())
38+ }
39+
40+ if err := server.run(strings.NewReader(`{"type":"hello","id":"1","token":"synthetic-token"}` + "\n")); err != nil {
41+ t.Fatalf("run RPC server: %v", err)
42+ }
43+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")
44+ if len(lines) != 2 || !strings.Contains(lines[0], `"type":"response"`) || !strings.Contains(lines[1], `"type":"ext_alert"`) {
45+ t.Fatalf("authenticated RPC frames = %q, want response followed by ext_alert", out.String())
46+ }
47+}
@@ -0,0 +1,47 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/agent/extproto"
10+)
11+
12+func TestRPCExtAlertIsJSONOnly(t *testing.T) {
13+ var out bytes.Buffer
14+ hooks := &rpcExtHooks{server: &rpcServer{out: &out, started: true, authed: true}}
15+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
16+
17+ if strings.ContainsRune(out.String(), '\a') {
18+ t.Fatalf("RPC alert contains raw BEL: %q", out.String())
19+ }
20+ var frame map[string]any
21+ if err := json.Unmarshal(out.Bytes(), &frame); err != nil {
22+ t.Fatalf("decode RPC alert: %v", err)
23+ }
24+ if frame["type"] != "ext_alert" || frame["extension"] != "question-ext" || frame["kind"] != extproto.AlertKindBell || frame["reason"] != "question_ready" {
25+ t.Fatalf("RPC alert frame = %#v", frame)
26+ }
27+}
28+
29+func TestRPCExtAlertWaitsForAuthentication(t *testing.T) {
30+ t.Setenv("ZOTCORE_RPC_TOKEN", "synthetic-token")
31+
32+ var out bytes.Buffer
33+ server := &rpcServer{out: &out, provider: "test", model: "test-model", version: "test-version"}
34+ hooks := &rpcExtHooks{server: server}
35+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
36+ if out.Len() != 0 {
37+ t.Fatalf("pre-auth RPC output = %q, want empty", out.String())
38+ }
39+
40+ if err := server.run(strings.NewReader(`{"type":"hello","id":"1","token":"synthetic-token"}` + "\n")); err != nil {
41+ t.Fatalf("run RPC server: %v", err)
42+ }
43+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")
44+ if len(lines) != 2 || !strings.Contains(lines[0], `"type":"response"`) || !strings.Contains(lines[1], `"type":"ext_alert"`) {
45+ t.Fatalf("authenticated RPC frames = %q, want response followed by ext_alert", out.String())
46+ }
47+}
M packages/agent/settings_store.go +9 -0
@@ -41,6 +41,15 @@ func (configSettingsStore) SetInlineImages(enabled bool) error {
4141 return SaveConfig(cfg)
4242 }
4343
44+func (configSettingsStore) SetTerminalAlertsEnabled(enabled bool) error {
45+ cfg, err := LoadConfig()
46+ if err != nil {
47+ return err
48+ }
49+ cfg.TerminalAlertsEnabled = &enabled
50+ return SaveConfig(cfg)
51+}
52+
4453 func (configSettingsStore) SetAutoSwarm(enabled bool) error {
4554 cfg, err := LoadConfig()
4655 if err != nil {
@@ -41,6 +41,15 @@ func (configSettingsStore) SetInlineImages(enabled bool) error {
41 return SaveConfig(cfg)41 return SaveConfig(cfg)
42 }42 }
43 43
44+func (configSettingsStore) SetTerminalAlertsEnabled(enabled bool) error {
45+ cfg, err := LoadConfig()
46+ if err != nil {
47+ return err
48+ }
49+ cfg.TerminalAlertsEnabled = &enabled
50+ return SaveConfig(cfg)
51+}
52+
44 func (configSettingsStore) SetAutoSwarm(enabled bool) error {53 func (configSettingsStore) SetAutoSwarm(enabled bool) error {
45 cfg, err := LoadConfig()54 cfg, err := LoadConfig()
46 if err != nil {55 if err != nil {
M packages/agent/settings_store_test.go +21 -0
@@ -23,6 +23,27 @@ func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) {
2323 }
2424 }
2525
26+func TestConfigSettingsStorePersistsTerminalAlerts(t *testing.T) {
27+ t.Setenv("ZOT_HOME", t.TempDir())
28+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
29+ t.Fatal(err)
30+ }
31+
32+ if err := (configSettingsStore{}).SetTerminalAlertsEnabled(false); err != nil {
33+ t.Fatal(err)
34+ }
35+ cfg, err := LoadConfig()
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if cfg.TerminalAlertsEnabled == nil || *cfg.TerminalAlertsEnabled {
40+ t.Fatal("terminal_alerts_enabled was not persisted as disabled")
41+ }
42+ if cfg.Theme != "dark" {
43+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
44+ }
45+}
46+
2647 func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) {
2748 t.Setenv("ZOT_HOME", t.TempDir())
2849 if err := SaveConfig(Config{Theme: "dark"}); err != nil {
@@ -23,6 +23,27 @@ func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) {
23 }23 }
24 }24 }
25 25
26+func TestConfigSettingsStorePersistsTerminalAlerts(t *testing.T) {
27+ t.Setenv("ZOT_HOME", t.TempDir())
28+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
29+ t.Fatal(err)
30+ }
31+
32+ if err := (configSettingsStore{}).SetTerminalAlertsEnabled(false); err != nil {
33+ t.Fatal(err)
34+ }
35+ cfg, err := LoadConfig()
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if cfg.TerminalAlertsEnabled == nil || *cfg.TerminalAlertsEnabled {
40+ t.Fatal("terminal_alerts_enabled was not persisted as disabled")
41+ }
42+ if cfg.Theme != "dark" {
43+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
44+ }
45+}
46+
26 func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) {47 func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) {
27 t.Setenv("ZOT_HOME", t.TempDir())48 t.Setenv("ZOT_HOME", t.TempDir())
28 if err := SaveConfig(Config{Theme: "dark"}); err != nil {49 if err := SaveConfig(Config{Theme: "dark"}); err != nil {
M packages/agent/skills/builtin/write-zot-extension/SKILL.md +18 -4
@@ -97,7 +97,7 @@ The very first frame the extension sends is `hello`:
9797 ```
9898
9999 Capabilities are advisory; current values are `commands`, `tools`,
100-`events`. Send all that apply.
100+`events`, `alerts`, and `panels`. Send all that apply.
101101
102102 zot replies with `hello_ack`:
103103
@@ -150,6 +150,7 @@ process itself runs from the extension directory, so do not use
150150 {"type":"event_intercept_response","id":"ghi","block":true,
151151 "reason":"refused: command matches the danger pattern \"rm -rf\""}
152152 {"type":"notify","level":"info","message":"refreshed cache"}
153+{"type":"alert","kind":"bell","reason":"question_ready"}
153154 {"type":"clear_notes"}
154155 {"type":"shutdown_ack"}
155156 ```
@@ -159,6 +160,12 @@ prompt (and on `esc` / `/clear`). Send `clear_notes` to retract every
159160 note this extension pushed earlier (e.g. a transient approval prompt)
160161 without waiting for the next turn; other extensions' notes are kept.
161162
163+`alert` is a host-owned, fire-and-forget request. The first supported
164+kind is `bell`; `reason` is semantic metadata. The interactive host
165+applies the user's shared terminal-alert setting and non-interactive
166+hosts may ignore it. Extensions must never write BEL or ANSI bytes to
167+stdout.
168+
162169 `command_response.action` values:
163170 - `"prompt"` — submit `prompt` as a fresh user message
164171 - `"insert"` — drop `insert` into the editor at the cursor
@@ -177,9 +184,9 @@ never stalls the agent.
177184
178185 - **stdout is reserved for the protocol.** Anything you print to
179186 stdout that isn't a JSON frame breaks the wire. The first stdout
180- frame must be `hello`; do not send `notify`, logs, or registration
181- frames before that handshake starts. Use stderr for logs / debug
182- output (zot captures stderr to `$ZOT_HOME/logs/ext-<name>.log`).
187+ frame must be `hello`; do not send `notify`, `alert`, logs, or
188+ registration frames before that handshake starts. Use stderr for
189+ logs / debug output (zot captures stderr to `$ZOT_HOME/logs/ext-<name>.log`).
183190 - **One JSON object per line.** No multi-line JSON. Always end
184191 every frame with `\n`.
185192 - **Flush after writing.** Most stdout writes are line-buffered when
@@ -245,6 +252,13 @@ host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
245252 {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}
246253 ```
247254
255+After the hello handshake completes inside `Run`, call `e.Alert` from a
256+command, tool, or panel handler when the user needs to be drawn back to zot:
257+
258+```go
259+e.Alert(ext.AlertRequest{Kind: ext.AlertKindBell, Reason: "question_ready"})
260+```
261+
248262 ### TypeScript (no SDK; handles the protocol directly)
249263
250264 Run via `tsx`, which executes `.ts` files without a build step.
@@ -97,7 +97,7 @@ The very first frame the extension sends is `hello`:
97 ```97 ```
98 98
99 Capabilities are advisory; current values are `commands`, `tools`,99 Capabilities are advisory; current values are `commands`, `tools`,
100-`events`. Send all that apply.100+`events`, `alerts`, and `panels`. Send all that apply.
101 101
102 zot replies with `hello_ack`:102 zot replies with `hello_ack`:
103 103
@@ -150,6 +150,7 @@ process itself runs from the extension directory, so do not use
150 {"type":"event_intercept_response","id":"ghi","block":true,150 {"type":"event_intercept_response","id":"ghi","block":true,
151 "reason":"refused: command matches the danger pattern \"rm -rf\""}151 "reason":"refused: command matches the danger pattern \"rm -rf\""}
152 {"type":"notify","level":"info","message":"refreshed cache"}152 {"type":"notify","level":"info","message":"refreshed cache"}
153+{"type":"alert","kind":"bell","reason":"question_ready"}
153 {"type":"clear_notes"}154 {"type":"clear_notes"}
154 {"type":"shutdown_ack"}155 {"type":"shutdown_ack"}
155 ```156 ```
@@ -159,6 +160,12 @@ prompt (and on `esc` / `/clear`). Send `clear_notes` to retract every
159 note this extension pushed earlier (e.g. a transient approval prompt)160 note this extension pushed earlier (e.g. a transient approval prompt)
160 without waiting for the next turn; other extensions' notes are kept.161 without waiting for the next turn; other extensions' notes are kept.
161 162
163+`alert` is a host-owned, fire-and-forget request. The first supported
164+kind is `bell`; `reason` is semantic metadata. The interactive host
165+applies the user's shared terminal-alert setting and non-interactive
166+hosts may ignore it. Extensions must never write BEL or ANSI bytes to
167+stdout.
168+
162 `command_response.action` values:169 `command_response.action` values:
163 - `"prompt"` — submit `prompt` as a fresh user message170 - `"prompt"` — submit `prompt` as a fresh user message
164 - `"insert"` — drop `insert` into the editor at the cursor171 - `"insert"` — drop `insert` into the editor at the cursor
@@ -177,9 +184,9 @@ never stalls the agent.
177 184
178 - **stdout is reserved for the protocol.** Anything you print to185 - **stdout is reserved for the protocol.** Anything you print to
179 stdout that isn't a JSON frame breaks the wire. The first stdout186 stdout that isn't a JSON frame breaks the wire. The first stdout
180- frame must be `hello`; do not send `notify`, logs, or registration187+ frame must be `hello`; do not send `notify`, `alert`, logs, or
181- frames before that handshake starts. Use stderr for logs / debug188+ registration frames before that handshake starts. Use stderr for
182- output (zot captures stderr to `$ZOT_HOME/logs/ext-<name>.log`).189+ logs / debug output (zot captures stderr to `$ZOT_HOME/logs/ext-<name>.log`).
183 - **One JSON object per line.** No multi-line JSON. Always end190 - **One JSON object per line.** No multi-line JSON. Always end
184 every frame with `\n`.191 every frame with `\n`.
185 - **Flush after writing.** Most stdout writes are line-buffered when192 - **Flush after writing.** Most stdout writes are line-buffered when
@@ -245,6 +252,13 @@ host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
245 {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}252 {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}
246 ```253 ```
247 254
255+After the hello handshake completes inside `Run`, call `e.Alert` from a
256+command, tool, or panel handler when the user needs to be drawn back to zot:
257+
258+```go
259+e.Alert(ext.AlertRequest{Kind: ext.AlertKindBell, Reason: "question_ready"})
260+```
261+
248 ### TypeScript (no SDK; handles the protocol directly)262 ### TypeScript (no SDK; handles the protocol directly)
249 263
250 Run via `tsx`, which executes `.ts` files without a build step.264 Run via `tsx`, which executes `.ts` files without a build step.
A packages/tui/alert_test.go +16 -0
@@ -0,0 +1,16 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestWriteBellWritesStandaloneAlert(t *testing.T) {
9+ var out bytes.Buffer
10+ if err := WriteBell(&out); err != nil {
11+ t.Fatalf("WriteBell() error = %v", err)
12+ }
13+ if got := out.String(); got != "\a" {
14+ t.Fatalf("WriteBell() = %q, want standalone BEL", got)
15+ }
16+}
@@ -0,0 +1,16 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestWriteBellWritesStandaloneAlert(t *testing.T) {
9+ var out bytes.Buffer
10+ if err := WriteBell(&out); err != nil {
11+ t.Fatalf("WriteBell() error = %v", err)
12+ }
13+ if got := out.String(); got != "\a" {
14+ t.Fatalf("WriteBell() = %q, want standalone BEL", got)
15+ }
16+}
M packages/tui/terminal.go +8 -0
@@ -43,6 +43,14 @@ func NewProcTerm() *ProcTerm {
4343
4444 func (p *ProcTerm) Write(b []byte) (int, error) { return p.out.Write(b) }
4545
46+// WriteBell emits one standalone terminal alert character. Callers should
47+// route it through the same output writer used for the rest of the terminal
48+// so redraws and alerts share the host's output boundary.
49+func WriteBell(w io.Writer) error {
50+ _, err := io.WriteString(w, "\a")
51+ return err
52+}
53+
4654 func (p *ProcTerm) Size() (int, int) {
4755 w, h, err := term.GetSize(int(p.out.Fd()))
4856 if err != nil || w <= 0 || h <= 0 {
@@ -43,6 +43,14 @@ func NewProcTerm() *ProcTerm {
43 43
44 func (p *ProcTerm) Write(b []byte) (int, error) { return p.out.Write(b) }44 func (p *ProcTerm) Write(b []byte) (int, error) { return p.out.Write(b) }
45 45
46+// WriteBell emits one standalone terminal alert character. Callers should
47+// route it through the same output writer used for the rest of the terminal
48+// so redraws and alerts share the host's output boundary.
49+func WriteBell(w io.Writer) error {
50+ _, err := io.WriteString(w, "\a")
51+ return err
52+}
53+
46 func (p *ProcTerm) Size() (int, int) {54 func (p *ProcTerm) Size() (int, int) {
47 w, h, err := term.GetSize(int(p.out.Fd()))55 w, h, err := term.GetSize(int(p.out.Fd()))
48 if err != nil || w <= 0 || h <= 0 {56 if err != nil || w <= 0 || h <= 0 {

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