diff --git a/eww/.config/eww/eww.scss b/eww/.config/eww/eww.scss
index b642d16..7436db5 100644
--- a/eww/.config/eww/eww.scss
+++ b/eww/.config/eww/eww.scss
@@ -90,7 +90,8 @@ eventbox, box, label, scale {
.battery-icon,
.battery-percent,
.time-label,
-.notification-icon {
+.notification-icon,
+.dnd-icon {
color: $fg;
font-weight: bold;
padding: 0 $icon-pad;
@@ -101,7 +102,8 @@ eventbox, box, label, scale {
.signal-eventbox,
.net-eventbox,
-.notification-eventbox {
+.notification-eventbox,
+.dnd-eventbox {
min-width: 0;
min-height: 0;
}
diff --git a/eww/.config/eww/eww.yuck b/eww/.config/eww/eww.yuck
index eae5128..8b02be7 100644
--- a/eww/.config/eww/eww.yuck
+++ b/eww/.config/eww/eww.yuck
@@ -70,11 +70,18 @@
;; Notifications
;; ---------------------------------------------------------------------------
+(defpoll dnd-state :interval "1s" "bash ~/.config/eww/scripts/mako-dnd-state")
+
(defwidget notification-widget []
- (eventbox :class "notification-eventbox"
- :onclick "bash ~/.config/eww/scripts/notification-history-toggle"
- :tooltip "Toggle notification history"
- (label :text "" :class "notification-icon")))
+ (box :orientation "h" :space-evenly false :spacing gap-tight
+ (eventbox :class "notification-eventbox"
+ :onclick "bash ~/.config/eww/scripts/notification-history-toggle"
+ :tooltip "Toggle notification history"
+ (label :text "" :class "notification-icon"))
+ (eventbox :class "notification-eventbox dnd-eventbox"
+ :onclick "bash ~/.config/eww/scripts/mako-dnd-toggle"
+ :tooltip "Toggle do-not-disturb"
+ (label :text {dnd-state} :class "notification-icon dnd-icon"))))
;; ---------------------------------------------------------------------------
;; Battery
diff --git a/eww/.config/eww/scripts/mako-dnd-state b/eww/.config/eww/scripts/mako-dnd-state
new file mode 100755
index 0000000..578e59a
--- /dev/null
+++ b/eww/.config/eww/scripts/mako-dnd-state
@@ -0,0 +1,8 @@
+#!/bin/bash
+# Print the current do-not-disturb state as a bar icon.
+# Called by eww as a defpoll.
+if makoctl mode 2>/dev/null | grep -q dnd; then
+ echo "" # bell-off: do-not-disturb active
+else
+ echo "" # bell-ring: notifications on
+fi
diff --git a/eww/.config/eww/scripts/mako-dnd-toggle b/eww/.config/eww/scripts/mako-dnd-toggle
new file mode 100755
index 0000000..976fe9f
--- /dev/null
+++ b/eww/.config/eww/scripts/mako-dnd-toggle
@@ -0,0 +1,3 @@
+#!/bin/bash
+# Toggle do-not-disturb mode for mako notifications.
+makoctl mode -t dnd
diff --git a/mako/.config/mako/config b/mako/.config/mako/config
index eadf35e..5252716 100644
--- a/mako/.config/mako/config
+++ b/mako/.config/mako/config
@@ -14,6 +14,7 @@ height=300
margin=5
padding=8
icon-path=/usr/share/icons/Papirus-Dark
+max-icon-size=48
# Layout
anchor=top-right
@@ -21,29 +22,66 @@ outer-margin=30,0,0,0
# Behavior
default-timeout=6000
-ignore-timeout=0
-max-visible=20
+# Force mako's own timeouts so no notification (critical or not) can hang
+# indefinitely, even if the app requests timeout=0 (never).
+ignore-timeout=1
+max-visible=5
sort=-time
+group-by=app-name
+max-history=20
-# Format - show app name in italics
-format=%s (%a)\n%b
-markup=1
+# Progress bar for notifications that report progress (downloads, updates)
+progress-color=over #69C3FF
-# Mouse bindings - click to focus app, invoke action, and close
+# Format - plain text (markup=0) so app text with renders literally.
+# Priority is shown by a leading icon instead of markup/colors.
+markup=0
+format= %s (%a)\n%b
+
+# Mouse bindings
on-button-left=exec ~/.config/mako/scripts/handle-click $id
on-button-middle=none
on-button-right=exec makoctl dismiss -n $id
+on-notify=exec ~/.config/mako/scripts/notify-sound $id
# Urgency levels
[urgency=low]
background-color=#212a3b
border-color=#405275
+format= %s (%a)\n%b
[urgency=normal]
background-color=#1c2433
border-color=#69C3FF
+format= %s (%a)\n%b
[urgency=critical]
background-color=#1c2433
border-color=#FF738A
-default-timeout=0
+format= %s (%a)\n%b
+# Was 0 (never) - critical notifications now auto-dismiss after 15s
+default-timeout=15000
+
+# Grouped notifications (group-by=app-name) - plain text, icon per urgency
+[grouped]
+format= (%g) %s\n%b
+
+[grouped urgency=low]
+format= (%g) %s\n%b
+
+[grouped urgency=critical]
+format= (%g) %s\n%b
+
+# Placeholder shown when more notifications exist than max-visible
+[hidden]
+format= +%h more
+
+# Pi agent notifications (sent via the pi QoL extension from wezterm)
+[app-name=wezterm summary=Pi]
+border-color=#EACD61
+background-color=#212a3b
+
+# Do-not-disturb: makoctl mode -a dnd hides all notifications.
+# Must stay last so it overrides every style above.
+[mode=dnd]
+invisible=1
diff --git a/mako/.config/mako/config.template b/mako/.config/mako/config.template
index 7d1d273..f786273 100644
--- a/mako/.config/mako/config.template
+++ b/mako/.config/mako/config.template
@@ -14,6 +14,7 @@ height=300
margin=5
padding=8
icon-path=/usr/share/icons/Papirus-Dark
+max-icon-size=48
# Layout
anchor=top-center
@@ -21,29 +22,67 @@ outer-margin=30,0,0,0
# Behavior
default-timeout=6000
-ignore-timeout=0
-max-visible=20
+# Force mako's own timeouts so no notification (critical or not) can hang
+# indefinitely, even if the app requests timeout=0 (never).
+ignore-timeout=1
+max-visible=5
sort=-time
+group-by=app-name
+max-history=20
-# Format - show app name in italics
-format=%s (%a)\n%b
-markup=1
+# Progress bar for notifications that report progress (downloads, updates)
+progress-color=over #$BORDER
-# Mouse bindings - click to focus app, invoke action, and close
+# Format - plain text (markup=0) so app text with renders literally.
+# Priority is shown by a leading icon instead of markup/colors.
+markup=0
+format= %s (%a)\n%b
+
+# Mouse bindings
on-button-left=exec ~/.config/mako/scripts/handle-click $id
on-button-middle=none
on-button-right=exec makoctl dismiss -n $id
+on-notify=exec ~/.config/mako/scripts/notify-sound $id
# Urgency levels
[urgency=low]
background-color=#$BG_BAR
border-color=#$TRACK
+format= %s (%a)\n%b
[urgency=normal]
background-color=#$BG
border-color=#$BORDER
+format= %s (%a)\n%b
[urgency=critical]
background-color=#$BG
border-color=#$URGENT
-default-timeout=0
+format= %s (%a)\n%b
+# Was 0 (never) - critical notifications now auto-dismiss after 15s
+default-timeout=15000
+
+# Grouped notifications (group-by=app-name) - plain text, icon per urgency
+[grouped]
+format= (%g) %s\n%b
+
+[grouped urgency=low]
+format= (%g) %s\n%b
+
+[grouped urgency=critical]
+format= (%g) %s\n%b
+
+# Placeholder shown when more notifications exist than max-visible
+[hidden]
+format= +%h more
+
+# Pi agent notifications (sent via the pi QoL extension from wezterm).
+# Matches theme accent $accent (#EACD61).
+[app-name=wezterm summary=Pi]
+border-color=#EACD61
+background-color=#$BG_BAR
+
+# Do-not-disturb: makoctl mode -a dnd hides all notifications.
+# Must stay last so it overrides every style above.
+[mode=dnd]
+invisible=1
diff --git a/mako/.config/mako/scripts/Chord.wav b/mako/.config/mako/scripts/Chord.wav
new file mode 100644
index 0000000..9209784
Binary files /dev/null and b/mako/.config/mako/scripts/Chord.wav differ
diff --git a/mako/.config/mako/scripts/Chord2.wav b/mako/.config/mako/scripts/Chord2.wav
new file mode 100644
index 0000000..b7059a5
Binary files /dev/null and b/mako/.config/mako/scripts/Chord2.wav differ
diff --git a/mako/.config/mako/scripts/handle-click b/mako/.config/mako/scripts/handle-click
old mode 100644
new mode 100755
index ae5f495..b8b1e34
--- a/mako/.config/mako/scripts/handle-click
+++ b/mako/.config/mako/scripts/handle-click
@@ -1,43 +1,47 @@
#!/bin/bash
-# Handle notification click: focus app → invoke action → close
-# Called by mako with notification ID as parameter
+# Handle notification click: focus the app that sent it → invoke action → dismiss
+# Called by mako with the notification ID as argument
ID="$1"
+[ -z "$ID" ] && exit 0
-if [ -z "$ID" ]; then
- exit 0
-fi
+# Pull the app name out of makoctl list, scoped to this notification ID
+APP=$(makoctl list 2>/dev/null | awk -v id="$ID" '
+ $1 == "Notification" { if (seen) exit; if ($2 == id) seen = 1; next }
+ seen && /^ App name:/ { sub(/^ App name: /, ""); print; exit }')
-# Get app name from notification using makoctl
-APPNAME=$(makoctl list | grep -A 2 "^Notification $ID" | grep "App name:" | cut -d: -f2- | xargs)
+# Find a sway window matching the app and focus it.
+# Exact app_id match first, then X11 class, then partial app_id as fallback.
+if [ -n "$APP" ]; then
+ TREE=$(swaymsg -t get_tree 2>/dev/null)
-# Step 1: Focus the application window in Sway (if app name found)
-if [ -n "$APPNAME" ]; then
- # Get the workspace of the window
- WORKSPACE=$(swaymsg -t get_tree | jq -r ".. | select(.app_id?==\"$APPNAME\" or .window_properties?.class?==\"$APPNAME\") | .workspace" | head -1)
+ CID=$(echo "$TREE" | jq -r --arg app "$APP" '
+ [ .. | objects | select(.type? == "con")
+ | select((.app_id? // "") != "")
+ | select((.app_id | ascii_downcase) == ($app | ascii_downcase))
+ | .id ][0] // empty')
- # If workspace not found, try case-insensitive
- if [ -z "$WORKSPACE" ] || [ "$WORKSPACE" = "null" ]; then
- WORKSPACE=$(swaymsg -t get_tree | jq -r ".. | select(.app_id?|test(\"$APPNAME\";\"i\") or .window_properties?.class?|test(\"$APPNAME\";\"i\")) | .workspace" | head -1)
+ if [ -z "$CID" ]; then
+ CID=$(echo "$TREE" | jq -r --arg app "$APP" '
+ [ .. | objects | select(.type? == "con")
+ | select((.window_properties?.class? // "") != "")
+ | select((.window_properties.class | ascii_downcase) == ($app | ascii_downcase))
+ | .id ][0] // empty')
fi
- # Switch to the workspace if found
- if [ -n "$WORKSPACE" ] && [ "$WORKSPACE" != "null" ]; then
- swaymsg workspace "$WORKSPACE" 2>/dev/null
- sleep 0.1
+ if [ -z "$CID" ]; then
+ CID=$(echo "$TREE" | jq -r --arg app "$APP" '
+ [ .. | objects | select(.type? == "con")
+ | select((.app_id? // "") != "")
+ | select((.app_id | ascii_downcase) | contains($app | ascii_downcase))
+ | .id ][0] // empty')
fi
- # Now focus the window
- swaymsg "[app_id=\"$APPNAME\"]" focus 2>/dev/null || \
- swaymsg "[app_id=\"(?i)$APPNAME\"]" focus 2>/dev/null || \
- swaymsg "[class=\"$APPNAME\"]" focus 2>/dev/null || \
- swaymsg "[class=\"(?i)$APPNAME\"]" focus 2>/dev/null
-
- sleep 0.1
+ if [ -n "$CID" ]; then
+ swaymsg "[con_id=$CID]" focus >/dev/null 2>&1
+ fi
fi
-# Step 2: Invoke the notification's default action (if any)
-makoctl invoke -n "$ID" 2>/dev/null
-
-# Step 3: Dismiss the notification
-makoctl dismiss -n "$ID" 2>/dev/null
+# Invoke the notification's default action (may open/focus the app), then dismiss
+makoctl invoke -n "$ID" >/dev/null 2>&1
+makoctl dismiss -n "$ID" >/dev/null 2>&1
diff --git a/mako/.config/mako/scripts/notify-sound b/mako/.config/mako/scripts/notify-sound
new file mode 100755
index 0000000..e97aa4e
--- /dev/null
+++ b/mako/.config/mako/scripts/notify-sound
@@ -0,0 +1,39 @@
+#!/bin/bash
+# Play a notification sound, alternating between Chord.wav and Chord2.wav.
+# Skips: do-not-disturb mode and low-urgency notifications.
+# Usage: notify-sound (called from mako's on-notify binding)
+
+ID="${1:-}"
+
+# Silence while do-not-disturb is active
+if makoctl mode 2>/dev/null | grep -q dnd; then
+ exit 0
+fi
+
+# Only play for normal/critical urgency
+if [ -n "$ID" ]; then
+ urgency=$(makoctl list 2>/dev/null | awk -v id="$ID" '
+ $1 == "Notification" { if (seen) exit; if ($2 == id) seen = 1; next }
+ seen && /^ Urgency:/ { print $2; exit }')
+ if [ "$urgency" = "low" ]; then
+ exit 0
+ fi
+fi
+
+SOUND_DIR=~/.config/mako/scripts
+STATE_FILE="${XDG_CACHE_HOME:-$HOME/.cache}/mako-notify-sound"
+
+# Alternate between the two sounds on every play
+if [ -f "$STATE_FILE" ] && [ "$(cat "$STATE_FILE")" = "0" ]; then
+ SOUND="$SOUND_DIR/Chord2.wav"
+ echo "1" > "$STATE_FILE"
+else
+ SOUND="$SOUND_DIR/Chord.wav"
+ echo "0" > "$STATE_FILE"
+fi
+
+if command -v paplay >/dev/null 2>&1; then
+ paplay "$SOUND"
+elif command -v pw-play >/dev/null 2>&1; then
+ pw-play "$SOUND"
+fi
diff --git a/mako/.config/mako/scripts/pling.wav b/mako/.config/mako/scripts/pling.wav
new file mode 100644
index 0000000..f8a8486
Binary files /dev/null and b/mako/.config/mako/scripts/pling.wav differ
diff --git a/pi/.pi/agent/extensions/subagent-pane-reminder.ts b/pi/.pi/agent/extensions/subagent-pane-reminder.ts
new file mode 100644
index 0000000..770088e
--- /dev/null
+++ b/pi/.pi/agent/extensions/subagent-pane-reminder.ts
@@ -0,0 +1,114 @@
+/**
+ * Subagent pane lifecycle reminder.
+ *
+ * When the parent agent dispatches to a `pane: true` agent via `subagent`,
+ * this extension floats a cleanup reminder to the parent only:
+ *
+ * - immediately: appended to the `subagent` tool result of that call;
+ * - persistently: a one-line system-prompt note on later turns until the
+ * parent calls `stop_subagent` for that agent (or the session ends).
+ *
+ * Nothing is injected for bg agents (`pane: false`), for `delegate_subagent`
+ * (pane targets are rejected there anyway), for `/agents` commands, or in
+ * sessions that never spawn a pane agent. Other sessions and workflows stay
+ * untouched.
+ */
+
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { homedir } from "node:os";
+
+interface SubagentInput {
+ agent?: unknown;
+ tasks?: Array<{ agent?: unknown }>;
+ chain?: Array<{ agent?: unknown }>;
+}
+
+const USER_AGENT_DIRS: string[] = [
+ path.join(homedir(), ".pi", "agent", "agents"),
+ path.join(homedir(), ".claude", "agents"),
+];
+
+function projectAgentDirs(cwd: string): string[] {
+ const dirs: string[] = [];
+ let current = path.resolve(cwd);
+ for (;;) {
+ dirs.push(path.join(current, ".pi", "agents"), path.join(current, ".claude", "agents"));
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+ return dirs;
+}
+
+function hasPaneFrontmatter(text: string): boolean {
+ const block = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ return block ? /^\s*pane\s*:\s*(?:true|yes|on)\s*$/im.test(block[1]) : false;
+}
+
+function isPaneAgent(name: string, cwd: string): boolean {
+ for (const dir of [...USER_AGENT_DIRS, ...projectAgentDirs(cwd)]) {
+ for (const ext of [".md", ".markdown"]) {
+ try {
+ if (hasPaneFrontmatter(fs.readFileSync(path.join(dir, `${name}${ext}`), "utf8"))) return true;
+ } catch {
+ // not present in this directory
+ }
+ }
+ }
+ return false;
+}
+
+function agentNamesFrom(input: SubagentInput): string[] {
+ const names = new Set();
+ const add = (value: unknown) => {
+ if (typeof value === "string" && value.trim()) names.add(value.trim());
+ };
+ add(input.agent);
+ for (const item of input.tasks ?? []) add(item.agent);
+ for (const item of input.chain ?? []) add(item.agent);
+ return [...names];
+}
+
+function reminderText(names: string[]): string {
+ const quoted = names.map((name) => `\`${name}\``).join(", ");
+ return (
+ `\n\nPane cleanup: you spawned persistent pane agent(s) ${quoted}. ` +
+ `After you collect the result, call \`stop_subagent\` with the agent name to kill its tmux pane. ` +
+ `Skip only if you plan a follow-up (steer or another subagent call to the same agent); stop it at the end of the workflow. ` +
+ `Stopping preserves the session file, so a later \`subagent\` call resumes the agent's memory.`
+ );
+}
+
+export default function (pi: ExtensionAPI) {
+ // Agent names whose panes are still alive this session.
+ const pending = new Set();
+
+ pi.on("tool_call", (event) => {
+ if (event.toolName === "stop_subagent") {
+ const agent = (event.input as { agent?: unknown } | undefined)?.agent;
+ if (typeof agent === "string") pending.delete(agent);
+ }
+ });
+
+ pi.on("tool_result", (event, ctx) => {
+ if (event.toolName !== "subagent" || event.isError) return;
+ const input = (event.input as SubagentInput | undefined) ?? {};
+ const paneAgents = agentNamesFrom(input).filter((name) => isPaneAgent(name, ctx.cwd));
+ if (paneAgents.length === 0) return;
+ for (const name of paneAgents) pending.add(name);
+ return {
+ content: [...(event.content ?? []), { type: "text" as const, text: reminderText(paneAgents) }],
+ };
+ });
+
+ pi.on("before_agent_start", (event) => {
+ if (pending.size === 0) return;
+ const note =
+ "\n\nPane cleanup (pending): " +
+ `[${[...pending].join(", ")}]. ` +
+ "After collecting their results, call stop_subagent for each (unless a follow-up is planned).";
+ return { systemPrompt: event.systemPrompt + note };
+ });
+}