/** * pi-focus-bell — ring the terminal bell (BEL, \a) when pi finishes a turn and * is waiting on you, staying quiet when you're already looking at its pane. * * Why a bell and not a desktop notification (cf. the bundled notify.ts example)? * A raw BEL propagates through the whole stack: tmux (bell-action any, * visual-bell off) forwards it up the ssh session to the outer terminal, which * plays audio natively (e.g. Ghostty's `bell-features = audio`). So one BEL * covers the local-terminal, tmux, and remote-over-ssh cases with no per-hop * config, and works even when there's no desktop-notification daemon. * * Focus gating: inside tmux we only ring when pi's own pane is NOT the active * pane of the active window, so it doesn't ping while you're watching it. * Outside tmux we can't cheaply know focus, so we always ring. * * The BEL is written straight to the pane's slave tty (resolved via tmux), * bypassing pi's TUI render buffer, with /dev/tty as a fallback. * * Environment: * PI_BELL_ALWAYS if set, skip focus gating and always ring on turn end. * PI_BELL_DEBUG if set to a file path, append a line per event describing * what the extension decided (rang, gated, failed). */ import { execFileSync } from "node:child_process"; import { openSync, writeSync, closeSync, appendFileSync } from "node:fs"; const ALWAYS = !!process.env.PI_BELL_ALWAYS; const DEBUG_PATH = process.env.PI_BELL_DEBUG; function debug(msg: string): void { if (!DEBUG_PATH) return; try { appendFileSync(DEBUG_PATH, `[${new Date().toISOString()}] ${msg}\n`); } catch { // Debug logging is best-effort; never let it disrupt the agent. } } function tmux(args: string[]): string | undefined { try { return execFileSync("tmux", args, { encoding: "utf-8" }).trim(); } catch { return undefined; } } /** Active pane id of the active window, or undefined if not resolvable. */ function activePaneId(): string | undefined { const out = tmux([ "list-panes", "-s", "-F", "#{?pane_active,#{?window_active,#{pane_id},},}", ]); const id = out?.replace(/\s+/g, ""); return id ? id : undefined; } /** Write a single BEL to `path`, returning true on success. */ function writeBel(path: string): boolean { try { const fd = openSync(path, "a"); try { writeSync(fd, "\x07"); } finally { closeSync(fd); } return true; } catch { return false; } } function ringBell(): void { const pane = process.env.TMUX_PANE; // Not in tmux: ring on the controlling tty unconditionally. if (!process.env.TMUX || !pane) { debug("not in tmux; ringing /dev/tty"); writeBel("/dev/tty"); return; } if (!ALWAYS) { // Only ring when we can confirm our pane isn't the focused one. Fail closed: // if focus can't be resolved (tmux hiccup, stale $TMUX), stay silent rather // than risk ringing the pane you're already watching. const active = activePaneId(); if (!active) { debug(`focus unresolved; staying silent (pane=${pane})`); return; } if (pane === active) { debug(`pane ${pane} is focused; not ringing`); return; } debug(`pane ${pane} unfocused (active=${active}); ringing`); } else { debug(`PI_BELL_ALWAYS set; ringing pane ${pane} regardless of focus`); } const paneTty = tmux(["display-message", "-p", "-t", pane, "#{pane_tty}"]); if (!paneTty || !writeBel(paneTty)) { debug(`pane tty unwritable (${paneTty ?? "unresolved"}); falling back to /dev/tty`); writeBel("/dev/tty"); } else { debug(`BEL -> ${paneTty}`); } } export default function (pi: any) { pi.on("agent_end", async (_event: unknown, ctx: any) => { // Only meaningful when attached to a terminal. if (ctx?.mode && ctx.mode !== "tui") return; // NOTE: pi computes a `willRetry` flag on agent_end but does not currently // pass it to extension events, so we can't distinguish a "waiting for you" // completion from one pi is about to auto-retry after a transient error. // Worst case is one extra bell during a retry backoff. A clean fix needs pi // to expose `willRetry` on the extension AgentEndEvent. debug("agent_end"); ringBell(); }); }