diff --git a/modules/dunst/README.md b/modules/dunst/README.md
index 53661357..6c288e2f 100644
--- a/modules/dunst/README.md
+++ b/modules/dunst/README.md
@@ -29,6 +29,16 @@ ends the waits it held; it says `started` {machine} once it serves, and the oper
open answers there again (novox/hq ADR 0258). `notify-send` prints the id before it waits, also into a
pipe (checked on the laptop on 2026-10-08: the id at once, and "Wait timeout expired" when it expired).
+## What is shown once, and whether it would be seen
+
+- **A secret notification** (`send` with `secret`: a link's code) is sent transient and in the category
+ `mesh.secret`, whatever category was named. The dunstrc's rules keep it, and every transient one, out of
+ dunst's history (`history_ignore`), and `history` leaves out any that got there anyway, counting them as
+ `withheld`: the history is served to every caller of the mesh's verbs, agents among them.
+- **Whether notifications are paused** is said as this module's event `paused` {paused, machine}: at start,
+ on every change, and every two minutes, read from dunst itself so a pause set by a key counts too. The desk
+ shows a link's code only on a machine it heard is not paused lately.
+
## Tools
Every tool goes over the account's session bus. None needs the screen, and each answers clearly when
diff --git a/modules/dunst/cmd/dunst-tools/dunst.go b/modules/dunst/cmd/dunst-tools/dunst.go
index a9991095..3ef707cc 100644
--- a/modules/dunst/cmd/dunst-tools/dunst.go
+++ b/modules/dunst/cmd/dunst-tools/dunst.go
@@ -28,8 +28,16 @@ type Notification struct {
Category string
ReplaceID int
Actions []Choice
+ // Transient asks dunst to keep it out of its history; Secret marks something shown once (a link's code):
+ // transient, and in SecretCategory whatever category was named, which the dunstrc keeps out of the
+ // history and the history verb leaves out (the review of 2026-10-09).
+ Transient bool
+ Secret bool
}
+// SecretCategory is the category a secret notification is sent in, and the one the history never shows.
+const SecretCategory = "mesh.secret"
+
func notificationOf(args map[string]any) (Notification, error) {
var n Notification
var err error
@@ -70,10 +78,32 @@ func notificationOf(args map[string]any) (Notification, error) {
if n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1); err != nil {
return n, err
}
+ if n.Transient, err = boolean(args, "transient"); err != nil {
+ return n, err
+ }
+ if n.Secret, err = boolean(args, "secret"); err != nil {
+ return n, err
+ }
+ if n.Secret {
+ n.Transient, n.Category = true, SecretCategory
+ }
n.Actions, err = choicesOf(args)
return n, err
}
+// boolean reads an optional true or false.
+func boolean(args map[string]any, key string) (bool, error) {
+ v, present := args[key]
+ if !present || v == nil {
+ return false, nil
+ }
+ b, ok := v.(bool)
+ if !ok {
+ return false, fmt.Errorf("%s is true or false", key)
+ }
+ return b, nil
+}
+
// SendResult is what node-notifier.send answers.
type SendResult struct {
ID int `json:"id"`
@@ -100,6 +130,9 @@ func Send(n Notification) (SendResult, error) {
if n.Category != "" {
args = append(args, "--category="+n.Category)
}
+ if n.Transient {
+ args = append(args, "--transient")
+ }
if n.ReplaceID > 0 {
args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID))
}
@@ -187,6 +220,8 @@ type Shown struct {
type HistoryResult struct {
Total int `json:"total"`
Notifications []Shown `json:"notifications"`
+ // Withheld is how many secret notifications the history held and this left out.
+ Withheld int `json:"withheld,omitempty"`
}
// History is dunst's history, newest first.
@@ -203,13 +238,18 @@ func parseHistory(raw string, nowMicros int64, limit int) (HistoryResult, error)
if err != nil {
return HistoryResult{}, err
}
- out := HistoryResult{Total: len(entries), Notifications: []Shown{}}
+ out := HistoryResult{Notifications: []Shown{}}
type stamped struct {
Shown
at int64
}
var all []stamped
for _, e := range entries {
+ if str(e["category"]) == SecretCategory {
+ out.Withheld++
+ continue
+ }
+ out.Total++
at := number(e["timestamp"])
all = append(all, stamped{Shown{
ID: int(number(e["id"])), AppName: str(e["appname"]), Summary: str(e["summary"]), Body: str(e["body"]),
diff --git a/modules/dunst/cmd/dunst-tools/main.go b/modules/dunst/cmd/dunst-tools/main.go
index dc493989..547e9cab 100644
--- a/modules/dunst/cmd/dunst-tools/main.go
+++ b/modules/dunst/cmd/dunst-tools/main.go
@@ -12,6 +12,7 @@ import (
func main() {
go sayStarted()
+ go watchPaused()
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@@ -25,7 +26,8 @@ func tools() []stdio.Tool {
Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " +
"normal, critical), and optionally the sending application's name, an icon, how long it stays and " +
"the answers it offers (actions: token and label each). Answers the notification's id; the answer " +
- "the operator chooses is said later as the event action-chosen {id, token, machine}.",
+ "the operator chooses is said later as the event action-chosen {id, token, machine}. A secret " +
+ "or transient one is kept out of the history.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
@@ -37,6 +39,9 @@ func tools() []stdio.Tool {
"expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"},
"category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"},
"replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"},
+ "transient": map[string]any{"type": "boolean", "description": "keep it out of the history (default false)"},
+ "secret": map[string]any{"type": "boolean", "description": "it carries something shown once, such as a " +
+ "link's code: transient, in the category " + SecretCategory + ", and never in the history (default false)"},
"actions": map[string]any{"type": "array", "description": "at most 4 answers the notification offers, " +
"each {token, label}; the chosen token is said as the event action-chosen",
"items": map[string]any{"type": "object", "properties": map[string]any{
@@ -55,7 +60,7 @@ func tools() []stdio.Tool {
{
Name: "node-notifier.history",
Description: "The notifications the operator was shown, newest first: id, application, title, " +
- "body, urgency and how long ago.",
+ "body, urgency and how long ago. A secret one (a link's code) is never shown; withheld counts them.",
Input: map[string]any{
"limit": map[string]any{"type": "integer", "description": "at most this many (default 20, at most 200)"},
},
diff --git a/modules/dunst/cmd/dunst-tools/paused.go b/modules/dunst/cmd/dunst-tools/paused.go
new file mode 100644
index 00000000..8d55ac29
--- /dev/null
+++ b/modules/dunst/cmd/dunst-tools/paused.go
@@ -0,0 +1,71 @@
+package main
+
+// Whether notifications are paused, said as this module's event `paused` {paused, machine} (the review of
+// 2026-10-09, C4): at start, on every change, and again every PausedEvery. The desk shows a link's code only on
+// a machine it heard is not paused lately, and says it cannot send one while none is: a code held back by a
+// paused notifier would appear later, to whoever is then at the screen. The pause is read from dunst itself
+// (`dunstctl is-paused`), so one set by a key or by hand is said like one set by dunst_pause.
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+)
+
+const (
+ // PausedEvent is the event's name; PausedEvery how often it is said when nothing changed; PausedLook how
+ // often dunst is asked.
+ PausedEvent = "paused"
+ PausedEvery = 2 * time.Minute
+ PausedLook = 5 * time.Second
+)
+
+// pauseWatch says the pause; its two reaches are seams a test replaces.
+type pauseWatch struct {
+ isPaused func() (bool, error)
+ say func(paused bool) error
+ saidAt time.Time
+ said bool
+ known bool
+}
+
+// step looks once, and says it when it changed or was said PausedEvery ago. Nothing is said while dunst
+// cannot be asked: the desk then hears nothing, and takes the machine as not known.
+func (w *pauseWatch) step(now time.Time) {
+ paused, err := w.isPaused()
+ if err != nil {
+ return
+ }
+ if w.known && paused == w.said && now.Sub(w.saidAt) < PausedEvery {
+ return
+ }
+ if w.say(paused) == nil {
+ w.known, w.said, w.saidAt = true, paused, now
+ }
+}
+
+func isPaused() (bool, error) {
+ out, err := dunstctl("is-paused")
+ if err != nil {
+ return false, err
+ }
+ switch strings.TrimSpace(out) {
+ case "true":
+ return true, nil
+ case "false":
+ return false, nil
+ }
+ return false, fmt.Errorf("dunstctl is-paused answered %q", out)
+}
+
+// watchPaused runs the watch for as long as the bundle runs.
+func watchPaused() {
+ w := &pauseWatch{isPaused: isPaused, say: func(p bool) error {
+ return emit(PausedEvent, map[string]any{"paused": p, "machine": os.Getenv("MESH_NODE")})
+ }}
+ for {
+ w.step(time.Now())
+ time.Sleep(PausedLook)
+ }
+}
diff --git a/modules/dunst/cmd/dunst-tools/secret_test.go b/modules/dunst/cmd/dunst-tools/secret_test.go
new file mode 100644
index 00000000..728e2169
--- /dev/null
+++ b/modules/dunst/cmd/dunst-tools/secret_test.go
@@ -0,0 +1,88 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// A notification carrying something shown once — a link's code — is sent transient and in the category
+// mesh.secret, whatever category the caller names (the review of 2026-10-09, H4): the dunstrc's rules keep
+// both out of dunst's history, and the history verb leaves out any that got there anyway, so no agent reads
+// the code back through node-notifier.history.
+func TestASecretNotificationIsTransientAndKeptOutOfTheHistory(t *testing.T) {
+ withBus(t)
+ bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo 42`})
+ t.Setenv("LOG", filepath.Join(bin, "log"))
+ n, err := notificationOf(map[string]any{"summary": "Link code", "body": "123 456", "secret": true, "category": "email"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Send(n); err != nil {
+ t.Fatal(err)
+ }
+ asked, _ := os.ReadFile(filepath.Join(bin, "log"))
+ for _, want := range []string{"[--transient]", "[--category=" + SecretCategory + "]"} {
+ if !strings.Contains(string(asked), want) {
+ t.Errorf("notify-send was not asked %s: %s", want, asked)
+ }
+ }
+ if strings.Contains(string(asked), "--category=email") {
+ t.Error("a secret notification kept the caller's category")
+ }
+ if n, err := notificationOf(map[string]any{"summary": "x", "transient": true}); err != nil || !n.Transient || n.Secret {
+ t.Errorf("transient alone: %+v %v", n, err)
+ }
+
+ // The history leaves a secret one out, and says how many it left out.
+ withSecret := strings.Replace(history, `"category":{"type":"s","data":"device"}`,
+ `"category":{"type":"s","data":"`+SecretCategory+`"}`, 1)
+ got, err := parseHistory(withSecret, 200_000_000, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Notifications) != 1 || got.Notifications[0].ID != 7 || got.Total != 1 || got.Withheld != 1 {
+ t.Fatalf("the history showed a secret notification: %+v", got)
+ }
+
+ // The decided file holds the rules that keep them out of dunst's own history.
+ rc, err := os.ReadFile(filepath.Join("..", "..", "files", "dunstrc"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"category = \"" + SecretCategory + "\"", "match_transient = yes", "history_ignore = yes"} {
+ if !strings.Contains(string(rc), want) {
+ t.Errorf("dunstrc does not say %q", want)
+ }
+ }
+}
+
+// Whether notifications are paused is said as the event `paused` {paused, machine}: at once, on every change,
+// and again every PausedEvery, so the desk knows a link's code would not be seen (C4); nothing is said while
+// dunst cannot be asked.
+func TestWhetherPausedIsSaidOnChangeAndEveryFewMinutes(t *testing.T) {
+ var said []bool
+ paused, fails := false, false
+ w := pauseWatch{
+ isPaused: func() (bool, error) {
+ if fails {
+ return false, os.ErrNotExist
+ }
+ return paused, nil
+ },
+ say: func(p bool) error { said = append(said, p); return nil },
+ }
+ at := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
+ w.step(at)
+ w.step(at.Add(5 * time.Second))
+ paused = true
+ w.step(at.Add(10 * time.Second))
+ w.step(at.Add(10*time.Second + PausedEvery))
+ fails = true
+ w.step(at.Add(10*time.Second + 3*PausedEvery))
+ if len(said) != 3 || said[0] || !said[1] || !said[2] {
+ t.Fatalf("said %v", said)
+ }
+}
diff --git a/modules/dunst/files/dunstrc b/modules/dunst/files/dunstrc
index 705a1f42..727a12e1 100644
--- a/modules/dunst/files/dunstrc
+++ b/modules/dunst/files/dunstrc
@@ -92,3 +92,14 @@
foreground = "#ffffff"
frame_color = "#ff0000"
timeout = 0
+
+# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's
+# code, category mesh.secret) and any transient one leave no entry in the history, which
+# node-notifier.history serves to every caller of the mesh's verbs.
+[mesh-secret]
+ category = "mesh.secret"
+ history_ignore = yes
+
+[transient-history-ignore]
+ match_transient = yes
+ history_ignore = yes
diff --git a/modules/dunst/module.json b/modules/dunst/module.json
index 7c37c2ae..37c0adfa 100644
--- a/modules/dunst/module.json
+++ b/modules/dunst/module.json
@@ -66,7 +66,7 @@
"path": "${machine:account-home}/.config/dunst/dunstrc",
"owner": "${machine:account}",
"mode": "0644",
- "content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"%s\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n"
+ "content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"%s\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n\n# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's\n# code, category mesh.secret) and any transient one leave no entry in the history, which\n# node-notifier.history serves to every caller of the mesh's verbs.\n[mesh-secret]\n category = \"mesh.secret\"\n history_ignore = yes\n\n[transient-history-ignore]\n match_transient = yes\n history_ignore = yes\n"
},
{
"id": "notifier",
@@ -96,6 +96,7 @@
},
"emits": [
"action-chosen",
- "started"
+ "started",
+ "paused"
]
}