Notifications¶
Overview¶
Sase includes a notification system that surfaces important events from background
processes (axe, workflows, mentors) to the user through sase's TUI. Notifications are
stored as JSONL and persisted to ~/.sase/notifications/notifications.jsonl.
Plan, epic-plan, question, agent-launch, sudo, and task-triage approvals use the
notification row as a typed transport projection of a durable interaction gate. The
reviewed content, option-query branches, validation schemas, and hash-verified commands
live in ~/.sase/interaction_requests/<kind>/<request-id>/; sase's TUI, mobile,
Telegram, and typed CLI actions all resolve that same bundle. Sudo requests add a
terminal-only approval path; see Sudo Requests.
Remote Attention¶
When remote machines are enrolled, sase's TUI normal refresh polls their global pending
attention inventory independently of the Agents rows currently visible or followed. Each
authorized remote question or gate becomes a durable unread notification in the
Attention panel, labeled with the owning machine alias. Selecting the notification
opens the same answer/approve modal as the corresponding remote Agents row and submits
the decision through the durable sase machine attention operation.
The inbox reconciles requests by origin installation, request ID, and revision. Read and dismissed are local browsing state, never an answer to the remote request: re-polling the same revision refreshes the row's content but keeps your read or dismissed choice, along with existing mute and snooze choices. A new revision creates a fresh unread row that needs renewed review, and sase's TUI dismisses the superseded revision. When a fresh, complete host inventory no longer contains a request, sase's TUI dismisses the stale row. Only rows sase's TUI dismissed itself come back, unread, if that same revision is reported pending again. A failed, unavailable, cached, stale, partial, or paged (more results pending) host inventory does not settle missing requests, so a transient outage cannot silently remove a pending decision. If the owning alias is later removed or quarantined, submission stops with a diagnostic instead of routing the answer elsewhere.
Viewing Notifications¶
Press i on any tab in sase's TUI to open the notifications modal. The modal receives
the complete unread, non-silent inbox dataset, so every populated tab and every eligible
row remains accessible even when the backlog is large. Rows in the list show relative
timestamps (e.g., "2m ago", "1h ago") and can be marked as read or dismissed. Dismissed
rows are not gone for good: the dismissed view lists them again for
review and one-key restore. The detail pane shows the selected notification's absolute
send time alongside its relative age (sent today 13:18:42 · 4m ago), tiered as
today HH:MM:SS / yesterday HH:MM / Mon D HH:MM / Mon D 'YY HH:MM in the
configured timezone.
Modal Keybindings¶
| Key | Action |
|---|---|
j / k |
Navigate between notifications |
Enter |
Select notification (jump to PR, approve plan, etc) |
d |
Open Gate Debug for the highlighted row |
x |
Dismiss notification, or dismiss marked rows when marks are present |
u |
Restore (undismiss) the highlighted dismissed row, or marked rows |
m |
Toggle the per-row mark on the highlighted notification |
M |
Toggle mute on the highlighted notification, or marked rows |
s |
Snooze the highlighted notification, or marked rows (opens duration picker) |
e |
Open attached file in $EDITOR |
V |
Open the current image attachment in the image viewer |
Ctrl+N / Ctrl+P |
Cycle through attached files |
Ctrl+D / Ctrl+U |
Scroll file content down / up |
g / G |
Jump the detail pane to the top / bottom of its contents |
1–9 / 0 |
Jump to the 1st–9th or 10th current tab; a missing position does nothing |
[ / ] |
Cycle notification tabs |
+ |
Cycle newest-first through the selected row's +1 evidence, then default |
R |
Mark every unread notification in the active tab read (confirms first) |
S |
Toggle the active tab between sectioned and newest-first rows |
T |
Toggle between the unread inbox and the dismissed-notifications view |
Esc / q |
Close modal |
The detail-pane scroll keys work with either the notification list or detail pane
focused. Apostrophe jump mode consumes hint keys first, so g and G select matching
jump hints while hints are visible instead of scrolling the detail pane. The same
first-refusal applies to digits: 2 selects the second tab in normal mode, while '
then 2 (or a two-character hint such as 10) jumps to the matching row without
changing tabs.
Every gate-backed notification (plan, epic, question, launch, workflow HITL, custom,
sudo, task-triage, flag-triage, snooze, stale-cleanup, and required-plugin) and every
remote attention row requires confirmation (y / n) before
dismissal to prevent accidental loss of pending decisions. The same y / n
confirmation is used for bulk dismissal when at least one marked protected notification
is included in the batch.
Dismissed View¶
Press T to switch the modal between the unread inbox and the dismissed view, which
lists dismissed rows that are still unread; the title reads Notifications (dismissed)
while that view is active. Press u to restore the highlighted row (or every marked
row) to the inbox, then press T again to return to the normal inbox.
A dismissed live gate stays unread — selecting it never marks it read, and dismissing it
never does either — so it always resurfaces in the dismissed view until it is answered
or restored. Rows that are both read and dismissed (for example after R marks their
tab read) are outside the inbox's unread scope and stay out of both views; the pending
gate bundle itself remains reachable through the gate CLI (sase gate list /
sase gate show).
R is scoped to the tab you are on, not the whole inbox, and it is a wider write than
it looks: it marks the tab read in the notification store, which includes rows matching
that tab that sase's TUI has not loaded into the visible list. Because of that, it opens
a danger confirmation naming the tab (Mark Notification Tab Read?) that defaults to
Cancel; it cannot be undone from sase's TUI. The target is frozen when the prompt
opens, so switching tabs while the confirmation is up cannot redirect the write to a
different tab, and a tab with nothing in the visible list never prompts at all. The
mutation itself runs as a proc, so a slow store write does not block the modal. On
confirmation the marked-read rows leave the visible list immediately, the emptied tab
disappears from the tab strip, and the modal moves to the nearest surviving tab (or
shows No unread notifications when that was the last tab). A toast reports how many
notifications the store marked read.
Gate Detail Pane¶
Highlighting any gate-backed row — plan, epic, question, launch, custom, sudo,
task-triage, flag-triage, snooze, stale-cleanup, required-plugin, or workflow HITL —
always renders a live decision card in the right pane: a status line
(Awaiting your decision, Answered, Cancelled, Timed out, or
Gate details unavailable), the notification's context and tags, a Decision block
listing every branch in canonical query order with the primary branch marked, and an
Attachments line when the gate has files. The card renders instantly from the
notification row and enriches itself with the verified bundle a moment later without
blocking navigation. When a bundle cannot be resolved, hashed, or parsed — a deleted
directory, a corrupted request.json, a legacy bundle layout — the card degrades to
▲ Gate details unavailable rather than going blank; press d to open Gate Debug and
see exactly why. Every other notification, including attachment-less ones, gets a
compact summary card instead of an empty pane.
+1 Evidence¶
A sender can append corroborating evidence to an existing notification without creating
a duplicate row. The list renders a [+N] badge after the title; the count includes
older evidence dropped by bounded retention. The default detail card adds a
+1 EVIDENCE block with each retained sender, age, and note. Report cards also show the
total and latest retained evidence age in their provenance line.
Each row retains its newest 500 entries and counts older dropped entries in N. SASE
collapses whitespace in each note and stores at most 2,000 characters. Repeated entries
from the same sender remain separate occurrences.
Press + to replace the detail pane with the newest retained +1, then walk backward
through older entries. One more press after the oldest entry wraps to the normal detail
pane. Changing rows or tabs also resets the cycle. A row with no retained evidence shows
a status hint and leaves the pane unchanged.
Appending a +1 is deliberately evidence-only: it does not change the notification's
read, dismissed, muted, or snoozed state, activity ordering, or delivery cursors.
Tabs and Ordering¶
The modal renders a compact tab strip above the list whenever there is at least one tab.
It is hidden only at zero tabs, which is also when the list itself is replaced by the
No unread notifications message — so the strip never pops in and out, and the list
never shifts, as dismiss, mute, and snooze actions collapse the tabs down to one.
Every notification belongs to exactly one tab — the Rust core decides which one by a
fixed precedence, so the panel, the top-bar indicator, and the mobile snapshot always
agree; see Tags below for that precedence in full. Tabs then sort by
priority descending, with the core's order as
the tiebreak at equal priority. The tabs, at their default priorities:
| Tab | Icon | Priority | Contents |
|---|---|---|---|
Gates |
⚑ |
60 |
Plan and epic approvals, user questions, workflow HITL prompts, launch approvals, and generic gates without a declared panel. |
| Panel | ◆ |
50 |
Gates with presentation.panel. At equal priority they keep the core's label order after Gates; remote questions and gates use Attention (?), while built-in task triage gates use Beads (◈) and woken BeadSnooze, due FlagTriage, and BeadStaleCleanup gates land there too. The shipped beads priority is 0, so Beads sits between custom tags and Snoozed and renders a ▾ mark. |
Errors |
✖ |
40 |
Axe digests, failed file hooks, and agent errors (axe, file-hooks, or user-agent with ViewErrorReport). |
General |
✉ |
30 |
Untagged, unmuted notifications with no other classification. |
Done |
# |
20 |
Notifications carrying the done tag, pinned before other custom tags. |
| Custom | # |
10 |
Other normalized notification tags, sorted alphabetically after Done at equal priority. Sudo requests declare no panel, so they land in the sudo tag tab rather than Gates. |
Snoozed |
☾ |
-10 |
Muted notifications with a future wake time — snoozed notifications and notifications for snoozed task beads alike. |
Muted |
⊘ |
-20 |
Muted notifications with no wake time. |
Each tab's icon resolves through the same chain as its color; see
Tab icons below. A tab whose effective priority differs from its default
renders one extra cell immediately after the count: ▴ (amber #FFAF00) when raised,
▾ (grey #8A8A8A) when lowered. Compact-mode tabs keep the mark after shedding the
label. The top-bar indicator chips omit it — a chip is already <icon><count>, and the
badge's left-to-right order plus the hover tooltip (▾ priority 0) spell out the
deviation.
The first ten tabs, in that same current order, carry an unboxed shortcut digit
immediately before the icon: 1–9, then 0 for the tenth. The mapping is positional
and is resolved from the live classified order on every keypress and every render, so
dismiss, mute, snooze, mark-read, and priority changes cannot leave a stale digit. Tabs
past the tenth stay unnumbered and remain reachable with [ / ] or the mouse.
Pressing a digit with no tab at that position is a no-op that stays inside the modal.
The strip reflows to fit its measured width rather than clipping, in three tiers. Full shows shortcut, icon, label, count, and optional priority mark for every tab. When that would overflow, compact keeps the shortcut on every tab, sheds inactive labels, and retains the active name. When compact still does not fit, micro packs shortcut, icon, and count with tighter separators. Shortcut digits are never shed. Shedding labels is what keeps a tab from falling off the end of the line, where it would be both invisible and unclickable; a resize re-renders the strip only when the width actually changed.
A row with multiple tags therefore occupies exactly one tab, not one per tag; dismissing it removes the row from at most one tab's count.
Sections¶
Tabs can render their rows under ordered section headers. The shipped strategy is
enabled for Beads, where task-bead rows are grouped by task type in catalog order:
Bug, CI failure, Feature, Flaky test, and Memory, followed by Due, Cleanup, and Other
for rows without a task-type chip. Rows inside each section keep the same activity
ordering described below.
Press S to toggle the active tab between grouped sections and recent, a flat
newest-first render. The choice is per tab and lasts only for the current sase's TUI
process. Tabs without a grouping strategy stay flat and report that they have no
sections.
Within the active tab, rows are ordered newest-first by their activity time —
resurfaced_at when a snooze has expired, otherwise timestamp (see
Activity Ordering). Rows with equal activity times keep their
original arrival order, and rows whose activity time can't be parsed fall to the bottom
rather than breaking the modal. The sort runs on every modal rebuild, so live actions
like mark-read, dismiss, mute, and snooze update the visible order immediately.
Switching tabs with [ / ] or a mouse click clears modal-local marks so a hidden row
is never bulk-dismissed by accident.
Marks and Bulk Actions¶
Press m on a notification to toggle a per-row mark. Marks are scoped to the open modal
— closing the modal clears them. While at least one row is marked, x, M, and s
target every live marked row instead of the highlighted row. Gate-backed and remote
attention rows in a marked dismiss batch use the same y / n confirmation prompt as a
single dismissal.
Successful marked mute, unmute, snooze, and dismiss actions consume the acted-on marks.
Stale marks are pruned; if no live marks remain, M and s fall back to the
highlighted row rather than writing an empty batch. Marked mute uses one shared state
for the whole target set: if any marked target is unmuted, M mutes all targets;
otherwise it unmutes all targets and cancels any pending snoozes.
Mute and Snooze¶
Press M on a notification to toggle its muted state, or on marked rows to toggle the
whole marked set. Muted notifications are dimmed in the list, prefixed with ~, and
moved to the Muted tab. They are still delivered to the JSONL store, remain visible in
the modal, and get their own counted chip in the top-bar indicator — only the toast
pipeline and arrival bell ignore them.
Press s to snooze a notification, or marked rows, for 15m, 1h, 4h, or until
tomorrow morning. A marked snooze computes one deadline and applies it to every target.
Snoozed notifications are implicitly muted, but a future wake time routes them to the
Snoozed tab rather than Muted (see Tabs and Ordering), and
they display a ⏰ <remaining> badge counting down to the snooze expiry. Toggling mute
off cancels any pending snooze. The snooze deadline is persisted as a canonical UTC
instant, so the notification re-emerges from Snoozed on its own once the deadline
passes — see Snooze Expiry and Resurfacing for the
exact state transitions, timing guarantee, and recovery behavior.
Snooze Expiry and Resurfacing¶
Exact Elapsed Versus Calendar Time¶
Duration presets (15m, 1h, 4h) mean exactly that much elapsed time, so they are
added on the UTC timeline. A four-hour snooze started just before a DST transition still
expires after four real hours, not three or five. Calendar presets such as "tomorrow
morning" resolve in the configured IANA timezone first (09:00 local) and are then
converted to UTC for storage. Display formatting stays local; the stored deadline is
always a canonical UTC RFC-3339 instant.
New snooze writes accept only timezone-aware, parseable, future instants. A naive, malformed, or already-past deadline is rejected before any row changes, and a rejected bulk snooze leaves every target untouched. Snoozing a dismissed or unknown notification is likewise a no-op rather than a silent success, so the modal never shows a false "Snoozed" state — a store failure or stale row reloads authoritative state and shows an actionable error instead.
State Transitions¶
| Event | muted |
snooze_until |
read |
resurfaced_at |
Delivery result |
|---|---|---|---|---|---|
| Snooze active row | true |
validated future UTC instant | unchanged | unchanged | hidden from active delivery until due |
| Resnooze | true |
replacement future UTC instant | unchanged | unchanged | only the replacement deadline is scheduled |
| Expire active row | false |
null |
false |
expiry instant | one new activity generation becomes visible |
| Explicit unmute | false |
null |
unchanged | unchanged | timer is cancelled, not treated as an expiry |
| Dismiss | unchanged | null |
unchanged | unchanged | pending snooze is cancelled and can never alert later |
Expiry is atomic and batched: every row that is due at the same reconciliation stamps
the same resurfaced_at instant, becomes unmuted and unread, and clears its deadline.
A row that was marked read while snoozed still returns to the inbox as unread. Permanent
mutes (muted with no deadline) are never touched, and dismissed rows are skipped
entirely, so a dismissed notification can never ring later.
Timing Guarantee¶
While a supporting long-lived consumer is running, a snoozed notification becomes current within that consumer's tolerance of its wall-clock deadline:
- sase's TUI session — one second, including after suspend/resume, a restart, or a
system-clock change, and independently of
--refresh-interval. sase's TUI schedules a deadline-driven coordinator rather than relying on the general refresh tick. - Mobile gateway — the next authenticated list or detail read, which expires the row
and publishes a
notifications_changedevent so connected clients refresh. - Telegram outbound job — the next scheduled job run.
If every consumer is offline, no alert is emitted at the deadline; the durable guarantee is instead that the first later current-state read atomically catches the row up before returning any state. Expiry is therefore never lost, only deferred to the next reader.
Because expiry happens under the store lock, exactly one concurrent reader observes a
given row in its expired_ids transition metadata. Every other consumer still observes
the result through the persistent muted, read, and resurfaced_at fields, so a
losing process never misses the resurfacing. sase's TUI emits one toast and one tmux
bell per observed resurface batch and does not repeat it on later polls.
Activity Ordering¶
Every consumer orders notifications by an effective activity key rather than the raw creation time:
activity_at(notification) = resurfaced_at ?? timestamp
activity_cursor(notification) = (activity_at, notification_id)
timestamp keeps its immutable meaning as the original creation time and is what the UI
displays as the sent time. The activity key is what makes a resurfaced snooze
first-class recent activity: an old row moves to the top of sase's TUI modal, the first
sase notify list page, and the first mobile page instead of staying buried. The
notification-ID tie-breaker is required anywhere a cursor is persisted (mobile
newer_than/high-water, Telegram delivery) so two rows sharing an activity instant
cannot hide one another.
Legacy and Malformed Deadlines¶
A legacy row whose snooze_until is unparseable or timezone-naive must not stay
silently muted forever. The current-state reconciliation path treats it as immediately
due: the row resurfaces at once and is reported as an expiry, while every new mutation
path rejects such a value outright. Rows written before the resurface field existed
default cleanly to resurfaced_at: null.
Raw Versus Current-State Reads¶
Raw audit reads (load_notifications(), the non-expiring snapshot read) deliberately
never mutate time-driven state, so inspection and export paths cannot cause a resurface
as a side effect. User-facing "current inbox" reads
(read_current_notification_snapshot() and the CLI, sase's TUI, mobile, and Telegram
projections built on it) atomically expire due rows under the store lock before
projecting rows, counts, expired_ids, and the next active deadline.
Top-Bar Indicator¶
The inbox: group in the TUI top bar renders one colored <icon><count> chip per
notification-panel tab (see Tabs and Ordering), in the panel's own
left-to-right order, so the badge and the panel always agree on what each count means:
- Nothing pending — a dim
inbox: 0. - Snoozed only —
inbox: ☾4: the count renders in the Snoozed tab's resolved color at a dimmer weight than an actionable count, prefixed by the Snoozed tab's own icon instead of a trailingzsuffix. - Anything else — one
<icon><count>chip per visible tab, each the tab's icon and count in its own resolved color at full weight, joined by a single space, for exampleinbox: ⚑2 ✖3 ◈1. Each chip is self-identifying, so there is no separator glyph between chips — the·separates groups, not tab chips — and no✉anchor: oncegeneralowns✉as its own tab icon, prefixing the whole badge with it would render the same glyph twice, meaning two different things. As soon as any non-snoozed tab has a count, the Snoozed chip drops out of the badge entirely — it does not compete for the limited chip budget — but the snoozed count still appears in the tooltip. - Overflow — at most
ace.notification_indicator_max_countschips (4 by default), taken in panel order; any remaining tabs collapse into one trailing dim+Kchip, joined by the same single space. Every suppressed tab is still described in the tooltip.
The chip budget is display-only. It does not limit the notification modal dataset, tab
counts, or the Agents Enter action. On the Agents tab, Enter searches the complete
unread dataset for the selected agent's pending gate (every gate kind), so older pending
agent decisions remain reachable regardless of indicator overflow or backlog size.
Hovering the indicator opens a tooltip briefing: a header count of unread rows (snoozed
and muted rows are informational and excluded from that header count) followed by one
line per tab showing its icon, its count, its oldest unread activity (oldest 14m ago)
or, for the Snoozed tab, when it next wakes (next wakes in 43m). Tab labels in the
tooltip are colored the same as their indicator chip, so the tooltip doubles as a
legend. See Tab colors and Tab icons for how each tab's
color and icon are resolved.
Silent notifications never contribute to the indicator (see Silent Notifications below).
Visual and Audible Delivery¶
New unmuted notifications remain visually prominent through the top-bar indicator and
action-specific toasts. A genuinely new PlanApproval or EpicApproval rings once on
arrival, alongside its priority inbox row, warning toast, and the producer's desktop
notification. sase's TUI toast says Tale ready or Epic ready; an epic adds the
gate-time phase, dependency-wave, and non-zero phase-size counts, while batched toasts
count tales and epics separately. Already-handled plan reviews discovered during polling
and the intermediate post-approval handoff remain silent. Task triage, stale-cleanup,
questions, launch/custom/HITL/sudo gates, errors, agent completions, and ordinary
notifications retain their arrival bell. Priority actions include PlanApproval,
EpicApproval, UserQuestion, LaunchApproval, TaskTriage, BeadSnooze,
FlagTriage, BeadStaleCleanup, PluginsRequired, GateExecutionFailed,
JumpToMentorReview, and RemoteAttention.
Snooze expiry is an explicit reminder chosen by the user and, by default, remains audible for every notification class, including a snoozed tale or epic review. A delivery rule that matches the resurfaced row still applies to it.
Every toast and arrival sound described here is the default. ace.notification_rules
overrides either per notification; see Delivery Rules.
Delivery Rules¶
ace.notification_rules is an ordered list of rules that decide, per new notification,
whether sase's TUI shows a toast and how the arrival is announced: the terminal bell, a
sound file of your choosing, or silence. With no rules configured every notification
toasts and rings the bell once per poll, exactly as described above.
ace:
notification_rules:
- name: quiet-task-beads
description: Task-bead triage is too noisy to announce right now.
match:
tab: beads
toast: false
sound: none
- name: mac-chime
sound: /System/Library/Sounds/Glass.aiff
| Key | Type | Meaning |
|---|---|---|
name |
string | Label for sase notify rules, doctor output, and diagnostics |
description |
string | Free prose explaining why the rule exists |
priority |
integer | Evaluation weight, -1000..1000; higher is consulted earlier |
match |
mapping | Criteria. Omitted or {} matches every notification |
toast |
boolean | Whether a TUI toast is shown |
sound |
string | bell, none, or the path of a sound file |
Every key is optional, and an unknown key drops the whole rule rather than being
ignored: a rule applies exactly as written or not at all. A rule with no name is
reported as rule[<index>], counting from 0 across the rules that survived. sase
versions that predate delivery rules report ace.notification_rules as an unknown key
in sase config validate and sase doctor.
Matching¶
Six criteria, each drawn from something visible on the notification row:
| Criterion | Matched against |
|---|---|
tab |
The tab that owns the row (see Tabs and Ordering): beads, hitl, errors, general, a tag, and so on |
sender |
The notification's sender |
action |
The notification's action; an empty string for a row with no action |
tags |
Any one of the notification's tags |
title |
The first note, which the panel shows as the row headline |
note |
Any line of the notification's notes |
- Every value is a case-insensitive glob:
*,?,[abc],[a-c], and[!abc]. A value with no wildcard is therefore an exact, case-insensitive match. There is no substring or fuzzy matching:title: "Plan ready"does not matchPlan ready for review, whiletitle: "Plan ready*"does. Write[*]for a literal*. A[with no closing]matches a literal[. - A criterion takes one string or a list of strings; a list is any-of.
- Several criteria in one
matchare all-of. tab: gatesis an alias fortab: hitl, because the panel labels that tabGates. The muted and snoozed tabs are__muted__and__snoozed__.
The tab a row matches is the tab the panel shows it under, because both come from the
same core classification. sase notify rules --explain reports the tab of the row as it
is stored now, so a muted or snoozed row reports __muted__ or __snoozed__.
Resolution¶
Rules are consulted in descending priority, with ties broken by position in the merged
list. For toast and for sound independently, the first matching rule that sets
that field decides it. A field no matching rule sets keeps the default: toast shown,
sound bell. A rule that sets only toast therefore never blocks a later rule from
choosing the sound.
The merged list follows config layering: rules from ~/.config/sase/sase.yml come
first, then rules from machine overlays such as sase_<machine>.yml, then project-local
rules. A machine-wide sound rule in an overlay can sit under a narrower rule in
sase.yml without re-stating it. Use priority only when a later layer must
deliberately override an earlier one.
Because the first match wins, no negation is needed. To silence everything except gates, say what gates do, then silence everything else:
ace:
notification_rules:
- name: gates-announce
match:
tab: gates
toast: true
sound: bell
- name: silence-the-rest
toast: false
sound: none
A gate row matches gates-announce, which decides both fields before the second rule is
consulted. Every other row falls through to silence-the-rest, whose empty match
matches all notifications.
Sounds¶
sound is a single value with two reserved words, compared case-insensitively:
| Value | Announcement |
|---|---|
bell |
The terminal bell through tmux, three rings (the default) |
none |
Silence |
| anything | The path of a sound file, with ~ and $VAR expanded and then played |
A file literally named bell or none is addressed as ./bell. sase's TUI picks the
first audio player found on PATH:
| Platform | Players, in order |
|---|---|
| macOS | afplay |
| Linux | paplay, aplay, ffplay -nodisp -autoexit |
A missing file, a missing player, a player that fails or hangs, or an unsupported
platform never interrupts the poll: the announcement is simply silent. A player that
runs longer than 30 seconds is stopped. bell rings through tmux only, using the
tmux_ring_bell helper found on PATH: when sase's TUI is not running inside a tmux
pane ($TMUX_PANE unset) or the helper is missing, bell is silent. Playback runs on a
worker thread, so it cannot stall the TUI. A sound file also plays detached from the
poll that triggered it — a long chime never holds up the notification tick or the agent
refresh behind it — and only one file plays at a time, so notifications arriving faster
than the file is long cannot stack up players.
Each poll announces at most one sound. It is the resolved sound of the first new
notification, in activity order, whose sound is not none; if every arriving row
resolves to none, the tick is silent. A burst of five notifications is one chime, not
five.
What a rule does not change¶
toast: false suppresses the toast, and sound: none the sound; nothing else changes.
The notification is still stored, still unread, still counted in the top-bar indicator,
and still listed in the panel under its tab. Rules never affect whether a notification
is created, read, muted, or snoozed. Suppressed rows are also left out of grouped
toasts: a batch of six with three suppressed produces three individual toasts rather
than one group of six.
Inspecting rules¶
sase notify rulesprints the merged rules in the order they are consulted, with the config layer each came from, its criteria, and the behaviors it sets. Entries that were dropped as malformed are listed with the reason.sase notify rules --explain <id>(-e) takes a stored notification ID or unique prefix and prints the fields the rules match on, then fortoastandsoundthe rule that decided it or that the default applied. Add-j/--jsonto either form for machine-readable output.sase doctor -C config.notification_rulesflags unknown keys, amatchthat is not a mapping, a glob with an unclosed[, an empty criterion list, asoundfile that does not exist, a sound file with no audio player available on this platform, and a rule that sets neithertoastnorsound.
Notification Types¶
The following events generate notifications:
| Sender | Event |
|---|---|
plan / epic |
A tale or epic plan is ready for user review and approval |
bead |
A task bead needs triage, a snoozed task woke, a due flag task bead needs FlagTriage, or stale uncorroborated tasks need BeadStaleCleanup |
plugin |
A project's required plugins are missing; the gate offers to install them |
launch |
A running agent requested a new agent launch for approval |
question |
An agent is asking the user a question (via /sase_questions) |
sudo |
An agent requested reviewed privileged execution (via sase sudo request); see Sudo Requests |
custom |
A sase gate create custom gate is waiting for review, unless its presentation.sender names another sender |
hitl |
A workflow HITL step is waiting for user input |
sync |
A sync operation completed for a Patch |
axe |
Hourly error digest summarizing recent axe errors |
service |
A desired-running service proc entered crash-loop or the restart policy gave up on it; the notes name the reason, restart count, log path, and the reviving sase service proc start <name> command |
file-hooks |
A configured per-file hook completed or failed, or a producer-side dispatch failure before a command ran |
mentors |
All mentors finished for a Patch entry (or none matched) |
wait_checks |
A %wait dependency ended in a terminal state that can never satisfy the waiter |
gate |
A gate turn's follow-up handoff failed; the notes name the failed stage and the resume command |
agent_hold |
A %hold or sase agent hold admission hold was armed or released |
runner_slot_admission |
A held agent and the agent holding it are blocking each other (hold deadlock) |
| Workflow-specific sender label | Workflow completion (success or failure) |
Task Triage Notification¶
The five-minute bead_task_triage job creates one human-only TaskTriage gate for each
ready task bead that has accumulated at least its
effective +1 bar of independent +1 reports — its
task type's own triage.min_plus_ones, or
bead.task_triage.min_plus_ones for an untyped or unregistered
type. A sub-threshold task is withheld from triage — it stays stored as ready, only
the gate is withheld — and a TaskTriage gate already raised for a task that later
falls below the bar is canceled and its notification dismissed on the job's next tick.
After upgrading, gates already raised for beads below the new bar are canceled and their
notifications dismissed automatically on the first checks-lane tick. Run
sase axe job run bead_task_triage to force that tick immediately. To see the matching
cleanup gate without waiting for the hour, run sase axe job run bead_stale_cleanup.
Its compact notification note (notes[0]) is <bead-id> — <title> and it lands in the
Beads panel while retaining the bead and task tags. Every gate whose subject is a
typed task bead also declares a presentation.chip
— the type glyph, the slug as its label, and the type accent — plus a second note
(notes[1]) with the compact typed facts line, for example
Flaky test · Test node ID: tests/x.py::test_y · Evidence: 3/50 under -n 8. The type
slug is appended as a display tag. An untyped bead's notification stays a single note
with no chip. The filing agent, when known, appears as a Filed by line in the
Markdown preview above the task's description and notes; the notes section is present
only when the bead has notes. The gate offers three branches:
- Launch is the default. It submits an unattributed proc that runs
sase bead work <task-id> --yes-to-all; optional feedback is appended to the worker prompt. - Close requires feedback and closes the bead with that reason and
resolution=canceled. - Snooze collects one required
durationline containing a wake time with an optional+Nsuffix, for example3d,2026-08-09T09:00:00-04:00, or3d +2, the same vocabulary assase bead snooze. Feedback is optional here and records why the task was deferred. An unparsable value fails the option and leaves the gate pending rather than losing the bead's only triage gate. It defers the bead — the triage gate settles and aBeadSnoozewake gate takes its place once the reconciler's next tick runs; see Snoozed Task Notification below.
No decision branch is chosen automatically. While one of TaskTriage/BeadSnooze
remains pending for a bead, the job that owns both kinds (bead_task_triage) suppresses
duplicates and keeps the two mutually exclusive — a task bead never holds both at once.
After the bead mutation commits, sase bead close makes a best-effort attempt to cancel
the matching pending gate; a cancellation failure does not fail the close, and the next
reconciliation remains the backstop. Choosing Launch in TaskTriage answers that
gate normally, and a successful launch submission from sase's TUI Beads pane explicitly
cancels it. A direct sase bead work <task-id> command does not settle an older gate
itself; while a live agent is working the bead, the next reconciliation cancels that
stale gate with reason bead_work_in_flight. The same liveness rule covers BeadSnooze
and FlagTriage. If the bead's status otherwise changes out of band (leaves ready,
gets snoozed, or wakes), the job cancels the gate of the wrong kind and creates the
right one on its next tick. If a gate becomes terminal, disappears, or uses an obsolete
presentation or option-input contract while still expected, the next five-minute scan
creates a replacement with a new generation-specific request ID, except while the task
bead's detached launch is still in flight or a live agent is working the bead.
Snoozed Task Notification¶
A snoozed task bead's BeadSnooze gate is born already snoozed: the reconciler creates
it with presentation.panel: "beads" and presentation.snooze_until set to the bead's
wake time, so the notification is muted with that deadline in one atomic step — there is
no window where it appears unread. It sits in the Snoozed tab for the whole deferral
(see Tabs and Ordering) and resurfaces in the Beads panel tab,
unmuted, exactly like any other snooze expiry once its wake time arrives. The compact
notification note (notes[0]) is still <bead-id> — <title>. Every gate whose subject
is a typed task bead also declares the same
presentation.chip and typed facts notes[1] as a
typed triage gate; an untyped bead stays a single note with no chip. The preview shows
who snoozed the bead, when, why, the wake time, and +1 progress toward any configured
target.
The gate offers three branches:
- Close is the default. Empty feedback closes the bead with a preset reason
(
"Snoozed until <until> with no new evidence; closing as stale."); any feedback text replaces that reason verbatim. Resolution iscanceled. - Ready returns the bead to
readywith a preset note, and the ordinaryTaskTriagegate takes over on the reconciler's next tick. - Snooze collects the same required
durationline as the triage gate's snooze option and re-snoozes the bead with a new wake time and optional+Ntarget; an unparsable value fails the option and leaves the gate pending rather than losing the bead. Feedback is optional and replaces the recorded deferral reason.
Reaching a configured +1 target wakes the bead independently of the wake-time gate:
the bead promotes straight to ready with a preset note, and the pending BeadSnooze
gate is canceled in favor of a fresh TaskTriage gate. The two wake conditions race;
whichever is reached first wins.
Flag Triage Notification¶
A due task bead of type flag raises one FlagTriage gate through the same
bead_task_triage reconciler that owns the other two task-bead gate kinds. The
notification lands in the Beads panel with bead and task tags plus the flag type
chip and tag. Its preview shows the flag key, kind, both-branch prose, remove_when,
both removal thresholds, the due countdown, the registry definition, notes, and call
sites.
The gate offers four branches:
- Remove is the primary path. It deletes the Off branch and makes the On branch
unconditional, requires the winning branch (
enabledordisabled) for the worker brief, and launches a worker to remove the registry entry and close the flag bead. - Extend requires a new date/release threshold and a reason, then pushes both thresholds out and leaves the bead live.
- Keep requires a rationale for making the behavior permanent. It was never a feature flag; convert it to an ordinary config field and close the bead.
- Close abandons the removal. It requires a reason and closes the bead; registry/bead integrity checks catch any surviving orphaned flag.
Stale Task Cleanup Notification¶
The hourly bead_stale_cleanup job raises one human-only BeadStaleCleanup gate once
at least bead.task_triage.stale_cleanup_min_beads ready task
beads have sat below the +1 bar for
bead.task_triage.stale_after_days days. The notification
lands in the Beads panel with bead, task, and stale tags. Its preview lists the
offered roster (at most 50 beads, oldest first) and names how many additional stale
beads were omitted when the backlog is larger. Each offered bead is a close/keep control
defaulting to close; the single Close selected branch closes the reviewer-selected
subset as canceled. Selecting nothing fails the command and leaves the gate pending.
The job keeps at most one of these gates at a time and cancels it when the backlog drops
below the bar. Run sase axe job run bead_stale_cleanup to raise or refresh that gate
without waiting for the next hourly housekeeping tick.
Required Plugin Notification¶
The five-minute plugins_required job raises one human-only PluginsRequired gate per
enabled project whose plugins.required entries are missing or version-mismatched. The
notification lands in the Plugins panel with plugin and required tags. Its preview
lists each unsatisfied requirement and notes that a successful install restarts the
scheduler.
The gate offers two branches:
- Install is the primary path. It performs one combined install for every missing
requirement, sharing a bounded public-index probe and resolving each plugin from the
index unless public PyPI returns a definitive 404, in which case that plugin uses git.
When planning or the
uvmutation fails, including when sase is not auv toolinstall, the gate stays pending rather than reporting a phantom success. - Dismiss records the decision so the same missing set is not re-offered until it changes.
The job cancels the gate when the required set becomes satisfied. Agent and
non-interactive contexts still fail closed and never auto-install. Run
sase axe job run plugins_required to raise or refresh those gates without waiting for
the next five-minute checks tick.
Blocked Wait and Hold Notifications¶
These informational rows have no action, so they carry nothing to approve. Each one is
deduplicated, and a repeat occurrence appends +1 evidence to the existing row instead
of creating a new one.
- Terminally blocked waits. The
wait_checksjob notifies once per parked waiter whose named%waitdependency already ended with an outcome that can never satisfy the wait, such as a failed or killed agent. Waiters whose runner is provably dead are skipped and never notified. The notes name the waiter, the blocking dependency, its artifact directory, and its outcome, and suggest killing and relaunching the waiter or deliberately clearing the wait. The row attaches both artifact directories and carries thewait,blocked, andterminal-dependencytags; later checks appendStill blocked on …evidence. - Hold armed and released. Arming a
sase agent holdadmission hold posts oneagent_holdrow per armer with theagent-holdandarmedtags, its expiry, and any frozenpendingcapture counts. The%holddirective pre-arms the same durable hold during launch submission. Re-arming appends+1evidence. A release posts anagent-hold/releasedrow with the reason, including automatic release after the armer dies or the TTL expires. Expiry evidence names the exact TTL boundary and retains the stored arm-time capture summary. - Hold deadlocks. When runner-slot admission finds that a held agent and the agent
holding it are blocking each other, it posts a
Hold deadlockrow with thehold,deadlock, andblockedtags and both artifact directories. This happens when the armer is itself still waiting or queued on the held agent, directly or through its own%waitchain. The notification only reports the deadlock: the hold's TTL still guarantees progress, and releasing the hold or killing one side resolves it sooner.
Agent Completion Attachments¶
Agent completion notifications attach the standard chat transcript and diff first. On
failures they also include the error report and output log when those files exist. When
a successful agent added or modified 10 or fewer Markdown files, SASE renders
best-effort PDF artifacts and appends those PDFs after the standard artifacts. When the
run added or modified image files, SASE appends those generated images after any
Markdown PDFs. When the run added or modified video files, SASE appends those generated
videos after generated images. Explicit artifacts created during the run with
sase artifact create -p <path> [-l <label>] [-k <kind>] are read from the persistent
artifact index and appended last when their stored files still exist. Supported Markdown
extensions are .md and .markdown; supported image extensions are .png, .jpg,
.jpeg, .webp, and .gif; supported video extensions are .mp4, .m4v, .mov, and
.webm.
Attachment paths are discovered from local git changes, untracked files, saved
proposal/commit diffs, and the latest commit when the agent committed or opened a PR.
Missing, deleted, unsupported, and duplicate paths are ignored. If more than 10 Markdown
sources remain after filtering, SASE skips Markdown PDF rendering for that completion
and includes a note explaining the limit. The final PDF, image, and video lists are also
written to done.json as markdown_pdf_paths, image_paths, and video_paths for
agent metadata consumers. Explicit artifact paths are read from the explicit-artifact
association index at notification time, deduplicated against the standard attachments,
and ignored if the index is unavailable.
In sase's TUI, completion artifacts are opened from the Agents tab with a. The
artifact panel supports marking multiple files and opening the full artifact sequence,
so notification attachments, generated PDFs/images/videos, plan files, and explicit
artifacts use one selection workflow. Generated videos are included as ordinary file
artifacts. sase's TUI may also include image and video files referenced by saved prompt
artifacts in that picker. Those prompt-referenced media are persisted or synthesized as
sase's TUI artifact-list entries, but they are not appended to notification delivery
payloads unless they also appear in done.json.image_paths / done.json.video_paths or
were saved explicitly with sase artifact create.
When an agent sets output variables with sase var set, non-reserved variables are
snapshotted into the completion notification as sorted JSON and rendered in Telegram
agent-completion messages. The SASE-managed artifacts list from sase artifact create
is included like any other non-reserved variable. The snapshot preserves structured
values (including nested lists and maps); each value is already bounded by the 64 KiB
encoded-variable limit, and the notification store does not impose a smaller
action_data limit. The reserved repeat-control variable STOP is omitted from
Telegram completion summaries.
The Agents tab also treats user-agent completions as unread work items. When a terminal
agent is selected after it has been marked unread, or when the user jumps to it with the
unread-agent shortcut, sase's TUI clears the row's unread marker and dismisses the
matching completion notification. A host-owned settlement row (epic-launch or
monitor-settlement) that names the exact (cl_name, raw_suffix) of that row or of any
turn on its parent_timestamp chain — for example, an epic launch approved through the
EpicApproval gate names the gate's launch monitor, and reading the session row clears it
— is acknowledged with the row and dismissed alongside the completion notification. Plan
approvals and user questions remain explicit response workflows and are not auto-read
merely by selection.
Unread state on the Agents tab is projected from the active user-agent completion
notifications in the store, plus any active host-owned settlement notification — the
notification an epic launch or monitor handoff posts when an agent session finishes
(senders epic-launch or monitor-settlement) — whose (cl_name, raw_suffix) matches
the Agents-tab row or any turn on its parent_timestamp chain, not only direct
children. Unread state is not written as separate per-row state. A finished agent row
whose session settles is therefore flagged unread until that notification is dismissed;
when the underlying notification is dismissed (per-row selection, response modal, or any
other path) the row's unread marker clears on the next refresh. A host-owned settlement
notification that names the exact (cl_name, raw_suffix) of an agent row or of a turn
on its parent_timestamp chain is acknowledged with that row — read, dismissed, or
marked — exactly like the row's completion notification. Manually toggling a row unread
with U overrides this projection locally so a deliberately re-flagged row is not
immediately re-cleared. Plan approvals and user questions still require an explicit y
/ n response and are never auto-dismissed by row navigation.
A newly arrived completion notification also drives a targeted Agents-tab refresh:
sase's TUI reloads only the matching agents' artifact directories rather than rebuilding
the whole list. Host-owned settlement notifications from epic launches and monitor
handoffs (senders epic-launch and monitor-settlement) refresh the agent session they
name the same way, so a settled session updates without waiting for the next full
refresh. When the named row is acknowledged, the settlement row is dismissed with it:
the match requires both cl_name and raw_suffix to equal the row's key, since
cl_name alone is the project-wide patch name shared by every agent and would clear
unrelated settlement rows. The finished node shows its terminal status and unread marker
on the toast tick, from an exact read-only status probe. The targeted reload that
follows is authoritative.
See agent_images.md for the full attachment contract and sase's TUI
image preview notes.
For user-agent completion and failure notifications, action_data also includes
bead_display when the agent name maps to a bead created by sase bead work. The value
includes the bead ID plus the issue description or title when the bead can be resolved,
and falls back to the ID alone otherwise. Cross-project lookups prefer the agent's
owning project, then the caller's current bead view, then all known SASE projects.
Mentors-Complete Notification¶
A mentors-complete notification uses sender mentors and fires once per
(Patch, STITCHES entry) under either of two conditions:
- All mentors terminal — every mentor that was started for the entry has reached a
terminal status (
PASSED,COMMENTED,FAILED,DEAD, orKILLED). - No matching profile — every hook is ready and no mentor profile matched the Patch, so no mentors will run.
Selecting the notification jumps to the Patches sub-tab, focuses the target Patch, and pushes the Mentor Review modal when at least one mentor produced reviewable output.
Idempotency is enforced via ~/.sase/notifications/mentors_complete.json, keyed on
(project_file, changespec_name, entry_id) — so the notification survives process
restarts and project-spec archival without re-firing. The sender suppresses the
notification on the same axe cycle that just wrote the MENTORS field for the latest
entry, preventing premature firing on Draft → Ready transitions.
Report Notifications¶
Any producer — a job, a hook, or an agent — may attach a structured report to a
notification by setting action: "ViewReport". The report is a job report document
({"title": ..., "blocks": [...]}), the same artifact sase.jobs.JobReport builds and
the Services tab already renders, so the notification carries no producer-private
schema.
action_data describes where the document lives:
| Key | Meaning |
|---|---|
report_path |
Absolute (or ~-prefixed) path to a JSON report document the producer keeps up to date |
report |
A JSON-encoded report document embedded in the notification — an immutable snapshot |
report_title |
Optional display title override; otherwise the document's own title, else "Report" |
Both keys are optional and both may be present. Resolution order, and the provenance the reader sees:
report_pathresolves, loads, and validates → live, stamped with the file's mtime (live · updated 2m ago).- Otherwise
reportparses and validates → snapshot, stamped with the notification timestamp (snapshot · captured 3h ago). - Otherwise → one explicit failure line naming the reason, plus the path that was tried. Never an exception, never an empty pane.
Pointing at a stable published path lets an hours-old notification open the current picture, while the inline snapshot guarantees the pane still renders honestly if the producer's state was wiped.
The loader (sase.notifications.load_notification_report) is fail-closed and never
raises. It performs no network access and no subprocess calls, and it rejects:
- a relative or
~-unexpandable path, a missing path, or a path that is not a regular file; - a file larger than 256 KiB — a size failure, not a truncation;
- content that is not a JSON object, or that fails job-report validation through the
Rust schema authority (
sase.jobs.validate_job_report).
Every failure becomes a bounded, human-readable error string such as
report file not found, report file is too large (312 KiB), or
report document is invalid: unknown variant `bogus`. Rendering is safe by
construction: render_chop_report builds Rich text with explicit styles and never
interprets console markup or ANSI from the document.
In sase's TUI, selecting a ViewReport notification renders the report in the modal's
right pane under its provenance line, with a dim attachments: footer when the
notification also carries files. Pressing Enter re-reads the document and opens the
full-screen report modal, so the modal always shows the freshest published report rather
than the pane's cached load. That modal binds Ctrl+D/Ctrl+U for half-page scrolling,
j/k for lines, g/G for top/bottom, y to copy the report path, e to open the
file in $EDITOR, and Esc/q to close. y and e warn instead for an inline
snapshot, which has no file path. ViewReport is an ordinary informational action:
selecting it marks the notification read.
Action-less Notifications¶
A notification with no action is a valid, common shape — an informational row with
nothing to open. Selecting one in sase's TUI marks it read and does nothing else; it is
a silent no-op, not a producer error. Only a non-empty action string this build does
not recognize produces an "Unsupported notification action" warning.
Notification Fields¶
Each notification contains:
| Field | Type | Description |
|---|---|---|
id |
string | UUID4 unique identifier |
timestamp |
string | ISO-8601 creation timestamp; immutable, and never rewritten by a snooze or resurface |
sender |
string | Source identifier (e.g., "plan", "sync", "axe") |
icon |
string|null | Optional single emoji or display glyph |
color |
string|null | Optional sender-declared #RRGGBB accent for the notification-panel tab |
notes |
list[string] | Human-readable message lines |
files |
list[string] | Associated file paths (e.g., plan files, error digest files, generated agent images) |
tags |
list[string] | Optional normalized labels for filtering and modal tabs |
action |
string|null | Action type: HITL, PlanApproval, EpicApproval, TaskTriage, BeadStaleCleanup, UserQuestion, LaunchApproval, ViewReport, etc. null means the notification is purely informational |
action_data |
dict | String identifiers and owned paths for the typed action; rich gate definitions stay in request.json |
read |
bool | Whether the notification has been read |
dismissed |
bool | Whether the notification has been dismissed |
silent |
bool | Silent notifications are stored but hidden from the TUI |
muted |
bool | Muted notifications appear under Muted (or Snoozed, with a wake time set) and are excluded from the arrival bell and toasts; the indicator counts them separately (see Top-Bar Indicator) |
snooze_until |
string|null | Canonical UTC RFC-3339 instant at which a snoozed notification automatically un-mutes; null once expired or cancelled |
resurfaced_at |
string|null | UTC instant stamped when a snooze expired; drives activity ordering and delivery cursors. null for rows that never resurfaced |
dedup_key |
string|null | Optional sender-scoped key used to append evidence instead of creating another row |
plus_ones |
list[object] | Retained corroboration entries, each containing timestamp, sender, and note |
plus_ones_dropped |
int | Number of older corroboration entries omitted by bounded retention; included in the displayed and JSON plus_one_count |
Silent Notifications¶
Notifications from hidden background agents (summarize-hook, fix-hook, mentor) are
created with silent=True. Silent notifications are written to the JSONL file
(preserving the audit trail) but excluded from the TUI unread count, top-bar indicator,
arrival bell, toast, notification modal, and Telegram delivery. They remain visible to
local inspection commands such as sase notify list.
Agent completion and failure events from hidden background agents still write a notification row, but with the silent flag set. This keeps the JSONL audit trail complete while keeping the inbox focused on user-facing agent work.
Plan decision %auto receipts¶
When a plan or epic gate with at least one decision auto-resolves (source
auto_resolution), sase posts one quiet receipt notification:
actionis null, so it is not a gate and opens nothing;silentis true andmutedis left false;- tag
plan_decisions_receipt; - dedup key
plan-decisions-receipt-<request id>; - plans with no decisions post nothing.
Visibility is an intentional exception to the Silent Notifications rules: visible in the
ACE inbox direct page and delivered quietly by Telegram, with no unread bump, toast, or
bell, while remaining visible to sase notify list.
Tags¶
Senders may attach tags to a notification. Tags are normalized when notifications are
created: whitespace is trimmed, empty values are dropped, values are lowercased, and
duplicates are removed while preserving sender order. Tags do not change priority, error
classification, unread counts, mute, snooze, or auto-dismiss matching.
Successful visible and hidden user-agent completion notifications that jump back to the
agent row carry the done tag. Failed user-agent notifications do not carry done;
failures remain error reports.
In sase's TUI, tags create modal tabs above the notification list after the synthetic
Gates, declared panel tabs, Errors, General, and Done tabs. Every notification
belongs to exactly one tab. The Rust core decides which one, by this precedence, so
the panel, the top-bar indicator, and the mobile snapshot always agree:
Snoozed— muted with asnooze_untilwake timeMuted— muted with no wake time- the gate's declared
presentation.panel Gates— aPlanApproval,EpicApproval,UserQuestion,HITL,LaunchApproval,TaskTriage, orCustomGateaction (the core still keys this synthetic tabhitl; only the display label isGates)Errors— an error report- the first stored tag, in sender order
General— everything else
A row with two tags therefore occupies one tab and is counted once; dismissing it removes at most one tab. Later tags still render as badges on the row, but they do not create tabs.
A gate may declare presentation.panel to place its notification in a named panel tab.
Panel names are stripped, lowercased, limited to 32 characters, and may contain
lowercase letters, digits, underscores, and hyphens. The synthetic names errors,
gates, general, hitl, muted, and snoozed, along with names beginning with
__, are reserved. A panel name matching a tag merges into that tag's tab, which then
sorts as a panel tab.
Tab colors¶
Every tab renders with a color, so a brand-new tag tab is never colorless. The color resolves by precedence, highest first:
ace.notification_tabs.<tab>.color, when non-empty- the color a sender declared on a notification in that tab
- the built-in default for a tab sase's TUI ships knowing about (
hitl,errors,beads,general,snoozed,muted) - a stable auto-palette entry derived from the tab key, so the same tag keeps the same color across restarts
A sender declares a color with presentation.color on a gate, or the color field of
sase notify create JSON input. It must be a #RRGGBB hex string; anything else is
rejected at write time rather than stored as junk that would render as an unstyled chip.
When several rows in a tab declare a color, the tab wears the one from the row the panel
lists first — the most recent activity — so the color is deterministic rather than
dependent on render order.
The done tab is intended as the quick path for successful agent completions; reading
or jumping to a done Agents-tab row dismisses its matching completion notification, so
it disappears from the Done tab after the next refresh. Failed agent notifications
stay untagged by done and continue to render under the Errors tab.
Tab icons¶
Every tab renders with an icon, so the top-bar indicator's chips and the modal's tab strip are self-identifying instead of relying on color alone. The icon resolves by precedence, highest first:
ace.notification_tabs.<tab>.icon, when non-empty- the icon a sender declared on a notification in that tab
- the built-in default for a tab sase's TUI ships knowing about (
hitl,errors,beads,general,snoozed,muted) - a default keyed by the tab's own kind (
panel,tag), so a tab sase's TUI has never heard of still gets a glyph that means something about what it is •, reachable only when a tab arrives with no kind at all
Unlike color, an icon never falls back to a hashed auto-palette entry: an arbitrary
color is still a usable identifier, but an arbitrary glyph would teach the reader
something false, so the chain always bottoms out at a meaningful or honestly generic
mark instead. The bundled defaults are ⚑ hitl, ✖ errors, ◈ beads, ✉
general, ☾ snoozed, and ⊘ muted; a gate-declared panel with no closer match
falls to the kind default ◆, and a tag tab falls to #.
sase's TUI resolves icons over the whole ordered tab list before rendering. Configured
icons, sender-declared icons, and the bundled defaults are never rewritten. If two
SASE-chosen generic icons from rung 4 or 5 would collide, sase's TUI walks the tab key
and uses the first unused ASCII letter or digit from that key: axe can become a,
then x, then e; file-hooks can become f; 123-deploy can become 1. If every
alphanumeric character in the key is already claimed, the tab keeps the generic mark
rather than inventing a false glyph. Explicit duplicates remain explicit: if two
configured tabs or two gates choose the same glyph, sase's TUI renders that glyph for
both. Run sase doctor -C config.notification_tabs to report configured duplicates.
A sender declares an icon with presentation.panel_icon on a gate (see
Command-backed interaction gates below); there is
no raw-notification equivalent, since a raw row's own icon field styles the row, not
its tab. When several rows in a tab declare a panel_icon, the tab wears the one from
the row the panel lists first — the most recent activity — exactly as color does. A
panel_icon is donated only to the tab named by the same gate's presentation.panel,
so a muted or snoozed row does not carry its panel glyph into Snoozed or Muted.
CLI¶
The sase notify command can create notifications and inspect the local notification
inbox.
Bare sase notify is a read-only shortcut for sase notify list. Use
sase notify create when writing a notification from JSON input:
echo '{"sender": "test", "icon": "👋", "notes": ["Hello"], "tags": ["review"]}' | sase notify create
echo '{"sender": "audit", "notes": ["Background result"], "silent": true}' | sase notify create
sase notify create -s my_sender < notification.json
sase notify create -s my_sender --tag review --tag handoff < notification.json
sase notify +1 6f8a2 "same failure reproduced on macOS" -s ci_watch
sase notify +1 -k ci/sase-main "failure reproduced again" -s ci_watch
sase notify rules shows the configured delivery rules in the order
they are consulted, and sase notify rules -e ID explains which rule decides the toast
and the sound for one stored notification. It never changes notification state.
Raw creation validates and preserves the optional single-glyph JSON icon, the optional
#RRGGBB JSON color (see Tab colors), and the JSON silent field. It
rejects registered privileged actions (PlanApproval, EpicApproval, TaskTriage,
BeadSnooze, FlagTriage, BeadStaleCleanup, PluginsRequired, UserQuestion,
LaunchApproval, SudoRequest, CustomGate, and HITL) because a raw row has no
trusted command bundle.
sase notify +1 [ID] NOTE accepts an exact notification ID or unique prefix. With
-k/--dedup-key, it instead selects the newest row matching the resolved sender and
key; pass -s/--sender explicitly when producer identity matters. Otherwise the sender
is the current SASE agent identity, falling back to the OS user. An ID miss exits
non-zero; a dedup-key miss emits {"action":"no_match"} and exits zero so producers can
decide to create a fresh row.
Producers can make that decision atomically with sase notify create -k KEY -p NOTE.
When no (sender, key) row exists, SASE creates the input notification with that key;
when one exists, it appends NOTE and does not create a duplicate.
-S/--supersedes OLD_KEY applies only on the create branch: it retires matching old-key
rows by appending a superseding +1 and dismissing them. It is ignored when the current
key already matches a row. The same three values can be supplied as JSON dedup_key,
plus_one_note, and supersedes fields.
The first-class gate API reads a versioned gate specification from stdin:
sase gate create < gate-request.json
For example, this custom gate offers a restart-and-verify group plus a separate rejection branch:
{
"schema_version": 3,
"kind": "custom",
"request_id": "restart-api",
"producer": { "agent": "maintenance" },
"continuation_mode": "resume_agent",
"gate_timeout_seconds": 900,
"presentation": {
"sender": "maintenance",
"icon": "🛡️",
"notes": ["Restart the API after reviewing the health report?"],
"panel": "deployments",
"panel_icon": "🚀",
"origin_agent": "maintenance.agent",
"chip": { "glyph": "🚀", "label": "deploy", "color": "#5FD75F" },
"preview": "preview.md"
},
"query": "(restart AND verify) OR reject",
"primary_branch": ["restart", "verify"],
"options": [
{
"id": "restart",
"label": "Restart service",
"icon": "🚀",
"default_selected": true,
"feedback": "required",
"command": { "argv": ["commands/restart"] }
},
{
"id": "verify",
"label": "Verify service health",
"icon": "🩺",
"default_selected": true,
"feedback": "disabled",
"command": { "argv": ["commands/verify"] }
},
{
"id": "reject",
"label": "Do not restart",
"icon": "❌",
"feedback": "optional",
"command": { "argv": ["commands/reject"] }
}
],
"groups": [
{
"options": ["restart", "verify"],
"label": "Restart service",
"icon": "🚀"
}
],
"resources": [
{
"path": "commands/restart",
"role": "command",
"content": "#!/bin/sh\nprintf '{\"status\":\"restarted\"}\\n'\n"
},
{
"path": "commands/verify",
"role": "command",
"content": "#!/bin/sh\nprintf '{\"status\":\"healthy\"}\\n'\n"
},
{
"path": "commands/reject",
"role": "command",
"content": "#!/bin/sh\nprintf '{\"status\":\"rejected\"}\\n'\n"
},
{
"path": "preview.md",
"role": "preview",
"content": "# API health report\n\nAll checks passed.\n"
}
],
"auto": false
}
presentation.panel selects the named notification panel tab described in
Tags, and requires presentation.panel_icon alongside it — a gate that
names a tab is the thing introducing that tab to the user, so it is the thing
responsible for saying what the tab looks like. Omitting panel_icon while declaring
panel fails gate creation with a missing_presentation error.
presentation.origin_agent attributes the gate to the agent it was filed on behalf of;
it is stripped, limited to 128 characters, and stored without consulting the local agent
registry so remote agent names remain valid. All three fields are projected into
notification action_data as panel, panel_icon, and origin_agent; producers may
not write those protected keys directly through presentation.action_data.
presentation.chip is an optional subject chip: an object with glyph (required, one
grapheme), label (required, stripped, non-empty, single-line, free of control
characters, and at most 32 characters), and color (optional #RRGGBB). A declared
chip is projected into notification action_data as gate_chip_glyph,
gate_chip_label, and gate_chip_color; a colourless chip writes only the first two
keys. Those keys are protected: producers may not write them through
presentation.action_data. Every render surface — sase's TUI toast, the notification
row, the gate detail pane, the review modal, and the mobile bridge row — reads the
stored keys only and never resolves a task type. A stored colour that is not #RRGGBB
is ignored so the glyph and label still render; a missing glyph or label drops the chip.
Every gate whose subject is a typed task bead declares this chip from the presentation
frozen at gate creation (the type glyph, the slug as the label, and the type accent) and
adds the compact typed facts line as notes[1]. An untyped bead declares no chip.
presentation.title is the one-line decision headline shown in the notification panel's
gate detail pane and in the custom gate review modal's header. It
is stripped, limited to 120 characters, must be a single line, and must not contain
control characters; a missing title falls back to the gate kind's display title (for
example Custom Gate). It is required for kind: "custom" — along with a non-empty
presentation.icon and at least one non-blank presentation.notes entry — because a
custom gate has no other source for the headline, icon, and context the panel renders.
plan, epic_plan, question, launch, and hitl gates keep their existing
contracts and do not require a title. presentation.title is projected into
notification action_data as gate_title; producers may not write that protected key
directly through presentation.action_data.
presentation.color suggests the #RRGGBB accent for the tab the gate's notification
lands in, as described in Tab colors; a malformed value fails gate
creation with an invalid_color error.
presentation.panel_icon suggests one emoji or display glyph for the tab the gate's
notification lands in, as described in Tab icons; a malformed value fails
gate creation with an invalid_presentation error. It is a separate field from
presentation.icon rather than a reuse of it, because presentation.icon is the
row's icon and rows sharing one panel legitimately differ — donating the row icon to
the tab would make the tab's glyph flip depending on which row arrived most recently.
panel_icon is a property of the tab, and gates sharing a panel are expected to agree
on it.
presentation.icon, option.icon, and group.icon each accept one emoji or display
glyph. Each OR branch is a mutually exclusive resolution path. A singleton branch
renders as one button; an AND branch renders selectable option toggles plus a submit
button. The selected ids must be a non-empty subset of exactly one branch.
default_selected defaults to true, and a matching groups entry configures an AND
branch's submit label and icon. feedback is disabled, optional, or required;
custom options default to optional, and a group selection uses the strongest mode
among its selected members. Automatic resolution is forbidden for custom gates.
primary_branch must name one complete branch in canonical query order. sase's TUI
submits it with Enter while Space toggles the focused AND member; submitting a primary
group preserves the reviewer's current toggles. sase's TUI also numbers top-level
branches in canonical order: the fixed keys 1–9 submit their matching branches
directly. AND-member toggles remain unnumbered.
Every option references a bundle-owned command resource and is executed in query order
as an argv array without a shell after its hash is reverified. A selected-command
failure is recorded in the bundle error log and leaves the gate answerable. The
write-once response records selected_option_ids, option_results, and normalized
top-level feedback consistently for every transport.
On success, creation prints a stable JSON descriptor containing schema_version,
notification_id, request_id, kind, bundle/request/response/preview paths,
continuation_mode, auto_resolution, and hashes. The descriptor's request_id and
kind are the exact values accepted by sase gate wait. Typed front doors such as
sase plan propose, sase questions, and agent-initiated launch requests call the same
in-process gate service directly; they do not spawn this CLI. Agents should normally use
the generated /sase_gate skill to author this JSON.
Wait mechanically for a gate without reading or polling bundle files directly:
sase gate wait --id <request_id> --kind <kind>
sase gate wait --id <request_id> --kind <kind> --json
sase gate wait --id <request_id> --kind <kind> --timeout 60
Human output is colored and summarizes the selected options, feedback, response path,
and any failed execution recovery commands. -j/--json emits the stable shape status,
selected_option_ids, feedback, and response_path, plus input, option_inputs,
and option_results off the write-once response (populated only once the gate is
answered), operations from the execution journal, and for failed execution failure
plus failure_recovery (error_report_path and exact resume, restart, or cancel
commands when they are safe to run). Status is answered, cancelled, timeout, or
failed, with exit codes 0, 3, 4, and 5 respectively. A CLI timeout can shorten but
never extend the request's own gate timeout.
sase gate answer, sase gate act, and sase gate show are the headless counterparts
to sase's TUI modals: answer selects a branch and supplies each selected option's
declared input (--set field=value typed by its declaration,
--option-input <opt>=@file.json for a whole per-option value, or --input @file.json
for the legacy shared value) and resumes or restarts a partially executed AND branch
with --resume / --restart. On an already-answered turn-backed tale whose selected
branch requested a coder, --resume retries only that unfinished handoff: it reuses the
stored answer, skips option commands and plan archival, and launches nothing when the
successor is already recorded. Conflicting --option / input values are refused.
--restart still means "run the whole option branch again" and does not recover a coder
handoff. act runs one declared action headlessly, including opening $EDITOR for an
edit_file action, without answering the gate; show prints a gate's declared
branches, each option's input fields, and its declared actions, so an author can check
that the gate they wrote asks for what they intended. When a decision has been accepted
but its execution has not written response.json yet, show adds an Acceptance
block (an acceptance object with --json) with the accepted options, the submitting
surface, and the latest recorded execution error, or flags an invalid decision receipt.
See --help on each for the full flag reference.
For read-only inspection, list recent notifications as either a compact table or stable JSON:
sase notify
sase notify list
sase notify list -j -l 20
sase notify list -j --sender axe
sase notify list -j --unread
sase notify list -j --tag done
sase notify list -j --tag memory
sase notify list -j -q digest
sase notify list -j --all
Use the explicit list subcommand when passing list flags; for example, use
sase notify list -j, not sase notify -j.
sase notify list -j prints notifications newest first with id, timestamp, age,
sender, icon, priority, notes, files, tags, action, action_data, read,
dismissed, silent, muted, snooze_until, and resurfaced_at. The -q/--query
filter matches tags as well as ids, senders, notes, files, actions, and action data.
Dismissed notifications are hidden unless --all is provided.
list and show are current-state reads: they atomically expire any due snooze before
projecting, and they order and limit by the activity key, so a resurfaced old
notification appears on the first -l 1 page while still reporting its original
timestamp and age.
Inspect one notification by id:
sase notify show --id <notification_id>
sase notify show --id <notification_id> -f json
sase notify show --id <notification_id> -f markdown
The default show format is markdown. It includes the notification tags, notes,
attached file paths, action data, and state flags. Axe error digest notifications
usually point to the actionable report through files or
action_data.error_report_path; read that attached file for the detailed errors. Gate
execution failures use action GateExecutionFailed, deterministic ids scoped to the
gate and accepted receipt, tags gate, execution, and error, and action data with
error_report_path plus the exact safe resume, restart, and cancel commands.
To create a local test notification with a persistent PNG attachment for sase's TUI
modal image-preview checks, run tools/test_image_notification from the repository
root.
See docs/configuration.md for the full CLI reference.
Command-backed interaction gates¶
Each new gate is written once under ~/.sase/interaction_requests/<kind>/<request-id>/.
The bundle contains canonical request.json, eventual write-once response.json,
reviewed previews or attachments, and adapter-owned commands. The request records the
continuation mode, optional gate timeout, typed payload, presentation metadata and icon,
option-query branches, the declared primary branch, options with configurable icons and
feedback modes, AND-group submit metadata, input/result schemas, and hashes for the
request and owned resources. Commands are argv arrays executed without a shell. A gate
may also declare repeatable, non-terminal actions the reviewer can run any number of
times without answering the gate; see Gate actions below.
Manual creation succeeds only after the bundle, notification row, and pending-action registration are durable. A partial failure is compensated, and retries are idempotent by request ID. Manual and automatic selections use the same hash, input, result, and write-once response validation. The pending-action 24-hour stale threshold is transport-only; it may hide remote controls but does not terminate a waiting producer. Only cancellation or an explicit per-request gate timeout is terminal. Every terminal response or cancellation marks the pending action handled and dismisses the notification row, regardless of gate kind or client surface. When sase's TUI opens the notification modal, it also repairs live gate rows whose bundles became terminal without a corresponding dismissal.
sase's TUI, Telegram, and mobile derive gate-kind capabilities from the shared adapter
registry and render branches in query order from the same normalized envelope structure.
Registering a new branch-actionable kind therefore makes it actionable on every surface
without adding per-surface action or kind allowlists. Singleton branches are buttons.
AND branches expose one toggle per option and a configurable submit control; the primary
AND branch starts expanded. Top-level branches have fixed one-based digit selectors in
canonical query order, while AND members remain unnumbered and use Space to toggle.
sase's TUI Decision column shows those branches and toggles only — typed fields live in
a dedicated input panel, not in the left pane. Enter submits the declared primary
branch, Ctrl+S submits the active branch, and q or Escape cancels the modal. A
selection that needs typed input — any declared inputs field, a raw input_schema
with a property the host does not already collect, or feedback: required — opens the
panel first; confirming the panel submits the branch, and cancelling returns to the gate
with the selection and whatever was typed intact. An option that declares only
feedback: optional, or nothing at all, still answers on Enter or its digit. Press i
on the focused option first to open the same panel for an optional note. For an AND
group, the panel opens when the group's submit control is activated, not when a member
is toggled, and then shows one section per selected option that declares input. Surfaces
submit selected_option_ids, feedback, and each selected option's declared input (see
Gate inputs below), and the shared executor runs the selected commands
in query order.
| Key | Action |
|---|---|
j / k |
Focus the next / previous branch control |
| Space | Toggle the focused AND member |
| Enter | Submit the declared primary branch; opens the input panel first when the selection needs input |
| Ctrl+S | Submit the active branch; same panel rule as Enter. Inside the panel, submit from anywhere |
i |
Open the input panel for the focused option's note or declared fields |
1–9 |
Submit the matching top-level branch |
| Tab / Shift+Tab | In the input panel, walk the next / previous field (wraps; includes the buttons) |
q / Escape |
Cancel the review modal. In the panel, Escape leaves INSERT, then closes without submitting |
Tale plan approval uses (approve AND commit) OR reject OR feedback. The approve and
commit options start selected, the group submit is labeled Tale, and the two
singleton branches remain Reject and Send Feedback. Epic plans use
approve OR reject OR feedback.
Plan Decisions¶
A tale or epic plan may declare typed Plan Decisions in decisions: frontmatter. Start
with the authoring example and CLI review flow for user-facing
usage; this section describes the gate protocol. decision_<id> fields are treated as
already collected, so they do not appear as extra inputs. Override a default with
sase gate answer and --set decision_<id>=....
Gate build freezes them into payload.decisions and compiles decision_<id> raw
properties onto tale approve, commit, and feedback (epic approve and
feedback). Toggles compile to {"type": "boolean"}; choices compile to
{"enum": [keys]} in author order. They are never required, and additionalProperties
stays false. Approve and commit result schemas gain a required decisions object with
the same fully-resolved value types.
Normalization runs once at the top of execute_gate_selection, before
accept_gate_decision, and its output feeds both the receipt and execution paths.
Omitted ids take their effective default, so an omitted value and an explicit default
share one input_identity. Disagreeing decision_* values across selected options fail
as decision_conflict before any receipt or command. An agent memory value of true
fails as memory_decision_requires_human unless its effective default is already true.
Submissions carry the displayed review_revision. A mismatch fails as stale_review
before any side effect; an absent revision stays unchecked so mobile and older clients
keep working. In-gate edits may change prose but never anything under decisions:; the
freeze compares the digest of a rebuild against payload.decisions and refuses with the
Decisions-panel message.
Approval writes the resolved answers and reviewer provenance into the durable plan. Feedback carries changed values to the next planner as provisional choices, including an explicit reminder that provisional memory choices are not authorization.
The typed projections remain deliberately distinct. Their default feedback, generic-form rendering, and branch-action capabilities are declared by the same adapter entries that map kinds to notification actions:
| Gate kind | Notification action | Recommended producer |
|---|---|---|
plan |
PlanApproval |
sase plan propose with an authored tier: tale |
epic_plan |
EpicApproval |
sase plan propose with an authored tier: epic |
task_triage |
TaskTriage |
AXE's built-in bead_task_triage job |
bead_snooze |
BeadSnooze |
AXE's built-in bead_task_triage job |
flag_triage |
FlagTriage |
AXE's built-in bead_task_triage job |
bead_stale_cleanup |
BeadStaleCleanup |
AXE's built-in bead_stale_cleanup job |
plugins_required |
PluginsRequired |
AXE's built-in plugins_required job |
question |
UserQuestion |
sase questions |
launch |
LaunchApproval |
Agent-initiated sase launch request |
hitl |
HITL |
A workflow HITL step |
sudo |
SudoRequest |
Agent-initiated sase sudo request (beta) |
custom |
CustomGate |
sase gate create |
SudoRequest gates use approve OR deny. Approval requires a terminal and is accepted
only from sase sudo answer, while denial works from any surface; see
Sudo Requests.
TaskTriage uses launch OR close OR snooze, with Launch as the primary branch. Launch
accepts optional feedback and submits or reuses one globally visible unattributed proc
whose command is sase bead work <bead-id> --yes-to-all; the gate response records that
proc ID. Close requires feedback, closes the task bead with resolution=canceled, and
uses the feedback as its close reason. Snooze requires a wake-time expression and moves
the task to snoozed, after which reconciliation replaces this gate with a BeadSnooze
gate. The gate preview is generated from the bead's title, description, and notes, with
the notes section present only when the bead has notes. Automatic resolution is
forbidden, and all client surfaces use the same host-side side effects.
Inside a running SASE agent, workflow HITL now uses the same gate-turn handoff as
questions and plans. Historical HITL bundles remain readable and use their compatibility
response-file path; every neutral bundle is resolved through the same hash-verified
executor in sase's TUI and Telegram.
Fast decision acceptance¶
Gate answers are split into fast decision acceptance and slower command execution.
Before a selected option runs its commands, the shared executor writes a durable,
write-once decision_receipt.json under an acceptance lock. The receipt binds the
request hash, selected option IDs, typed input identities, feedback identity, submitting
surface, acceptance time, and execution owner. An identical duplicate selection replays
the accepted decision; a conflicting selection fails before any command runs.
Cancellation is refused while the execution owner is alive. A conflicting answer while
the owner is live fails promptly with gate_decision_conflict; it never replaces the
receipt of a running attempt.
decision_receipt.json is the local signal for immediate notification dismissal and
targeted sase's TUI refresh. response.json remains the terminal execution record
written only after the command set and archive work have completed.
Approved versus committed status¶
The decision status and the execution status are separate:
TALE APPROVEDandEPIC APPROVED(and the reject and feedback labels) derive from the acceptance receipt on every load path. They mean the human decision is durable, not that anything has finished, and they never free a worker's resources.PLAN COMMITTEDand other execution-dependent labels appear only after the archive succeeds. If the archive fails, the label is rolled back.- A durably recorded failure outcome shows as a distinct failed status that the approved label cannot hide.
response.json, the turn's terminal state, and the refresh pulse are published before post-terminal epic launch preparation, so sase's TUI does not wait on the follow-up launch.
Failure outcomes and recovery¶
Every failure after acceptance (an option command, terminal preparation or archive, a
side effect after response.json, or a GateError raised after acceptance) writes one
redacted attempt_failed journal event. It carries the attempt id, the failed stage
(command, terminal_prepare, side_effects, or follow_up), an error code and
message, and a timestamp, never raw input values, and it references the existing
errors/*.json record. attempt_completed is journaled only after terminal preparation
succeeds.
A failure publishes one deduped GateExecutionFailed notification per gate and attempt.
Failures in the command and terminal_prepare stages offer resume, restart, and
cancel; later stages offer resume only. A later successful attempt dismisses it.
poll_gate and waiting requesters receive the failure result instead of a pending or
already_answered state. The recovery actions work in sase's TUI even after the
original review notification was dismissed, and partial_attempt plan gates reuse the
gate retry modal.
resumeskips completed option commands and retries only archive and terminal preparation.restartre-runs the selected commands.cancelsettles the gate without a follow-up.
In sase's TUI, pressing Enter on a GateExecutionFailed row in the notification panel
opens a Gate execution failed: <stage> dialog listing the completed and failed
options:
| Key | Action |
|---|---|
r |
Resume after the failed step |
R |
Run the whole branch again (command and terminal_prepare stages only) |
c |
Cancel the gate (command and terminal_prepare stages only) |
e |
Open the error report in $EDITOR |
Esc / q |
Close without acting |
r and R need the selected option IDs from the failure bundle; when those cannot be
recovered, R is hidden. When no recovery action applies (no bundle, or a post-response
stage without recoverable options), Enter opens the error report directly.
A recorded failed outcome, or an execution owner that is provably dead, makes the receipt supersedable, so a different answer or a cancel is accepted.
Old in-flight bundles¶
Bundles accepted before receipts existed have no decision_receipt.json. They keep
working: labels come from agent_meta.json and response.json as before, and they
finish through the historical response.json path.
An accepted decision whose execution has not finished is protected from cleanup.
sase gate cancel leaves that turn as it is, the gate_turn_reclaim housekeeping job
never settles it as lost or timed out, and sase gate show reports it in an
Acceptance block. A receipt whose attempt failed partway, or whose owner died, is
superseded by the next submission instead of blocking it. sase's TUI submits gate
decisions, including plan and epic approvals, as a tracked sase gate answer proc, so
an accepted decision keeps running after the modal closes.
Rollout keeps existing bundles readable. Upgrade sase-core first so every surface has
the indexed turn lookup and acceptance policy. Then upgrade the SASE Python package,
sase's TUI, and mobile clients against that binding, and finally upgrade Telegram so its
receiver submits answers through the same supervised proc path. In-flight legacy gates
still fall back to their historical response.json path. Telegram receiver adoption
requires no config edit beyond enablement: the plugin declares a telegram_receiver
service proc that ships disabled, so enable it in the owning machine's overlay and the
sase service keeps it running. --once remains the diagnostic direct poll path, and
disabled or credential-less receivers self-terminate. Control it with
sase service proc stop|restart|disable telegram_receiver. A TUI update restarts the
service host and therefore the receiver; a CLI sase update restarts only the
scheduler, so a receiver upgrade then needs
sase service proc restart telegram_receiver. Until sase-11w lands, a package upgrade
does not retire the running receiver on its own, so restart it by hand so one running
current code starts.
Latency evidence from isolated probes on 2026-09-14:
- Exact turn lookup over 5,002 fixture artifacts had p50 1.317 ms, p95 3.755 ms, and max 13.008 ms across 25 lookups. Index rebuild took 3.554 s off the click path. The target resolved to the owning gate turn rather than a later inherited-code successor.
- Ten blocked-command acceptance runs wrote
decision_receipt.jsonat p50 18.95 ms, p95 21.6 ms, and max 22.53 ms. Notification dismissal followed at p50 35.37 ms, p95 44.47 ms, and max 46.35 ms, whileresponse.jsonstayed unwritten until the held command was released.
Matched before/after evidence for the two refresh paths, from isolated fixtures on 2026-09-20:
- Acceptance receipt to Agents row data, over a 1,500-artifact synthetic archive: the acceptance pulse now lands inside the answered agent's own directory, so it names an exact row instead of falling through to a project-level pulse. The broad tier-1 load that route used to force cost p50 74.8 ms, p95 123.4 ms, and max 185.6 ms; the exact artifact-dir delta that replaces it costs p50 1.7 ms, p95 2.5 ms, and max 2.5 ms across 25 runs — about 44x cheaper at the median.
- Response publication to refresh-pulse visibility, across ten settlements holding a 150 ms follow-up-launch barrier: the pulse now fires at p50 97.7 ms, p95 130.3 ms, where the old ordering would not have published it until p50 407.5 ms, p95 450.8 ms — roughly 310 ms earlier at the median, and unbounded by however long the follow-up launch actually takes.
These measurements exercise local durability and notification behavior only. Live Telegram callback acknowledgement, message delivery, and keyboard edits remain bounded by Bot API round-trip time and rate limits; terminal keyboard cleanup is retried from the on-disk tombstone when a remote edit fails.
Gate turns and continuation¶
A gate turn is a named, non-LLM member of an agent session, normally --gate or
--gate-N. It makes a user decision durable without keeping the asking provider process
or a runner slot alive. The turn can retain the session's workspace claim while pending,
records approved command output in gate.log, and settles only after the selected
commands finish. Its lifecycle is pending, settling, answered, completed, failed,
timeout, stopped, or lost. Answered commands start right away even when every runner
slot is busy. A free slot is claimed so a follow-up agent can inherit it, but a full
queue never delays the decision itself.
The hourly gate_turn_reclaim housekeeping job settles pending turns whose gates were
already answered, cancelled, or removed. It also times out gates past their own
gate_timeout_seconds deadline, and it marks a turn lost once
gate.turn.reclaim_grace_seconds (one hour by default) has
passed after that deadline. Reclaim skips a turn whose gate creation is still running
(its session's gate-creation lock is held), so an %auto gate that is still executing
its selected commands is never marked lost; a turn whose creator died before recording a
bundle is still settled lost on a later pass.
Agent-side gate creation also writes a per-process intent marker before slow setup work
starts. A clean creation error or the normal runner handoff clears it. If the provider
turn ends while a marker remains, the host waits up to 60 seconds for a creator that is
still running to hand off. Otherwise it writes gate_intent_lost.json evidence to the
agent's artifacts and fails the run with a gate intent lost: error. The error names
the gate kind and request ID and points at sase gate list --all
(sase sudo list --all for a sudo gate). That failed run is never retried.
The built-in front doors choose statuses and continuation policy for their domain:
/sase_questionscreatesQUESTION/ANSWERED; an answer launches the next session member with the accumulated Q&A./sase_plancreates aTALE,EPIC, or legacyPLANturn. Feedback launches a replanner, while approval follows the selected tale/epic/commit policy.- Agent-side workflow HITL creates
HITL; accept, edit, feedback, and rerun branches may launch a continuation, while rejection or an unconfigured terminal branch stops. - Agent-side
sase launch requestcreatesLAUNCHand defaults to resuming the requester after approve, reject, timeout, or failure. The successor receives the gate outcome, feedback, typed dispatch result, requester identity, session/workspace context, and checkpoint. A stopped gate remains terminal; an explicitterminal_handoffmode suppresses requester continuation on every branch. - Agent-side
sase sudo requestcreatesSUDO. Approval settles it asSUDOEDand launches the request'snext.promptsuccessor with the runner ledger; denial settles it asDENIEDwithout a follow-up. See Sudo Requests.
For a custom handoff, pass --turn to sase gate create. --next supplies the default
answered-branch prompt; --next-fork session|turn|none, --next-model, and repeatable
--next-output none|results|tail|file control its context, model, and output channels.
While legacy_sase_shell_syntax is on (the default), the retired shell spellings still
work: --shell, --shell-status, --shell-stop-status, and the --next-fork shell
value on sase gate create; a gate spec's "shell" block, the "fork" key set to
"shell", and "continuation_mode": "gate_shell"; and --shell on sase proc list or
sase proc run (where --name replaces it). Use the turn spellings for gates and
--name for proc commands. Turning the flag off rejects new uses of the retired
spellings. Stored records still read. Supplying both names for the same option or block
is an error. See feature_flags.
Branch policy in the specification may override or suppress that default. A turn block
without an explicit continuation_mode records the derived gate_turn mode; pairing a
turn block with an explicit "none" is rejected, because "none" would discard the
turn and leave the gate without a row in sase gate list. Selected option IDs joined
with + in query order form the answered branch key. Timeout, stopped, failed, and lost
branches never inherit the answered default: they launch only when explicitly
configured.
Answering a turn-backed gate runs its commands in a supervised detached proc by default,
so they survive the client that submitted the decision; sase gate answer --no-detach
opts into inline execution. Use sase gate list for pending turns,
sase gate list --all for their history, sase gate show <turn> for the resolved
branches and follow-up disposition, and sase gate cancel <turn> to settle a pending
turn without a follow-up. Approval history keeps its decision label (TALE APPROVED and
similar); that label does not imply the coder is running. A failed or interrupted
handoff is shown as needing attention with the
sase gate answer --kind <kind> --id <id> --option ... --resume recovery command. An
agent that creates a gate turn must end its turn rather than call sase gate wait;
direct waiting remains available to non-agent scripts.
Gate inputs¶
An option's command reads its input as stdin JSON, never as command arguments —
templating reviewer-supplied values into argv would break the hashed-command trust
model, so there is no "extra args" escape hatch. An option declares what it needs under
inputs, a closed, declarative vocabulary that compiles into the option's
input_schema at creation time. input_schema stays the single enforcement layer: the
executor validates the submitted value against the compiled schema, so a reader that
knows nothing about inputs still enforces correctly.
{
"id": "restart",
"label": "Restart service",
"command": { "argv": ["commands/restart"] },
"feedback": "optional",
"inputs": [
{
"id": "target_env",
"label": "Environment",
"type": "enum",
"required": true,
"choices": ["staging", "production"]
},
{ "id": "delay_seconds", "label": "Delay (seconds)", "type": "int", "default": 0 },
{ "id": "api_token", "label": "API token", "type": "line", "secret": true }
]
}
Each field has an id (the JSON property name, ^[a-z][a-z0-9_]*$), a label, and a
type:
type |
Compiles to |
|---|---|
word |
Non-empty string with no whitespace |
line |
String with no newlines |
text |
Any string |
path |
Non-empty single-line string |
agent |
Same as word |
int |
Integer |
bool |
Boolean |
float |
Number |
enum |
One of a declared, non-empty choices list |
repeatable: true wraps the compiled fragment in a JSON array. choices accepts either
plain strings or {value, label} objects and is required (and only valid) for enum.
default, placeholder, and help are optional; a declared default is validated
against the field's own compiled fragment, so a default no client could submit is a
creation error rather than a gate that fails on first answer. secret: true reaches the
command's stdin unredacted but is written to response.json and the execution journal
as {"$redacted": true} — masked display alone would not be enough, since the response
file is durable audit data. That covers every place those two files hold the value:
option_inputs, the legacy shared input beside it, and the stored command result. A
command is free to echo its stdin back, so any result string that merely contains a
submitted secret is replaced whole rather than spliced. Only non-empty string secrets
are matched, because a secret boolean or small integer carries no entropy and matching
on it would redact unrelated output. The journal's result_digest is taken from the raw
result and still identifies exactly what the command returned.
inputs and a raw input_schema are mutually exclusive per option: declaring both is a
creation error unless the raw schema exactly equals what inputs would compile to. An
option declaring neither means "this command takes no input", which compiles to the
honest {"type": "object", "additionalProperties": false} schema; an author who
genuinely wants the permissive schema writes "input_schema": {} explicitly. A declared
format keyword is annotation-only — the executor validates with no FormatChecker —
so it is documentation, never a constraint. Every stored schema is pinned to the Draft
2020-12 dialect.
At creation, every option's effective schema is checked for answerability: SASE
builds the richest value a client could actually submit and checks that the schema can
accept it. For an option declaring inputs, that value is {} plus every declared
field's default plus feedback when the option's feedback mode allows it, validated
against the compiled schema. For an option declaring a raw input_schema and no
inputs, the reviewer types the value into a raw-schema editor — sase's TUI input panel
(one YAML editor under that option's section), sase gate answer --option-input, or the
mobile bridge's option_inputs — so every property declared under properties is
producible and the schema's own constraints on those properties (patterns, bounds,
types) are the reviewer's to satisfy. What still fails closed is a required name that
nothing renders a control for: a name absent from properties, or feedback on an
option whose feedback mode is disabled. An option that could never be answered by any
surface fails sase gate create with unanswerable_option, naming the offending
required property, instead of being accepted and dying silently on first submission.
Every input value is also bounded, both at creation and at submission: canonical JSON at
most 64 KiB, nesting depth at most 16, at most 128 properties in one object, and at most
512 items in one array.
sase's TUI collects typed values in that input panel. Each selected option that declares
inputs or a raw schema gets its own section, headed by that option's icon and label.
When two selected AND options declare the same field id compatibly, the field is
collected once — it renders in the first declaring option's section, annotated
also sent to the other option's label — and option_inputs still carries the value
under every option that declared the id. An incompatible duplicate blocks submit and the
panel shows the conflict. Confirming the panel always submits; cancelling never does,
and the next open of the same selection restores the draft. Every freeform box in the
panel is a vim editor (insert and normal modes). Tab and Shift+Tab walk the fields;
Ctrl+S submits; a path field also accepts Ctrl+T to cycle filesystem completions. The
reviewer note editor also offers next-word autosuggest from typed prompt history:
Ctrl+T takes one word, Ctrl+L takes all, and a mid-sentence guess appears as a
border peek rather than inline ghost text.
Feedback is one rule everywhere. The reviewer's free-text note is injected as
input.feedback for a selected option iff that option's effective input_schema
declares a feedback property — an option with no feedback property is left alone,
and an option with an ordinary feedback field declared under its own inputs is
respected as-is. The same rule runs inside the shared executor for every caller — sase's
TUI, mobile, Telegram, and sase gate answer alike — so a gate answers identically
regardless of where it was tapped.
Submission is per option. A surface submits option_inputs, a mapping of selected
option id to that option's own JSON value; a two-member AND branch can hand each member
a different value without either schema having to tolerate the other's fields. The
legacy shared-value contract (input_data, one JSON value applied to every selected
option) still works for bundles that predate per-option submission; the two are mutually
exclusive, and supplying both is a creation-time conflicting_input error.
response.json always records option_inputs (every entry is the same value under the
legacy path) alongside the existing input field, so nothing that reads input today
breaks.
Gate actions¶
A gate action is a control the reviewer may run as many times as they need and that
never answers the gate — it exists so a reviewer can iterate toward a decision (edit
the plan until it validates, read a diff, run a dry run) instead of being forced to
answer or cancel immediately. Actions are declared in the request's operations array
(the field name that predates this mechanism, kept for wire compatibility) and rendered
in an Actions section, kept visually distinct from and above Decision; the
section is omitted entirely when a gate declares no actions.
Two kinds are declared:
edit_fileopens an editable resource in$EDITOR.edit_target: "resource"(the default) edits the bundle's own copy, exactly as before.edit_target: "origin"instead opens the durable file the resource was copied from — for example, a plan or epic gate'sedit_planaction opens the file under~/.sase/plans/thatsase plan proposewrote, not the bundle's snapshot of it. The edit is copied into the bundle and accepted only when the kind adapter validates it (for plan and epic gates, only whensase plan validatepasses); on rejection the bundle keeps its last accepted revision and the reviewer's draft stays in the origin file rather than being discarded, so reopening the editor resumes their work. While an origin file holds an edit the gate never accepted, every submit control is disabled — including reject — and the surface shows a banner naming the file; discarding the draft (a separate, confirmed action) restores the origin from the last accepted revision.run_commandruns an owned bundle command and shows the reviewer its output. Its stdout must be one JSON value validated against the action's ownresult_schema; the surface reads a small closed display record out of it —summary(a one-line toast),body(rendered per the declareddisplay:markdown,text,json, ornone), andrefresh(whether the surface should reload and re-verify the bundle before re-rendering) — and everything else stays in the result and the execution journal.targetslists the editable resources the command is allowed to rewrite; after the command exits every resource is re-hashed, and a change to any resource not listed intargetsis ahash_mismatchfailure. This is what stops a display command from quietly rewriting the very command the reviewer is about to approve.
An action never writes response.json and refuses to run once the gate already has a
response or a cancellation. Every run — successful or failed — appends to the bundle's
execution journal (journal.jsonl), which is also what sase gate wait --json reports
under operations: the audit trail of what a reviewer ran before deciding.
An action may declare a single-character key (for example key: "e" on the plan and
epic edit_plan action). Creation rejects a key already reserved by the static gate
modal bindings (q, d, g, G, and the digit branch selectors 1-9) or reused by
another action on the same gate. A collision with the reviewer's own configured gate
modal keymaps cannot be known at creation time; sase's TUI resolves it at render time by
reassigning from a deterministic fallback pool and displaying whichever key it actually
bound.
Debugging a gate¶
Press d on any notification row or from an open plan, epic, question, launch,
custom-gate, or workflow HITL panel to open Gate Debug. The sudo review modal is the
exception: there d denies the request, so open Gate Debug from the notification row.
The overlay keeps the underlying form mounted, so closing it returns to the same
selected branch, checked group options, and typed feedback. Non-gate inbox rows use the
same view and show an explicit no-bundle state alongside their raw notification JSON.
Gate Debug loads bundle I/O and hash verification away from the TUI event loop. Its tabs
show the lifecycle overview, the canonical request.json, the terminal response.json
or cancellation.json, bounded execution error records, and the raw row from
notifications.jsonl. The overview re-verifies request and resource hashes live and
includes timeout, pending-action transport/staleness, and notification state. Missing,
malformed, or oversized artifacts render as diagnostics instead of preventing the modal
from opening.
| Key | Gate Debug action |
|---|---|
[ / ] |
Switch tabs |
j / k |
Scroll one line |
Ctrl+D / Ctrl+U |
Scroll half a page |
g / G |
Jump to the top / bottom |
y |
Copy the current tab's raw text |
Y |
Copy the gate bundle path |
e |
Open the current tab's backing artifact in $EDITOR |
d / Esc / q |
Close Gate Debug and return to the underlying notification UI |
Compatibility window¶
Readers resolve the neutral bundle first and then fall back to in-flight legacy plan, question, launch, or HITL request directories. New producers do not dual-write the old trees. Keep these fallbacks for at least one complete SASE release after neutral writers ship and beyond the 24-hour remote-action stale window. The cross-kind resolver regression test locks this rule in; removing a fallback requires a separately announced migration after that window, updated fixtures, and explicit release notes.
Unanswered schema-v2 neutral bundles also remain hash-verifiable and answerable. Readers
project their historical first branch as the primary action in memory; new gate creation
requires schema v3 and an explicit primary_branch.
Question summaries use the same resolver, so sase's TUI can render both neutral and legacy questions. Gate command execution from sase's TUI is scheduled as tracked background work rather than running on Textual's event loop.
Storage¶
Notifications are stored in JSONL format at ~/.sase/notifications/notifications.jsonl.
The production store backend is sase_core_rs: appends and state mutations take a
shared sidecar lock, and rewrites use a tempfile plus rename so multiple axe processes
and the TUI can access the file without truncate-before-lock exposure. Common state-only
updates such as mark-read, mark-all-read, mute, snooze, and dismiss use a count-only
Rust mutation path unless the caller needs rehydrated notification rows; this keeps
inbox counters cheap when sase's TUI or a bridge process only needs mutation metadata.
sase's TUI snapshot reads memoize the parsed store against the JSONL path, the
include_dismissed flag, and an mtime+size change token, so an unchanged live file is
not re-parsed on the TUI refresh cadence. A parsed snapshot is cached only when the
change token is the same before and after the read, so a write that lands mid-read is
picked up by the next read instead of being masked. A due next_snooze_deadline still
forces a re-read when the caller asked to expire snoozes.
The store keeps itself O(live). The hourly notification_store_compact housekeeping job
drives compaction so a multi-megabyte re-parse does not sit on an interactive path. When
notifications.jsonl crosses 4 MiB or holds at least 1,000 dismissed rows, a
cache-missing read or rewrite may still compact under the same exclusive lock: dismissed
rows older than a 3-day retention window are appended to a sibling
notifications-archive.jsonl and dropped from the live file, then the live file is
replaced atomically so a crash mid-compaction loses no row. Snoozed rows are never
archived while snoozed, and dismissed rows inside the retention window stay put so the
inbox UI's include_dismissed view is unchanged. Unread and still-actionable rows stay
in the live file. sase logs pack reads the archive alongside the live file, so a
window older than the retention period still packs complete.
The Rust store also owns every temporal semantic: it validates and normalizes snooze
deadlines, expires due rows atomically under the same lock as the read, stamps
resurfaced_at, and reports both expired_ids and the earliest remaining
next_snooze_deadline alongside the projected counts. Consumers schedule their timers
from that projected deadline instead of polling. Concurrent expiring readers converge on
one store state without losing appended rows.
Source: src/sase/notifications/