/** * Peon Extension - CESP (Coding Event Sound Pack) Integration for Pi * * Announces Pi events with customizable sound packs following the CESP standard. * Default pack: Warcraft Orc Peon * * Usage: * /peon status - Show current pack and settings * /peon list - List installed packs * /peon set - Switch to a different pack * /peon volume <0-100> - Set master volume * /peon mute - Toggle global mute * /peon test - Test a sound category * * Categories: session.start, task.acknowledge, task.complete, task.error, * input.required, resource.limit */ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import * as fs from "node:fs"; import * as path from "node:path"; import { exec, execFile } from "node:child_process"; import { promisify } from "node:util"; const execAsync = promisify(exec); // ============ CONFIGURATION ============ const PACKS_DIR = path.join(process.env.HOME || "~", ".config/openpeon/packs"); const CONFIG_FILE = path.join(process.env.HOME || "~", ".config/openpeon/config.json"); const DEFAULT_PACK = "peon"; const DEBOUNCE_MS = 500; const REMOTE_HOST = "linux-pc"; // SSH host for remote notifications // ======================================= // ============ SSH DETECTION ============ function isSSH(): boolean { if ( process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY ) { return true; } // Check for sshd-session process (works in tmux/zellij) try { const { execSync } = require("child_process"); const result = execSync("pgrep -u $USER -f sshd-session 2>/dev/null", { encoding: "utf-8", timeout: 1000, }); return result.trim().length > 0; } catch { return false; } } // ======================================= // CESP Core Categories const CORE_CATEGORIES = [ "session.start", "task.acknowledge", "task.complete", "task.error", "input.required", "resource.limit", ] as const; type Category = typeof CORE_CATEGORIES[number]; interface Sound { file: string; label: string; sha256?: string; } interface CategoryConfig { sounds: Sound[]; } interface OpenPeonManifest { cesp_version: string; name: string; display_name: string; version: string; categories: Record; category_aliases?: Record; } interface PeonConfig { activePack: string; volume: number; // 0.0 to 1.0 muted: boolean; enabledCategories: Record; } // Default config const DEFAULT_CONFIG: PeonConfig = { activePack: DEFAULT_PACK, volume: 0.7, muted: false, enabledCategories: { "session.start": true, "task.acknowledge": true, "task.complete": true, "task.error": true, "input.required": true, "resource.limit": true, }, }; // State let config: PeonConfig = { ...DEFAULT_CONFIG }; let lastPlayed: number = 0; // ============ CONFIG PERSISTENCE ============ function loadConfig(): PeonConfig { try { if (fs.existsSync(CONFIG_FILE)) { const content = fs.readFileSync(CONFIG_FILE, "utf-8"); const saved = JSON.parse(content); return { ...DEFAULT_CONFIG, ...saved }; } } catch { // Fall back to defaults } return { ...DEFAULT_CONFIG }; } function saveConfig(): void { try { const dir = path.dirname(CONFIG_FILE); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); } catch (err) { console.error("[peon] Failed to save config:", err); } } let lastSoundPerCategory: Map = new Map(); let manifestCache: Map = new Map(); // ============ AUDIO PLAYBACK ============ function playSoundLocal(soundPath: string, volume: number): void { if (volume <= 0) return; const platform = process.platform; if (platform === "darwin") { // macOS const vol = volume.toFixed(2); exec(`nohup afplay -v ${vol} "${soundPath}" >/dev/null 2>&1 &`); } else if (platform === "linux") { // Linux - try PipeWire first, then fall back execFile("pw-play", ["--volume=" + volume, soundPath], (err) => { if (err) { // Fallback to paplay (PulseAudio) const paVol = Math.round(volume * 65536); execFile("paplay", ["--volume=" + paVol, soundPath], (err2) => { if (err2) { // Final fallback to aplay (ALSA) execFile("aplay", [soundPath]); } }); } }); } } function playSoundRemote(soundPath: string, volume: number): void { // Play sound on remote host via SSH const vol = Math.round(volume * 100); exec( `ssh -o ConnectTimeout=2 -o BatchMode=yes ${REMOTE_HOST} "pw-play --volume=${vol} '${soundPath}'" 2>/dev/null || true`, { timeout: 5000 } ); } function playSound(soundPath: string, volume: number): void { // Always play locally playSoundLocal(soundPath, volume); // If on SSH, also play remotely if (isSSH()) { playSoundRemote(soundPath, volume); } } function sendNotification(title: string, message: string): void { if (process.platform === "linux") { exec( `notify-send -i ~/.pi/agent/extensions/assets/pi-logo.svg '${title}' '${message}' 2>/dev/null || true`, { timeout: 5000 } ); } } // ============ PACK MANAGEMENT ============ function getPackPath(packName: string): string { return path.join(PACKS_DIR, packName); } function loadManifest(packName: string): OpenPeonManifest | null { if (manifestCache.has(packName)) { return manifestCache.get(packName)!; } const manifestPath = path.join(getPackPath(packName), "openpeon.json"); if (!fs.existsSync(manifestPath)) { return null; } try { const content = fs.readFileSync(manifestPath, "utf-8"); const manifest = JSON.parse(content) as OpenPeonManifest; manifestCache.set(packName, manifest); return manifest; } catch { return null; } } function getInstalledPacks(): string[] { if (!fs.existsSync(PACKS_DIR)) { return []; } return fs.readdirSync(PACKS_DIR).filter((name) => { const packPath = path.join(PACKS_DIR, name); const manifestPath = path.join(packPath, "openpeon.json"); return fs.statSync(packPath).isDirectory() && fs.existsSync(manifestPath); }); } function resolveCategory(manifest: OpenPeonManifest, category: Category): CategoryConfig | null { // Direct lookup if (manifest.categories[category]) { return manifest.categories[category]; } // Check aliases if (manifest.category_aliases) { const aliased = manifest.category_aliases[category]; if (aliased && manifest.categories[aliased]) { return manifest.categories[aliased]; } } return null; } function pickSound(categoryConfig: CategoryConfig, category: Category): Sound | null { const sounds = categoryConfig.sounds; if (sounds.length === 0) return null; // No-repeat: exclude last played sound if there are alternatives const lastSound = lastSoundPerCategory.get(category); let candidates = sounds; if (lastSound && sounds.length > 1) { candidates = sounds.filter((s) => s.file !== lastSound); } // Random selection const sound = candidates[Math.floor(Math.random() * candidates.length)]; lastSoundPerCategory.set(category, sound.file); return sound; } // ============ SOUND PLAYBACK ============ function play(category: Category): void { if (config.muted) return; if (!config.enabledCategories[category]) return; // Global debounce check - never play two sounds at once const now = Date.now(); if (now - lastPlayed < DEBOUNCE_MS) { return; } lastPlayed = now; // Load manifest const manifest = loadManifest(config.activePack); if (!manifest) { console.error(`[peon] Pack not found: ${config.activePack}`); return; } // Resolve category const categoryConfig = resolveCategory(manifest, category); if (!categoryConfig) { // Silently skip if pack doesn't have this category return; } // Pick and play sound const sound = pickSound(categoryConfig, category); if (!sound) return; const soundPath = path.join(getPackPath(config.activePack), sound.file); if (!fs.existsSync(soundPath)) { // Try with sounds/ prefix if no slash in path if (!sound.file.includes("/")) { const altPath = path.join(getPackPath(config.activePack), "sounds", sound.file); if (fs.existsSync(altPath)) { playSound(altPath, config.volume); return; } } console.error(`[peon] Sound file not found: ${soundPath}`); return; } playSound(soundPath, config.volume); // Send desktop notification for important events const notificationMessages: Record = { "session.start": null, // Too noisy on startup "task.acknowledge": null, // Too noisy "task.complete": { title: "Pi", message: "Task complete" }, "task.error": { title: "Pi", message: "Task failed" }, "input.required": { title: "Pi", message: "Input required" }, "resource.limit": { title: "Pi", message: "Rate limited" }, }; const notification = notificationMessages[category]; if (notification) { sendNotification(notification.title, notification.message); } } // ============ COMMANDS ============ function registerCommands(pi: ExtensionAPI) { pi.registerCommand("peon", { description: "Manage sound packs and settings", handler: async (args: string, ctx: ExtensionContext) => { const parts = args.trim().split(/\s+/); const cmd = parts[0] || "status"; switch (cmd) { case "status": { const manifest = loadManifest(config.activePack); const packDisplay = manifest?.display_name || config.activePack; const lines = [ `Active pack: ${packDisplay}`, `Volume: ${Math.round(config.volume * 100)}%`, `Muted: ${config.muted}`, "Enabled categories:", ]; for (const cat of CORE_CATEGORIES) { const enabled = config.enabledCategories[cat] ? "✓" : "✗"; lines.push(` ${enabled} ${cat}`); } ctx.ui.notify(lines.join("\n"), "info"); break; } case "list": { const packs = getInstalledPacks(); if (packs.length === 0) { ctx.ui.notify("No packs installed. Add packs to ~/.config/openpeon/packs/", "warning"); } else { const lines = ["Installed packs:"]; for (const pack of packs) { const m = loadManifest(pack); const marker = pack === config.activePack ? "→ " : " "; lines.push(`${marker}${m?.display_name || pack} (${pack})`); } ctx.ui.notify(lines.join("\n"), "info"); } break; } case "set": { const packName = parts[1]; if (!packName) { ctx.ui.notify("Usage: /peon set ", "error"); return; } const packs = getInstalledPacks(); if (!packs.includes(packName)) { ctx.ui.notify(`Pack '${packName}' not found. Run /peon list to see installed packs.`, "error"); return; } config.activePack = packName; manifestCache.delete(packName); // Clear cache saveConfig(); ctx.ui.notify(`Switched to pack: ${packName}`, "info"); break; } case "volume": { const volStr = parts[1]; if (!volStr) { ctx.ui.notify(`Current volume: ${Math.round(config.volume * 100)}%`, "info"); return; } const vol = parseInt(volStr, 10); if (isNaN(vol) || vol < 0 || vol > 100) { ctx.ui.notify("Volume must be 0-100", "error"); return; } config.volume = vol / 100; saveConfig(); ctx.ui.notify(`Volume set to ${vol}%`, "info"); break; } case "mute": { config.muted = !config.muted; saveConfig(); ctx.ui.notify(config.muted ? "Muted" : "Unmuted", "info"); break; } case "toggle": { const cat = parts[1] as Category; if (!CORE_CATEGORIES.includes(cat)) { ctx.ui.notify(`Unknown category: ${cat}\nAvailable: ${CORE_CATEGORIES.join(", ")}`, "error"); return; } config.enabledCategories[cat] = !config.enabledCategories[cat]; saveConfig(); ctx.ui.notify(`${cat}: ${config.enabledCategories[cat] ? "enabled" : "disabled"}`, "info"); break; } case "test": { const cat = parts[1] as Category; if (!CORE_CATEGORIES.includes(cat)) { ctx.ui.notify(`Unknown category: ${cat}\nAvailable: ${CORE_CATEGORIES.join(", ")}`, "error"); return; } ctx.ui.notify(`Testing ${cat}...`, "info"); play(cat); break; } default: { ctx.ui.notify( "Usage: /peon [status|list|set |volume <0-100>|mute|toggle |test ]", "info" ); } } }, }); } // ============ EVENT WIRING ============ const INTERACTIVE_TOOLS = new Set(["question", "questionnaire"]); export default function(pi: ExtensionAPI) { registerCommands(pi); // Session start pi.on("session_start", async (_event, ctx) => { // Load persisted config config = loadConfig(); if (!ctx.hasUI) return; play("session.start"); }); // Task acknowledge - when agent starts working pi.on("agent_start", async (_event, ctx) => { if (!ctx.hasUI) return; play("task.acknowledge"); }); // Task complete - when agent finishes pi.on("agent_end", async (_event, ctx) => { if (!ctx.hasUI) return; play("task.complete"); }); // Task error - when a tool errors pi.on("tool_result", async (event, ctx) => { if (!ctx.hasUI) return; if (event.isError) { play("task.error"); } }); // Input required - when question/questionnaire is called pi.on("tool_call", async (event, ctx) => { if (!ctx.hasUI) return; if (INTERACTIVE_TOOLS.has(event.toolName)) { play("input.required"); } }); // Resource limit - detect rate limiting in tool results pi.on("tool_result", async (event, ctx) => { if (!ctx.hasUI) return; // Check for rate limit indicators in error messages const firstContent = event.content?.[0]; const content = firstContent?.type === "text" ? firstContent.text : ""; if (event.isError && /rate.limit|quota|too.many.requests|429/i.test(content)) { play("resource.limit"); } }); // Listen for input_required events from other extensions (permission-gate, confirm-destructive, etc.) pi.events.on("peon:input_required", (_data) => { play("input.required"); }); }