Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,29 @@ and approvals among them. While it is more than 0, the tab title starts with
"!" (see [web-ui.md](web-ui.md#browser-alerts)). The filters and the limit do
not apply to the two counts.

#### `POST /api/notifications/{id}/dismiss`

Dismisses a pending notification of any type and records the signed-in
person in `dismissed_by_actor_id`. Answers `{notification_id, dismissed: true}`,
or `409` when the row is not pending or an answer to an approval is already
running. Every dismissal sends a `notification_dismissed` event.

A question or an approval ends without an answer, as at expiry: the Telegram
and Slack cards get "🔕 Dismissed without an answer" and lose their buttons.
The session that asked a question gets the internal turn
`[Question dismissed without an answer: <title>]`, written by the person who
dismissed it. An external, archived or missing session gets no turn. While
the engine drains, the dismissal of a question that would start a turn is
refused (`503`) and the question stays pending. An approval gets an
`approval-dismissed` record in the mechanical-actions audit log. A review loop
that waits on its card proposes a new card at its next reconcile tick, as it
does after an expiry.

#### `POST /api/notifications/dismiss-all`

Dismisses the pending `notify` rows only, and sends no event. Answers
`{dismissed: <count>}`.

### Web Push

The installable panel and its push notifications (see
Expand Down Expand Up @@ -2304,6 +2327,8 @@ those for another session. Events marked *global* go to every socket.
{ type: "notification_answered", notification_id: "…", session_id: "…", answer: "…", answered_by: "…", answered_at: "…" | null, actor_id: "…" }
// answered_at: the stored answer time (ISO 8601). A snoozed approval has approval_status: "snoozed" and snooze_until instead.
{ type: "notification_expired", notification_id: "…", session_id: "…", notification_type: "question", title: "…" }
// One row was dismissed. actor_id: the person who dismissed it
{ type: "notification_dismissed", notification_id: "…", session_id: "…", notification_type: "question", title: "…", actor_id: "…" }
// An answer was relayed into the session as a message
{ type: "answer_injected", session_id: "…", notification_id: "…", title: "…", answer: "…", answered_by: "…", content: "…", actor_id: "…" }

Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ Async notification system for agent→user communication:
- **Multi-channel delivery** — web UI via `__global__` WebSocket broadcast channel, Telegram via direct bot API with inline keyboard buttons, Slack via Block Kit action buttons
- **Answer routing**: answers from any channel (web UI, Telegram inline button or a reply to the card, Slack button, `/reply` command) are persisted and either unblock a waiting tool or injected as a user message into the originating session. A web answer records the person in `answered_by_actor_id`, and the relayed message is written by that person; the agent's work in that turn has the session's cause. A channel answer keeps the provider user ID in metadata. A hosted channel answer in external mode records the person that the channel gateway resolved, as a web answer does; another channel answer records no actor. An approval dispatcher (`nerve/notifications/handlers.py`) receives the person who answered as its `actor` keyword argument, `None` for a channel answer with no actor
A dispatcher that asks for a snooze can return an `after_snooze` callback; the service awaits it with the result of its snooze write (False when the card was no longer pending)
- **Web UI**: `/notifications` page with status/type filters, inline answer options, dismiss, dismiss-all; real-time toast overlay for new notifications; NavRail badge for pending count. The page, the toast and the `ask_user` card in the chat show the same option list (see [web-ui.md](web-ui.md#tool-call-rendering))
- **Dismissal**: a dismissed question or approval ends without an answer, as at expiry: a `notification_dismissed` broadcast, edited Telegram and Slack cards, an internal note to the session that asked a question, and an `approval-dismissed` audit record for an approval
- **Web UI**: `/notifications` page with status/type filters, inline answer options, dismiss (also for questions and approvals), dismiss-all (`notify` rows only); real-time toast overlay for new notifications; NavRail badge for pending count. The page, the toast and the `ask_user` card in the chat show the same option list (see [web-ui.md](web-ui.md#tool-call-rendering))

### Cron Service (`nerve/cron/`)
APScheduler-based job scheduler:
Expand Down
4 changes: 2 additions & 2 deletions docs/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Tool calls are collapsed by default with specialized renderers:
- **Background continuation** — An autonomous turn (the runtime resumed after background work settled) starts with a divider naming what settled ("Background · build-chart finished").
- **AskUserQuestion** — Interactive question card with clickable options (radio for single-select, checkboxes for multi-select). Each question also offers **Other…**, which opens a "Your answer" field: the typed text (required once chosen) is that question's answer — it replaces the option in a single-select question and is appended to the chosen labels in a multi-select one. A card with one single-select question still answers on one click of an option. Multiple questions grouped in one card with a shared Submit button. Markdown previews on hover. When the agent is paused mid-turn (via `can_use_tool`), answers are sent through the interaction protocol (`answer_interaction` WebSocket message), injecting them into the SDK's `answers` field so the agent continues seamlessly. Once the question is no longer pending (answered here or in another tab, after a reload, or closed by a timeout), the card is read-only and shows the chosen answers; it never posts an answer as a chat message.
- **ask_form** (Nerve's form tool, `mcp__nerve__ask_form`) — One card for up to six questions while the agent's tool call waits for the answers (up to an hour). The call's optional intro (Markdown, the agent's explanation of why it asks) shows above the card as chat text, and stays there after the card is answered and folds. In the card: an optional title, then each question with its label chip (`*` when required, or "Optional"), the question and the agent's reason in muted text. By kind: `choice` is option rows to pick one and `multi` option rows to pick any, each with **Other…** for the person's own words unless the agent turned it off (in `multi` the Other… text is one more answer); `text` is a two-row box that grows (Enter is a new line); `url` is checked to be http(s) with a host and no spaces; `list` is chips (a comma or Enter adds one, Backspace in the empty field removes the last, a paste splits at commas and line breaks, and leaving the field keeps the typed text); `repos` is chips like `list`, each `owner/name` (a pasted GitHub URL becomes `owner/name`; an invalid one is red with a hint and blocks Submit). Defaults are prefilled. **Submit** (or Cmd/Ctrl+Enter anywhere in the card) is enabled once every required question has an answer and nothing is invalid; Escape inside the card never stops the waiting turn. The answers go back through `answer_interaction` (`result: { answers }`), and the agent gets one line per question plus the same answers as JSON. The card answers only while its own form waits; after an answer here or in another tab, a timeout or a reload it is read-only. Answered cards fold, `ask_form` and AskUserQuestion alike: an answered or closed card shows one line per question, `LABEL → answer` (chips for a list, "—" for no answer); **Show questions** opens the full card, read-only, and **Hide** folds it again. Cards start folded, also after a reload, where the answers are read from the tool result; a card that still waits is shown in full. "Closed" means it ended without an answer (a timeout, the form was closed, or the session stopped).
- **ask_user** (Nerve's question tool, `mcp__nerve__ask_user`): the question and its options, answerable in the chat while it waits. The card reads the question's notification, so the chat, the Notifications page and the toast show the same question with the same option list: one full-width row per option with a letter key (A, B, C). While focus is in the list, a letter or a digit 1 to 9 chooses that row. An option that ends in "(recommended)" shows first with a "Recommended" tag. The last row, **Other…**, opens a field for an answer in your own words (Enter sends, Escape closes the field). An approval (`propose_action`, on the Notifications page and in the toast) has no Other… row and says "Nothing happens until you choose."; a question does not, because the agent can continue without the answer. In an approval, Approve (green, ✓), Decline (red, ✕) and Snooze 24h (moon) show their icon in place of the letter, and the answer line shows that icon in place of `✓`. The letter keys also choose these rows. After an answer from any place (the chat, the Notifications page, the toast, Telegram, Slack), each place shows `✓ <answer>` with who answered, when, and through which channel; **Show options** opens the list, read-only. When the server refuses an answer, the card reads the question again and shows the answer from a different place, if there is one; if not, "Could not send the answer. Try again." shows under the options. A question that expired shows "Expired without an answer". The toast shows all options and stays open until you answer or dismiss it; when the question is answered in another place, the toast shows the answer and closes after 5 seconds. A `notify` call shows as a "Notify" row.
- **ask_user** (Nerve's question tool, `mcp__nerve__ask_user`): the question and its options, answerable in the chat while it waits. The card reads the question's notification, so the chat, the Notifications page and the toast show the same question with the same option list: one full-width row per option with a letter key (A, B, C). While focus is in the list, a letter or a digit 1 to 9 chooses that row. An option that ends in "(recommended)" shows first with a "Recommended" tag. The last row, **Other…**, opens a field for an answer in your own words (Enter sends, Escape closes the field). An approval (`propose_action`, on the Notifications page and in the toast) has no Other… row and says "Nothing happens until you choose."; a question does not, because the agent can continue without the answer. In an approval, Approve (green, ✓), Decline (red, ✕) and Snooze 24h (moon) show their icon in place of the letter, and the answer line shows that icon in place of `✓`. The letter keys also choose these rows. After an answer from any place (the chat, the Notifications page, the toast, Telegram, Slack), each place shows `✓ <answer>` with who answered, when, and through which channel; **Show options** opens the list, read-only. When the server refuses an answer, the card reads the question again and shows the answer from a different place, if there is one; if not, "Could not send the answer. Try again." shows under the options. While a question or an approval waits, **Dismiss** under the options in the chat and on the Notifications page ends it without an answer: the agent that asked a question gets a note, and the Telegram and Slack cards lose their buttons (see [api.md](api.md#post-apinotificationsiddismiss)). A question that expired shows "Expired without an answer"; one that was dismissed in any place shows "Dismissed without an answer". The toast shows all options and stays open until you answer or dismiss it; when the question is answered in another place, the toast shows the answer and closes after 5 seconds. A `notify` call shows as a "Notify" row.
- **send_file** (`mcp__nerve__send_file`): an image file (PNG, JPEG, GIF, WebP, AVIF or SVG) shows in the chat, at most 320 px high, with its name and a download link below it. A click on the image opens it at full size, with a download card. Another file, or an image that does not load (for example, a file that was deleted later), shows as a download card. A call that sent nothing shows "Could not send `<name>`" and the reason, for example a file outside the workspace. The image comes from `GET /api/files/download`, the same as the download card.
- **EnterPlanMode** — A status line, "Entering plan mode…" while it runs and "Entered plan mode" when complete ("Plan mode declined" on an error), with no buttons: entering plan mode only restricts the agent, so no approval is involved (the Claude CLI switches the mode itself; if a CLI asks, the server allows it at once).
- **ExitPlanMode** — The decision card. The agent pauses mid-turn until the person decides. The card shows an autogenerated summary, not the plan itself: up to six short bullets that the server writes with `agent.title_model` when the pause starts (the `plan_summary` event), with "Summarizing…" until they arrive and the plan's first heading and two lines when no summary can be made; the full plan (the interaction's `tool_input.plan`, which the CLI fills with the plan file's text) is in the side panel, which **Read the plan** opens. **Approve** lets the agent go on (it starts on the plan in the same turn) and closes the plan tab. **Request changes…** opens a required "What should change?" field; **Send** refuses the plan with that text, which reaches the agent verbatim: it stays in plan mode, revises the plan and asks again with a new card. **Edit**, offered when the pause carries the plan text, opens the side panel with the plan as a text field ("Edit the plan"); there **Apply edited plan** (enabled once the text changed) approves the person's text instead (`answer_interaction` with `result: { plan }`): the CLI saves it to the plan file and tells the agent the plan was approved with the person's edits. **Cancel** drops the edits. A decided card keeps its outcome line and a **Read the plan** link that opens that plan read-only (the edited text, for an edited approval). In the onboarding conversation the card is titled "Setup proposal" with **Apply setup**, **Change something…** and "Edit the setup proposal". After an approval, while that turn still runs, the card reads "Approved — applying…" ("Approved with your edits — applying…"; "Applying the setup…" in the onboarding conversation), then "Approved" or "Approved with your edits"; a change request reads "Changes requested" with the first line of the feedback, and an error that is not the person's feedback (a timeout, a stopped session, a CLI validation error) reads "Closed" with its reason.
Expand Down Expand Up @@ -420,7 +420,7 @@ above the list ("Browser alerts are on" or "off") opens the setting.
Otherwise it starts with "(N)", the number of pending notifications that
the Notifs badge shows. The favicon gets a red dot for "!" and a blue dot
for "(N)". A page that sets its own title gets the prefix again. The server
sends no event for a dismissal, so a tab that dismisses notifications tells
sends no event for Dismiss All, so a tab that dismisses notifications tells
the other tabs on the same `BroadcastChannel`, and they read the count
again. This needs no permission and no setting.
- **Limits:** browsers show alerts only for HTTPS addresses (and
Expand Down
4 changes: 2 additions & 2 deletions nerve/channels/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -1632,7 +1632,7 @@ async def expire_notification(
message_id: str,
text: str,
) -> None:
"""Replace a notification card with its expired state."""
"""Replace a notification card with *text* and no buttons."""
if not self.is_available:
return
channel_id, _ = parse_target(target)
Expand All @@ -1645,7 +1645,7 @@ async def expire_notification(
)
except Exception as exc:
logger.debug(
"Slack expiry edit failed for %s: %s", message_id, exc,
"Slack card edit failed for %s: %s", message_id, exc,
)

async def send(self, message: OutboundMessage) -> None:
Expand Down
9 changes: 7 additions & 2 deletions nerve/db/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,15 @@ async def find_notification_for_delivery(
async def dismiss_notification(
self, notification_id: str, *, attribution: Attribution = UNATTRIBUTED,
) -> bool:
"""Dismiss a pending row. ``attribution.actor_id`` is who dismissed it."""
"""Dismiss a pending row. ``attribution.actor_id`` is who dismissed it.

An approval that a press claimed (see :meth:`claim_pending_approval`)
is not dismissed: its dispatcher runs the decision.
"""
async with self._atomic():
async with self.db.execute(
"SELECT id FROM notifications WHERE id = ? AND status = 'pending'",
"""SELECT id FROM notifications
WHERE id = ? AND status = 'pending' AND answered_at IS NULL""",
(notification_id,),
) as cursor:
if not await cursor.fetchone():
Expand Down
Loading
Loading