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 }; + }); +}