detached 40856e8

feat(agent): add terminal alert support

commit
40856e8d62acaec31be949a36783fa647d6c930b
author
brice · · 2026-08-04 08:14 UTC
parent
9eef239
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +728 -15
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 +7 -0
@@ -48,6 +48,11 @@ func (h *interactiveExtHooks) Notify(extName, level, message string) {
4848 iv.Notify(extName, level, message)
4949 }
5050 }
51+func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest) {
52+ if iv := h.iv(); iv != nil {
53+ iv.Alert(extName, alert)
54+ }
55+}
5156 func (h *interactiveExtHooks) Submit(text string) {
5257 if iv := h.iv(); iv != nil {
5358 iv.SubmitOrQueue(text, nil)
@@ -273,6 +278,7 @@ type nonInteractiveExtHooks struct{}
273278 func (nonInteractiveExtHooks) Notify(ext, level, message string) {
274279 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
275280 }
281+func (nonInteractiveExtHooks) Alert(string, extproto.AlertRequest) {}
276282 func (nonInteractiveExtHooks) Submit(string) {}
277283 func (nonInteractiveExtHooks) SubmitSlash(string) {}
278284 func (nonInteractiveExtHooks) Insert(string) {}
@@ -1286,6 +1292,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12861292 Terminal: term,
12871293 Theme: theme,
12881294 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1295+ TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
12891296 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
12901297 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
12911298 JailByDefault: initialCfg.JailByDefault,
@@ -48,6 +48,11 @@ func (h *interactiveExtHooks) Notify(extName, level, message string) {
48 iv.Notify(extName, level, message)48 iv.Notify(extName, level, message)
49 }49 }
50 }50 }
51+func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest) {
52+ if iv := h.iv(); iv != nil {
53+ iv.Alert(extName, alert)
54+ }
55+}
51 func (h *interactiveExtHooks) Submit(text string) {56 func (h *interactiveExtHooks) Submit(text string) {
52 if iv := h.iv(); iv != nil {57 if iv := h.iv(); iv != nil {
53 iv.SubmitOrQueue(text, nil)58 iv.SubmitOrQueue(text, nil)
@@ -273,6 +278,7 @@ type nonInteractiveExtHooks struct{}
273 func (nonInteractiveExtHooks) Notify(ext, level, message string) {278 func (nonInteractiveExtHooks) Notify(ext, level, message string) {
274 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)279 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
275 }280 }
281+func (nonInteractiveExtHooks) Alert(string, extproto.AlertRequest) {}
276 func (nonInteractiveExtHooks) Submit(string) {}282 func (nonInteractiveExtHooks) Submit(string) {}
277 func (nonInteractiveExtHooks) SubmitSlash(string) {}283 func (nonInteractiveExtHooks) SubmitSlash(string) {}
278 func (nonInteractiveExtHooks) Insert(string) {}284 func (nonInteractiveExtHooks) Insert(string) {}
@@ -1286,6 +1292,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1286 Terminal: term,1292 Terminal: term,
1287 Theme: theme,1293 Theme: theme,
1288 InlineImagesEnabled: initialCfg.InlineImagesEnabled,1294 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1295+ TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
1289 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,1296 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1290 AutoCompactThreshold: initialCfg.AutoCompactThreshold,1297 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1291 JailByDefault: initialCfg.JailByDefault,1298 JailByDefault: initialCfg.JailByDefault,
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 +18 -0
@@ -220,6 +220,24 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
220220 h.hostW.Close()
221221 }
222222
223+func TestAlertEmitsStructuredFrame(t *testing.T) {
224+ h := newHarness("alert-ext")
225+ go h.ext.Run()
226+ h.handshake(t)
227+
228+ go h.ext.Alert(AlertRequest{Kind: AlertKindBell, Reason: "question_ready"})
229+ f := h.drainUntil(t, "alert")
230+
231+ var alert extproto.AlertFromExt
232+ if err := json.Unmarshal(f.raw, &alert); err != nil {
233+ t.Fatalf("unmarshal alert: %v", err)
234+ }
235+ if alert.Kind != AlertKindBell || alert.Reason != "question_ready" {
236+ t.Fatalf("alert = %+v, want bell/question_ready", alert)
237+ }
238+ h.hostW.Close()
239+}
240+
223241 func TestToolConfirmationRequestedEvent(t *testing.T) {
224242 h := newHarness("confirmation-events")
225243 received := make(chan Event, 1)
@@ -220,6 +220,24 @@ 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+ go h.ext.Run()
226+ h.handshake(t)
227+
228+ go h.ext.Alert(AlertRequest{Kind: AlertKindBell, Reason: "question_ready"})
229+ f := h.drainUntil(t, "alert")
230+
231+ var alert extproto.AlertFromExt
232+ if err := json.Unmarshal(f.raw, &alert); err != nil {
233+ t.Fatalf("unmarshal alert: %v", err)
234+ }
235+ if alert.Kind != AlertKindBell || alert.Reason != "question_ready" {
236+ t.Fatalf("alert = %+v, want bell/question_ready", alert)
237+ }
238+ h.hostW.Close()
239+}
240+
223 func TestToolConfirmationRequestedEvent(t *testing.T) {241 func TestToolConfirmationRequestedEvent(t *testing.T) {
224 h := newHarness("confirmation-events")242 h := newHarness("confirmation-events")
225 received := make(chan Event, 1)243 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 +249 -0
@@ -0,0 +1,249 @@
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+ for time.Now().Before(deadline) {
93+ i.mu.Lock()
94+ busy := i.busy
95+ i.mu.Unlock()
96+ if busy {
97+ break
98+ }
99+ time.Sleep(time.Millisecond)
100+ }
101+ i.CancelTurn()
102+
103+ deadline = time.Now().Add(time.Second)
104+ for time.Now().Before(deadline) {
105+ i.mu.Lock()
106+ busy := i.busy
107+ i.mu.Unlock()
108+ if !busy {
109+ break
110+ }
111+ time.Sleep(time.Millisecond)
112+ }
113+ if got := term.String(); got != "" {
114+ t.Fatalf("cancelled main turn alert output = %q, want empty", got)
115+ }
116+}
117+
118+func TestMainTurnEmitsBellAfterVisibleResponse(t *testing.T) {
119+ term := &alertTestTerminal{}
120+ client := &streamTestClient{events: []provider.Event{
121+ provider.EventTextDelta{Delta: "answer"},
122+ provider.EventDone{
123+ Stop: provider.StopEnd,
124+ Message: provider.Message{
125+ Role: provider.RoleAssistant,
126+ Content: []provider.Content{provider.TextBlock{Text: "answer"}},
127+ },
128+ },
129+ }}
130+ ag := core.NewAgent(client, "test-model", "", nil)
131+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
132+ i.rend = tui.NewRenderer(io.Discard)
133+ ctx, cancel := context.WithCancel(context.Background())
134+ defer cancel()
135+ i.runCtx = ctx
136+ go i.runStreamPacer(ctx)
137+ i.startTurn(ctx, "question")
138+
139+ deadline := time.Now().Add(time.Second)
140+ for time.Now().Before(deadline) {
141+ i.mu.Lock()
142+ ready := i.pendingAlert != nil && !i.streamFlushPending && len(i.streamPending) == 0
143+ i.mu.Unlock()
144+ if ready {
145+ i.redraw()
146+ break
147+ }
148+ time.Sleep(time.Millisecond)
149+ }
150+ if got := term.String(); got != "\a" {
151+ t.Fatalf("main turn alert output = %q, want BEL after response redraw", got)
152+ }
153+}
154+
155+func TestMainAlertWaitsForPacedText(t *testing.T) {
156+ term := &alertTestTerminal{}
157+ i := NewInteractive(InteractiveConfig{Terminal: term})
158+ i.rend = tui.NewRenderer(io.Discard)
159+ i.streamPending = []rune("final answer")
160+ i.streamFlushPending = true
161+ i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"}
162+ ctx, cancel := context.WithCancel(context.Background())
163+ defer cancel()
164+ go i.runStreamPacer(ctx)
165+
166+ deadline := time.Now().Add(time.Second)
167+ for time.Now().Before(deadline) {
168+ i.mu.Lock()
169+ ready := !i.streamFlushPending && len(i.streamPending) == 0 && i.pendingAlert != nil
170+ i.mu.Unlock()
171+ if ready {
172+ i.redraw()
173+ break
174+ }
175+ time.Sleep(time.Millisecond)
176+ }
177+ if got := term.String(); got != "\a" {
178+ t.Fatalf("paced alert output = %q, want BEL after final redraw", got)
179+ }
180+ i.mu.Lock()
181+ on, pending, alert := i.streamOn, len(i.streamPending), i.pendingAlert
182+ i.mu.Unlock()
183+ if on || pending != 0 || alert != nil {
184+ t.Fatalf("stream state after alert: on=%v pending=%d alert=%v", on, pending, alert)
185+ }
186+}
187+
188+func TestMainAlertReasonPolicy(t *testing.T) {
189+ cancelled, cancel := context.WithCancel(context.Background())
190+ cancel()
191+
192+ tests := []struct {
193+ name string
194+ ctx context.Context
195+ err error
196+ turnErr error
197+ stop provider.StopReason
198+ awaiting bool
199+ queued bool
200+ rescue bool
201+ recovering bool
202+ auto bool
203+ want string
204+ }{
205+ {name: "normal", ctx: context.Background(), stop: provider.StopEnd, want: "agent_done"},
206+ {name: "length", ctx: context.Background(), stop: provider.StopLength, want: "response_truncated"},
207+ {name: "returned error", ctx: context.Background(), err: errors.New("boom"), stop: provider.StopError, want: "agent_error"},
208+ {name: "guard error", ctx: context.Background(), stop: provider.StopError, want: "agent_error"},
209+ {name: "rescue", ctx: context.Background(), err: errors.New("temporary"), stop: provider.StopError, rescue: true, want: "rescue_required"},
210+ {name: "cancelled", ctx: cancelled, stop: provider.StopAborted},
211+ {name: "aborted", ctx: context.Background(), stop: provider.StopAborted},
212+ {name: "queued", ctx: context.Background(), stop: provider.StopEnd, queued: true},
213+ {name: "recovering", ctx: context.Background(), stop: provider.StopError, recovering: true},
214+ {name: "auto compacting", ctx: context.Background(), stop: provider.StopEnd, auto: true},
215+ {name: "startup pre", ctx: context.Background(), stop: provider.StopEnd, awaiting: true},
216+ }
217+ for _, tt := range tests {
218+ t.Run(tt.name, func(t *testing.T) {
219+ got := mainAlertReason(tt.ctx, tt.err, tt.turnErr, tt.stop, tt.awaiting, tt.queued, tt.rescue, tt.recovering, tt.auto)
220+ if got != tt.want {
221+ t.Fatalf("mainAlertReason() = %q, want %q", got, tt.want)
222+ }
223+ })
224+ }
225+}
226+
227+func TestTerminalAlertsSettingDefaultsEnabledAndToggles(t *testing.T) {
228+ i := NewInteractive(InteractiveConfig{Terminal: &alertTestTerminal{}})
229+ i.openSettingsDialog()
230+ var item *settingsItem
231+ for idx := range i.settingsDialog.items {
232+ if i.settingsDialog.items[idx].key == "terminal_alerts_enabled" {
233+ item = &i.settingsDialog.items[idx]
234+ break
235+ }
236+ }
237+ if item == nil || !item.value {
238+ t.Fatal("terminal alerts setting should be present and enabled by default")
239+ }
240+
241+ i.applySettingToggle("terminal_alerts_enabled", false)
242+ if i.cfg.TerminalAlertsEnabled == nil || *i.cfg.TerminalAlertsEnabled {
243+ t.Fatal("terminal alerts setting did not disable")
244+ }
245+ i.applySettingToggle("terminal_alerts_enabled", true)
246+ if i.cfg.TerminalAlertsEnabled == nil || !*i.cfg.TerminalAlertsEnabled {
247+ t.Fatal("terminal alerts setting did not re-enable")
248+ }
249+}
@@ -0,0 +1,249 @@
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+ for time.Now().Before(deadline) {
93+ i.mu.Lock()
94+ busy := i.busy
95+ i.mu.Unlock()
96+ if busy {
97+ break
98+ }
99+ time.Sleep(time.Millisecond)
100+ }
101+ i.CancelTurn()
102+
103+ deadline = time.Now().Add(time.Second)
104+ for time.Now().Before(deadline) {
105+ i.mu.Lock()
106+ busy := i.busy
107+ i.mu.Unlock()
108+ if !busy {
109+ break
110+ }
111+ time.Sleep(time.Millisecond)
112+ }
113+ if got := term.String(); got != "" {
114+ t.Fatalf("cancelled main turn alert output = %q, want empty", got)
115+ }
116+}
117+
118+func TestMainTurnEmitsBellAfterVisibleResponse(t *testing.T) {
119+ term := &alertTestTerminal{}
120+ client := &streamTestClient{events: []provider.Event{
121+ provider.EventTextDelta{Delta: "answer"},
122+ provider.EventDone{
123+ Stop: provider.StopEnd,
124+ Message: provider.Message{
125+ Role: provider.RoleAssistant,
126+ Content: []provider.Content{provider.TextBlock{Text: "answer"}},
127+ },
128+ },
129+ }}
130+ ag := core.NewAgent(client, "test-model", "", nil)
131+ i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
132+ i.rend = tui.NewRenderer(io.Discard)
133+ ctx, cancel := context.WithCancel(context.Background())
134+ defer cancel()
135+ i.runCtx = ctx
136+ go i.runStreamPacer(ctx)
137+ i.startTurn(ctx, "question")
138+
139+ deadline := time.Now().Add(time.Second)
140+ for time.Now().Before(deadline) {
141+ i.mu.Lock()
142+ ready := i.pendingAlert != nil && !i.streamFlushPending && len(i.streamPending) == 0
143+ i.mu.Unlock()
144+ if ready {
145+ i.redraw()
146+ break
147+ }
148+ time.Sleep(time.Millisecond)
149+ }
150+ if got := term.String(); got != "\a" {
151+ t.Fatalf("main turn alert output = %q, want BEL after response redraw", got)
152+ }
153+}
154+
155+func TestMainAlertWaitsForPacedText(t *testing.T) {
156+ term := &alertTestTerminal{}
157+ i := NewInteractive(InteractiveConfig{Terminal: term})
158+ i.rend = tui.NewRenderer(io.Discard)
159+ i.streamPending = []rune("final answer")
160+ i.streamFlushPending = true
161+ i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"}
162+ ctx, cancel := context.WithCancel(context.Background())
163+ defer cancel()
164+ go i.runStreamPacer(ctx)
165+
166+ deadline := time.Now().Add(time.Second)
167+ for time.Now().Before(deadline) {
168+ i.mu.Lock()
169+ ready := !i.streamFlushPending && len(i.streamPending) == 0 && i.pendingAlert != nil
170+ i.mu.Unlock()
171+ if ready {
172+ i.redraw()
173+ break
174+ }
175+ time.Sleep(time.Millisecond)
176+ }
177+ if got := term.String(); got != "\a" {
178+ t.Fatalf("paced alert output = %q, want BEL after final redraw", got)
179+ }
180+ i.mu.Lock()
181+ on, pending, alert := i.streamOn, len(i.streamPending), i.pendingAlert
182+ i.mu.Unlock()
183+ if on || pending != 0 || alert != nil {
184+ t.Fatalf("stream state after alert: on=%v pending=%d alert=%v", on, pending, alert)
185+ }
186+}
187+
188+func TestMainAlertReasonPolicy(t *testing.T) {
189+ cancelled, cancel := context.WithCancel(context.Background())
190+ cancel()
191+
192+ tests := []struct {
193+ name string
194+ ctx context.Context
195+ err error
196+ turnErr error
197+ stop provider.StopReason
198+ awaiting bool
199+ queued bool
200+ rescue bool
201+ recovering bool
202+ auto bool
203+ want string
204+ }{
205+ {name: "normal", ctx: context.Background(), stop: provider.StopEnd, want: "agent_done"},
206+ {name: "length", ctx: context.Background(), stop: provider.StopLength, want: "response_truncated"},
207+ {name: "returned error", ctx: context.Background(), err: errors.New("boom"), stop: provider.StopError, want: "agent_error"},
208+ {name: "guard error", ctx: context.Background(), stop: provider.StopError, want: "agent_error"},
209+ {name: "rescue", ctx: context.Background(), err: errors.New("temporary"), stop: provider.StopError, rescue: true, want: "rescue_required"},
210+ {name: "cancelled", ctx: cancelled, stop: provider.StopAborted},
211+ {name: "aborted", ctx: context.Background(), stop: provider.StopAborted},
212+ {name: "queued", ctx: context.Background(), stop: provider.StopEnd, queued: true},
213+ {name: "recovering", ctx: context.Background(), stop: provider.StopError, recovering: true},
214+ {name: "auto compacting", ctx: context.Background(), stop: provider.StopEnd, auto: true},
215+ {name: "startup pre", ctx: context.Background(), stop: provider.StopEnd, awaiting: true},
216+ }
217+ for _, tt := range tests {
218+ t.Run(tt.name, func(t *testing.T) {
219+ got := mainAlertReason(tt.ctx, tt.err, tt.turnErr, tt.stop, tt.awaiting, tt.queued, tt.rescue, tt.recovering, tt.auto)
220+ if got != tt.want {
221+ t.Fatalf("mainAlertReason() = %q, want %q", got, tt.want)
222+ }
223+ })
224+ }
225+}
226+
227+func TestTerminalAlertsSettingDefaultsEnabledAndToggles(t *testing.T) {
228+ i := NewInteractive(InteractiveConfig{Terminal: &alertTestTerminal{}})
229+ i.openSettingsDialog()
230+ var item *settingsItem
231+ for idx := range i.settingsDialog.items {
232+ if i.settingsDialog.items[idx].key == "terminal_alerts_enabled" {
233+ item = &i.settingsDialog.items[idx]
234+ break
235+ }
236+ }
237+ if item == nil || !item.value {
238+ t.Fatal("terminal alerts setting should be present and enabled by default")
239+ }
240+
241+ i.applySettingToggle("terminal_alerts_enabled", false)
242+ if i.cfg.TerminalAlertsEnabled == nil || *i.cfg.TerminalAlertsEnabled {
243+ t.Fatal("terminal alerts setting did not disable")
244+ }
245+ i.applySettingToggle("terminal_alerts_enabled", true)
246+ if i.cfg.TerminalAlertsEnabled == nil || !*i.cfg.TerminalAlertsEnabled {
247+ t.Fatal("terminal alerts setting did not re-enable")
248+ }
249+}
M packages/agent/modes/interactive.go +109 -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,45 @@ 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 emits a main-session alert now when no paced text remains,
3018+// or defers it until the pacer drains and the next redraw commits the final frame.
3019+func (i *Interactive) scheduleMainAlert(reason string) {
3020+ if reason == "" {
3021+ return
3022+ }
3023+ i.mu.Lock()
3024+ if len(i.streamPending) > 0 {
3025+ i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
3026+ i.mu.Unlock()
3027+ i.invalidate()
3028+ return
3029+ }
3030+ i.emitAlertLocked(extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason})
3031+ i.mu.Unlock()
3032+}
3033+
29813034 // ClearNotes removes every note line owned by extName from the
29823035 // bottom-sticky ext-notes block. Extensions use this to retract a
29833036 // transient status line (e.g. an approval prompt) once it no longer
@@ -3350,6 +3403,7 @@ func (i *Interactive) openSettingsDialog() {
33503403 imgHint = "terminal supports " + imageProtocolName(detected)
33513404 }
33523405
3406+ terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
33533407 autoSwarm := false
33543408 if i.cfg.AutoSwarmEnabled != nil {
33553409 autoSwarm = *i.cfg.AutoSwarmEnabled
@@ -3459,6 +3513,12 @@ func (i *Interactive) openSettingsDialog() {
34593513 disabled: imgDisabled,
34603514 hint: imgHint,
34613515 },
3516+ {
3517+ key: "terminal_alerts_enabled",
3518+ label: "terminal alerts",
3519+ desc: "ring the terminal when the main session needs attention or an extension raises an alert",
3520+ value: terminalAlerts,
3521+ },
34623522 {
34633523 key: "auto_swarm_enabled",
34643524 label: "auto-swarm",
@@ -3794,6 +3854,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
37943854 i.statusOK = "inline image rendering " + onOff(value)
37953855 i.statusErr = ""
37963856 i.mu.Unlock()
3857+ case "terminal_alerts_enabled":
3858+ val := value
3859+ i.mu.Lock()
3860+ i.cfg.TerminalAlertsEnabled = &val
3861+ i.mu.Unlock()
3862+ if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok {
3863+ if err := store.SetTerminalAlertsEnabled(value); err != nil {
3864+ i.mu.Lock()
3865+ i.statusErr = "settings: " + err.Error()
3866+ i.mu.Unlock()
3867+ return
3868+ }
3869+ }
3870+ i.mu.Lock()
3871+ i.statusOK = "terminal alerts " + onOff(value)
3872+ i.statusErr = ""
3873+ i.mu.Unlock()
37973874 case "auto_swarm_enabled":
37983875 val := value
37993876 i.cfg.AutoSwarmEnabled = &val
@@ -5695,6 +5772,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
56955772 i.statusOK = ""
56965773 i.streaming.Reset()
56975774 i.streamOn = true
5775+ i.pendingAlert = nil
56985776 i.toolCalls = map[string]*tui.ToolCallView{}
56995777 i.toolOrder = nil
57005778 i.toolGate = map[string]int{}
@@ -5727,7 +5805,15 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
57275805 i.mu.Unlock()
57285806 i.invalidate()
57295807
5808+ var (
5809+ lastStop provider.StopReason
5810+ lastTurnErr error
5811+ )
57305812 sink := func(ev core.AgentEvent) {
5813+ if e, ok := ev.(core.EvTurnEnd); ok {
5814+ lastStop = e.Stop
5815+ lastTurnErr = e.Err
5816+ }
57315817 i.handleEvent(ev)
57325818 i.invalidate()
57335819 }
@@ -5829,7 +5915,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
58295915 agentQueued = i.agent.QueuedMessageCount()
58305916 }
58315917 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
5918+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
58325919 i.mu.Unlock()
5920+ if alertReason != "" {
5921+ i.scheduleMainAlert(alertReason)
5922+ }
58335923 i.invalidate()
58345924 parent := i.runCtx
58355925 if parent == nil {
@@ -5852,6 +5942,25 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
58525942 }()
58535943 }
58545944
5945+func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
5946+ if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
5947+ return ""
5948+ }
5949+ if rescue {
5950+ return "rescue_required"
5951+ }
5952+ if stop == provider.StopAborted {
5953+ return ""
5954+ }
5955+ if err != nil || turnErr != nil || stop == provider.StopError {
5956+ return "agent_error"
5957+ }
5958+ if stop == provider.StopLength {
5959+ return "response_truncated"
5960+ }
5961+ return "agent_done"
5962+}
5963+
58555964 // openRescueDialog surfaces the rescue model picker after a recoverable
58565965 // provider failure. The pending prompt + images are stashed on the
58575966 // 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,45 @@ 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 emits a main-session alert now when no paced text remains,
3018+// or defers it until the pacer drains and the next redraw commits the final frame.
3019+func (i *Interactive) scheduleMainAlert(reason string) {
3020+ if reason == "" {
3021+ return
3022+ }
3023+ i.mu.Lock()
3024+ if len(i.streamPending) > 0 {
3025+ i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
3026+ i.mu.Unlock()
3027+ i.invalidate()
3028+ return
3029+ }
3030+ i.emitAlertLocked(extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason})
3031+ i.mu.Unlock()
3032+}
3033+
2981 // ClearNotes removes every note line owned by extName from the3034 // ClearNotes removes every note line owned by extName from the
2982 // bottom-sticky ext-notes block. Extensions use this to retract a3035 // bottom-sticky ext-notes block. Extensions use this to retract a
2983 // transient status line (e.g. an approval prompt) once it no longer3036 // transient status line (e.g. an approval prompt) once it no longer
@@ -3350,6 +3403,7 @@ func (i *Interactive) openSettingsDialog() {
3350 imgHint = "terminal supports " + imageProtocolName(detected)3403 imgHint = "terminal supports " + imageProtocolName(detected)
3351 }3404 }
3352 3405
3406+ terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
3353 autoSwarm := false3407 autoSwarm := false
3354 if i.cfg.AutoSwarmEnabled != nil {3408 if i.cfg.AutoSwarmEnabled != nil {
3355 autoSwarm = *i.cfg.AutoSwarmEnabled3409 autoSwarm = *i.cfg.AutoSwarmEnabled
@@ -3459,6 +3513,12 @@ func (i *Interactive) openSettingsDialog() {
3459 disabled: imgDisabled,3513 disabled: imgDisabled,
3460 hint: imgHint,3514 hint: imgHint,
3461 },3515 },
3516+ {
3517+ key: "terminal_alerts_enabled",
3518+ label: "terminal alerts",
3519+ desc: "ring the terminal when the main session needs attention or an extension raises an alert",
3520+ value: terminalAlerts,
3521+ },
3462 {3522 {
3463 key: "auto_swarm_enabled",3523 key: "auto_swarm_enabled",
3464 label: "auto-swarm",3524 label: "auto-swarm",
@@ -3794,6 +3854,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
3794 i.statusOK = "inline image rendering " + onOff(value)3854 i.statusOK = "inline image rendering " + onOff(value)
3795 i.statusErr = ""3855 i.statusErr = ""
3796 i.mu.Unlock()3856 i.mu.Unlock()
3857+ case "terminal_alerts_enabled":
3858+ val := value
3859+ i.mu.Lock()
3860+ i.cfg.TerminalAlertsEnabled = &val
3861+ i.mu.Unlock()
3862+ if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok {
3863+ if err := store.SetTerminalAlertsEnabled(value); err != nil {
3864+ i.mu.Lock()
3865+ i.statusErr = "settings: " + err.Error()
3866+ i.mu.Unlock()
3867+ return
3868+ }
3869+ }
3870+ i.mu.Lock()
3871+ i.statusOK = "terminal alerts " + onOff(value)
3872+ i.statusErr = ""
3873+ i.mu.Unlock()
3797 case "auto_swarm_enabled":3874 case "auto_swarm_enabled":
3798 val := value3875 val := value
3799 i.cfg.AutoSwarmEnabled = &val3876 i.cfg.AutoSwarmEnabled = &val
@@ -5695,6 +5772,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5695 i.statusOK = ""5772 i.statusOK = ""
5696 i.streaming.Reset()5773 i.streaming.Reset()
5697 i.streamOn = true5774 i.streamOn = true
5775+ i.pendingAlert = nil
5698 i.toolCalls = map[string]*tui.ToolCallView{}5776 i.toolCalls = map[string]*tui.ToolCallView{}
5699 i.toolOrder = nil5777 i.toolOrder = nil
5700 i.toolGate = map[string]int{}5778 i.toolGate = map[string]int{}
@@ -5727,7 +5805,15 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5727 i.mu.Unlock()5805 i.mu.Unlock()
5728 i.invalidate()5806 i.invalidate()
5729 5807
5808+ var (
5809+ lastStop provider.StopReason
5810+ lastTurnErr error
5811+ )
5730 sink := func(ev core.AgentEvent) {5812 sink := func(ev core.AgentEvent) {
5813+ if e, ok := ev.(core.EvTurnEnd); ok {
5814+ lastStop = e.Stop
5815+ lastTurnErr = e.Err
5816+ }
5731 i.handleEvent(ev)5817 i.handleEvent(ev)
5732 i.invalidate()5818 i.invalidate()
5733 }5819 }
@@ -5829,7 +5915,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5829 agentQueued = i.agent.QueuedMessageCount()5915 agentQueued = i.agent.QueuedMessageCount()
5830 }5916 }
5831 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()5917 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
5918+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
5832 i.mu.Unlock()5919 i.mu.Unlock()
5920+ if alertReason != "" {
5921+ i.scheduleMainAlert(alertReason)
5922+ }
5833 i.invalidate()5923 i.invalidate()
5834 parent := i.runCtx5924 parent := i.runCtx
5835 if parent == nil {5925 if parent == nil {
@@ -5852,6 +5942,25 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5852 }()5942 }()
5853 }5943 }
5854 5944
5945+func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
5946+ if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
5947+ return ""
5948+ }
5949+ if rescue {
5950+ return "rescue_required"
5951+ }
5952+ if stop == provider.StopAborted {
5953+ return ""
5954+ }
5955+ if err != nil || turnErr != nil || stop == provider.StopError {
5956+ return "agent_error"
5957+ }
5958+ if stop == provider.StopLength {
5959+ return "response_truncated"
5960+ }
5961+ return "agent_done"
5962+}
5963+
5855 // openRescueDialog surfaces the rescue model picker after a recoverable5964 // openRescueDialog surfaces the rescue model picker after a recoverable
5856 // provider failure. The pending prompt + images are stashed on the5965 // provider failure. The pending prompt + images are stashed on the
5857 // Interactive so a later applyRescueSelection can re-run the same turn5966 // Interactive so a later applyRescueSelection can re-run the same turn
M packages/agent/rpc.go +10 -0
@@ -134,6 +134,16 @@ func (h *rpcExtHooks) Notify(extName, level, message string) {
134134 })
135135 }
136136 }
137+func (h *rpcExtHooks) Alert(extName string, alert extproto.AlertRequest) {
138+ if h.server != nil {
139+ h.server.writeEvent(map[string]any{
140+ "type": "ext_alert",
141+ "extension": extName,
142+ "kind": alert.Kind,
143+ "reason": alert.Reason,
144+ })
145+ }
146+}
137147 func (h *rpcExtHooks) Display(extName, text string) {
138148 if h.server != nil {
139149 h.server.writeEvent(map[string]any{
@@ -134,6 +134,16 @@ func (h *rpcExtHooks) Notify(extName, level, message string) {
134 })134 })
135 }135 }
136 }136 }
137+func (h *rpcExtHooks) Alert(extName string, alert extproto.AlertRequest) {
138+ if h.server != nil {
139+ h.server.writeEvent(map[string]any{
140+ "type": "ext_alert",
141+ "extension": extName,
142+ "kind": alert.Kind,
143+ "reason": alert.Reason,
144+ })
145+ }
146+}
137 func (h *rpcExtHooks) Display(extName, text string) {147 func (h *rpcExtHooks) Display(extName, text string) {
138 if h.server != nil {148 if h.server != nil {
139 h.server.writeEvent(map[string]any{149 h.server.writeEvent(map[string]any{
A packages/agent/rpc_alert_test.go +27 -0
@@ -0,0 +1,27 @@
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}}
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+}
@@ -0,0 +1,27 @@
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}}
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+}
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