/** * pi-agent-done-chime * * Plays a pleasant chime when the pi agent settles after finishing work and is * waiting for your input. The chime is suppressed when you abort a run yourself * (Esc) — you already know it stopped because you stopped it. * * Bundled sound: "Pleasing Bell" by Spring Spring (OpenGameArt, CC0 1.0 / * public domain), normalized to peak -6 dB / mean -21 dB so it is clearly * audible without being harsh. See CREDITS.md and the original at * https://opengameart.org/content/pleasing-bell-sound-effect * * Commands: * /chime Toggle the chime on/off * /chime on|off Set explicitly * /chime test Play the chime once * /chime path Print the path to the bundled sound file * * Config: ~/.pi/agent/pi-agent-done-chime.json ({ enabled: boolean }) * Stored outside the package dir so `pi update` / npm reinstalls don't wipe it. */ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { spawn } from "node:child_process"; import { dirname, join } from "node:path"; import { homedir, platform } from "node:os"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; type ChimeConfig = { enabled: boolean; }; const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url)); const SOUND_FILE = join(PACKAGE_DIR, "sounds", "chime.wav"); // Stable config path under the pi agent dir — survives package updates. const CONFIG_DIR = join(homedir(), ".pi", "agent"); const CONFIG_FILE = join(CONFIG_DIR, "pi-agent-done-chime.json"); const DEFAULT_CONFIG: ChimeConfig = { enabled: true, }; function loadConfig(): ChimeConfig { try { const parsed = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); return { enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled, }; } catch { return DEFAULT_CONFIG; } } function saveConfig(config: ChimeConfig) { try { mkdirSync(CONFIG_DIR, { recursive: true }); } catch { // Directory likely already exists; ignore. } writeFileSync(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); } /** * Play the chime. Fire-and-forget: spawns a detached process so it never blocks * the agent loop or holds the session open. macOS uses afplay; other platforms * fall back to paplay (Linux) / a no-op (unsupported). */ function playChime(): void { if (!existsSync(SOUND_FILE)) return; const os = platform(); if (os === "darwin") { const child = spawn("afplay", [SOUND_FILE], { detached: true, stdio: "ignore", }); child.on("error", () => undefined); child.unref(); return; } if (os === "linux") { const child = spawn("paplay", [SOUND_FILE], { detached: true, stdio: "ignore", }); child.on("error", () => undefined); child.unref(); return; } // Windows / others: not supported by this extension yet. } export default function piAgentDoneChime(pi: ExtensionAPI) { // Tracks whether the most recent low-level agent run ended due to a user // abort. `agent_end` fires once per run (and may fire several times before // `agent_settled` due to retries / auto-compaction); the last `agent_end` // before `agent_settled` is the one that matters. Reset on `agent_start` so // a fresh run can never inherit a stale "aborted" reading from a prior cycle. let lastRunAborted = false; pi.registerCommand("chime", { description: "Toggle, test, or inspect the agent-settled chime sound", handler: async (args, ctx) => { const sub = (args ?? "").trim().toLowerCase(); if (sub === "test" || sub === "play") { playChime(); ctx.ui.notify("Chime test played", "info"); return; } if (sub === "path") { ctx.ui.notify(SOUND_FILE, "info"); return; } if (sub === "on" || sub === "off") { const config = loadConfig(); config.enabled = sub === "on"; saveConfig(config); ctx.ui.notify(`Chime ${sub}`, "info"); return; } // Default: toggle. const config = loadConfig(); config.enabled = !config.enabled; saveConfig(config); ctx.ui.notify(`Chime ${config.enabled ? "on" : "off"}`, "info"); }, }); pi.on("agent_start", async () => { lastRunAborted = false; }); pi.on("agent_end", async (event) => { // Find the last assistant message in this run. When the user aborts // (Esc), pi finalizes the run with an assistant message whose // stopReason is "aborted" — see pi-agent-core's handleRunFailure. for (let i = event.messages.length - 1; i >= 0; i--) { const message = event.messages[i] as { role?: string; stopReason?: string; }; if (message.role === "assistant") { lastRunAborted = message.stopReason === "aborted"; return; } } }); // agent_settled fires when the agent is truly done and waiting for input — // after all retries, auto-compaction, and queued follow-ups have drained. // Suppress the chime when the last run was aborted by the user: they // already know the agent stopped, because they stopped it. pi.on("agent_settled", async () => { const config = loadConfig(); if (!config.enabled) return; if (lastRunAborted) return; playChime(); }); }