diff --git a/agents/AGENTS.global.md b/agent/AGENTS.md similarity index 100% rename from agents/AGENTS.global.md rename to agent/AGENTS.md diff --git a/agent/extensions/notify-focus.sh b/agent/extensions/notify-focus.sh new file mode 100755 index 0000000..bce917c --- /dev/null +++ b/agent/extensions/notify-focus.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# notify-focus.sh — invoked by terminal-notifier when the Pi notification is +# clicked. Focuses Ghostty and switches the most-recently-active tmux client +# to the session/window/pane the agent was running in. +# +# Usage: notify-focus.sh +# +# terminal-notifier runs -execute scripts with a minimal PATH (/usr/bin:/bin), +# so homebrew's tmux isn't found unless we resolve it to an absolute path. +set -u + +TMUX_BIN="" +for _p in /opt/homebrew/bin/tmux /usr/local/bin/tmux; do + [ -x "$_p" ] && TMUX_BIN="$_p" && break +done +: "${TMUX_BIN:=$(command -v tmux 2>/dev/null || true)}" + +SESSION="${1:-}" +WINDOW="${2:-}" +PANE="${3:-}" + +[ -n "$SESSION" ] && [ -n "$TMUX_BIN" ] || exit 0 + +open -a Ghostty 2>/dev/null || true + +# Switch the most-recently-active client (highest #{client_activity}) to the +# target session, then land on the window/pane. A pane id is globally unique +# and selecting it also switches to its containing window, so prefer it over +# a bare window target. +C=$("$TMUX_BIN" list-clients -F '#{client_activity} #{client_name}' 2>/dev/null \ + | sort -rn | head -1 | cut -d' ' -f2-) +"$TMUX_BIN" switch-client -c "$C" -t "$SESSION" 2>/dev/null || true + +if [ -n "$PANE" ]; then + "$TMUX_BIN" select-pane -t "$PANE" 2>/dev/null || true +elif [ -n "$WINDOW" ]; then + "$TMUX_BIN" select-window -t "$SESSION":"$WINDOW" 2>/dev/null || true +fi \ No newline at end of file diff --git a/agent/extensions/notify.ts b/agent/extensions/notify.ts new file mode 100644 index 0000000..5ae9043 --- /dev/null +++ b/agent/extensions/notify.ts @@ -0,0 +1,244 @@ +/** + * notify.ts — Desktop notifications for Pi (macOS / Ghostty). + * + * Posts a native macOS notification via `terminal-notifier` when the agent + * finishes a turn and is waiting for input. Works regardless of tmux + * session/window — terminal-notifier runs outside the terminal stream, so + * tmux's same-session passthrough scoping (tmux PR #3501) is irrelevant. + * + * Clicking the notification focuses Ghostty and switches the active tmux + * client to the session/window/pane the agent ran in (see notify-focus.sh). + * NOTE: terminal-notifier's click actions (-activate/-execute) are reported + * broken on macOS Ventura+ due to security changes — clicking may do nothing, + * in which case the notification still serves as a passive banner + sound. + * For guaranteed click-to-action, a signed app bundle with UNNotificationAction + * would be required. + * + * Requires `terminal-notifier` (brew install terminal-notifier) and a tmux + * session (relies on $TMUX_PANE). No fallback path. + * + * Suppression: the notification is skipped when you are already viewing the + * agent's surface — i.e. Ghostty is the frontmost app and the most-recently- + * active tmux client is attached to the agent's session on the agent's + * window. Any doubt (other app frontmost, different session, detached, etc.) + * errs toward notifying. + * + * Place at ~/.pi/agent/extensions/notify.ts (auto-discovered globally). + * Reload with /reload, or test with `pi -e ~/.pi/agent/extensions/notify.ts`. + */ + +import { execFileSync, spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const EXT_DIR = dirname(fileURLToPath(import.meta.url)); +const FOCUS_SCRIPT = join(EXT_DIR, "notify-focus.sh"); + +function findTerminalNotifier(): string | null { + const candidates = [ + "/opt/homebrew/bin/terminal-notifier", + "/usr/local/bin/terminal-notifier", + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + try { + const out = execFileSync("which", ["terminal-notifier"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return out || null; + } catch { + return null; + } +} + +const TERMINAL_NOTIFIER = findTerminalNotifier(); + +interface TmuxTarget { + session: string; + window: string; + pane: string; +} + +function currentTmuxTarget(): TmuxTarget | null { + const paneId = process.env.TMUX_PANE; + if (!paneId) return null; + try { + // Format fields tab-separated; #{pane_id} is unique across the server. + const out = execFileSync( + "tmux", + ["display-message", "-p", "-t", paneId, "-F", "#{session_name}\t#{window_index}\t#{pane_id}"], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }, + ).trim(); + const [session, window, pane] = out.split("\t"); + if (!session) return null; + return { session, window: window ?? "", pane: pane ?? "" }; + } catch { + return null; + } +} + +// ── Suppression: is the user already viewing the agent's surface? ────── + +function frontmostIsGhostty(): boolean { + try { + const name = execFileSync( + "osascript", + ["-e", 'tell application "System Events" to get name of first application process whose frontmost is true'], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }, + ).trim().toLowerCase(); + return name.includes("ghostty"); + } catch { + return false; // System Events blocked or unavailable — assume not viewing + } +} + +function userIsViewingAgent(target: TmuxTarget): boolean { + // Step 1: Ghostty must be the frontmost app. If the user is in another app, + // they are not viewing the agent — notify. + if (!frontmostIsGhostty()) return false; + + // Step 2: Find the most-recently-active attached tmux client (that's where + // the user effectively is). #{client_activity} is an epoch-second count. + let mrcSession: string | null = null; + try { + const out = execFileSync( + "tmux", + ["list-clients", "-F", "#{client_activity}\t#{client_session}"], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }, + ).trim(); + if (!out) return false; // no attached clients → user not viewing → notify + let best = -1; + for (const line of out.split("\n")) { + const [actStr, sess] = line.split("\t"); + const act = Number(actStr); + if (Number.isFinite(act) && act > best && sess) { + best = act; + mrcSession = sess; + } + } + } catch { + return false; // tmux query failed — assume not viewing → notify + } + + if (!mrcSession || mrcSession !== target.session) return false; // different session → notify + + // Step 3: The user is in the agent's session. Are they on the agent's + // window? Per-client window fields are blank in tmux 3.6b, so the session's + // active window is the reliable signal for what the attached client shows. + try { + const activeWindow = execFileSync( + "tmux", + ["display-message", "-p", "-t", target.session, "-F", "#{window_index}"], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }, + ).trim(); + return activeWindow === target.window; + } catch { + return false; // failed → assume not viewing → notify + } +} + +function sendNotification(title: string, body: string): void { + if (process.platform !== "darwin") return; + if (!TERMINAL_NOTIFIER) return; // requires terminal-notifier, no fallback + + const target = currentTmuxTarget(); + if (!target) return; // requires a tmux pane, no fallback + + if (userIsViewingAgent(target)) return; // already viewing the agent → skip + + const args = [ + "-title", title, + "-message", body, + "-sound", "default", + "-activate", "com.mitchellh.ghostty", + "-execute", `${FOCUS_SCRIPT} ${target.session} ${target.window} ${target.pane}`, + "-group", "pi-notify", + ]; + const proc = spawn(TERMINAL_NOTIFIER, args, { stdio: "ignore" }); + proc.unref(); + proc.on("error", () => { + /* terminal-notifier vanished at runtime — nothing else to try */ + }); +} + +// ── Run tracking ─────────────────────────────────────────────────────── +// Summarizes what happened during the agent run so the notification body +// is informative (e.g. "✅ Done — 2 turns, 5 tool calls (3 unique)"). + +interface RunStats { + turns: number; + toolCalls: number; + errors: number; + toolNames: Set; +} + +function freshStats(): RunStats { + return { turns: 0, toolCalls: 0, errors: 0, toolNames: new Set() }; +} + +function formatAgentEndMessage(stats: RunStats): string { + const emoji = stats.errors > 0 ? "❌" : "✅"; + const parts: string[] = []; + + if (stats.turns === 1) { + parts.push("1 turn"); + } else if (stats.turns > 1) { + parts.push(`${String(stats.turns)} turns`); + } + + if (stats.toolCalls > 0) { + const uniqueCount = stats.toolNames.size; + parts.push( + `${String(stats.toolCalls)} tool ${stats.toolCalls === 1 ? "call" : "calls"} (${String(uniqueCount)} unique)`, + ); + } + + if (stats.errors > 0) { + parts.push(`${String(stats.errors)} ${stats.errors === 1 ? "error" : "errors"}`); + } + + const summary = parts.length > 0 ? parts.join(", ") : "no tool calls"; + return `${emoji} Done — ${summary}`; +} + +// ── Extension factory ────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI): void { + const TITLE = "Pi"; + let stats = freshStats(); + + // Reset per prompt. + pi.on("agent_start", () => { + stats = freshStats(); + }); + + pi.on("turn_end", () => { + stats.turns++; + }); + + pi.on("tool_execution_end", (event) => { + stats.toolCalls++; + stats.toolNames.add(event.toolName); + if (event.isError) { + stats.errors++; + } + }); + + // Agent finished its turn and is waiting for input: notify. + pi.on("agent_end", () => { + sendNotification(TITLE, formatAgentEndMessage(stats)); + }); + + pi.registerCommand("notify", { + description: "Send a test macOS desktop notification.", + handler: (args) => { + const message = args.trim() || "Waiting for your input"; + sendNotification(TITLE, `🔔 ${message}`); + return Promise.resolve(); + }, + }); +} \ No newline at end of file diff --git a/setup.sh b/setup.sh index e0d088b..6a45e90 100755 --- a/setup.sh +++ b/setup.sh @@ -22,7 +22,9 @@ function link_configs { mappings["zsh/starship.toml"]="$HOME/.config/starship.toml" mappings["mise"]="$HOME/.config/mise" mappings["worktrunk/config.toml"]="$HOME/.config/worktrunk/config.toml" - mappings["agents/AGENTS.global.md"]="$HOME/.claude/CLAUDE.md" + mappings["agent/AGENTS.md"]="$HOME/.pi/agent/AGENTS.md" + mappings["agent/extensions/notify.ts"]="$HOME/.pi/agent/extensions/notify.ts" + mappings["agent/extensions/notify-focus.sh"]="$HOME/.pi/agent/extensions/notify-focus.sh" for key in "${!mappings[@]}"; do source="${key}"