/** * 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 * * Commands: * /peon:status - Show current pack and settings * /peon:list - List installed packs * /peon:set - Interactively select a sound pack * /peon:settings - Interactive settings editor * /peon:volume [0-100] - Set or adjust master volume * /peon:mute - Toggle global mute * /peon:toggle [category]- Toggle sound categories * /peon:test [category] - Test a sound category */ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; import { DynamicBorder } from "@mariozechner/pi-coding-agent"; import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui"; import * as fs from "node:fs"; import * as path from "node:path"; import { exec, execFile } from "node:child_process"; // ============ 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; // ======================================= // Get the remote host to SSH back to (the machine the user is physically on) function getRemoteHost(): string | null { const sshConn = process.env.SSH_CONNECTION || ""; const clientIP = sshConn.split(" ")[0]; if (clientIP && clientIP !== "::1" && clientIP !== "127.0.0.1") { return clientIP; } const sshClient = process.env.SSH_CLIENT || ""; const clientIP2 = sshClient.split(" ")[0]; if (clientIP2 && clientIP2 !== "::1" && clientIP2 !== "127.0.0.1") { return clientIP2; } return null; } // Map Mac paths to Linux paths for SSH playback function mapPathToRemote(soundPath: string): string { if (soundPath.startsWith("/Users/thomasglopes/")) { return soundPath.replace("/Users/thomasglopes/", "/home/thomasgl/"); } return soundPath; } // ============ SSH DETECTION ============ function isSSH(): boolean { if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) { return true; } 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; muted: boolean; enabledCategories: Record; } 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; let lastSoundPerCategory: Map = new Map(); let manifestCache: Map = new Map(); // ============ 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); } } // ============ AUDIO PLAYBACK ============ function playSoundLocal(soundPath: string, volume: number): void { if (volume <= 0) return; const platform = process.platform; if (platform === "darwin") { const vol = volume.toFixed(2); exec(`nohup afplay -v ${vol} "${soundPath}" >/dev/null 2>&1 &`); } else if (platform === "linux") { execFile("pw-play", ["--volume=" + volume, soundPath], (err) => { if (err) { const paVol = Math.round(volume * 65536); execFile("paplay", ["--volume=" + paVol, soundPath], (err2) => { if (err2) { execFile("aplay", [soundPath]); } }); } }); } } function playSound(soundPath: string, volume: number): void { const remoteHost = getRemoteHost(); if (remoteHost && isSSH() && process.platform === "darwin") { const remotePath = mapPathToRemote(soundPath); exec( `ssh -o ConnectTimeout=2 -o BatchMode=yes thomasgl@${remoteHost} "pw-play --volume=${volume} '${remotePath}' 2>/dev/null" 2>/dev/null || true`, { timeout: 5000 } ); return; } if (remoteHost && isSSH() && process.platform === "linux") { const vol = Math.round(volume * 100); exec( `ssh -o ConnectTimeout=2 -o BatchMode=yes ${remoteHost} "afplay -v ${vol / 100} '${soundPath}' 2>/dev/null" 2>/dev/null || true`, { timeout: 5000 } ); return; } playSoundLocal(soundPath, volume); } function sendNotification(title: string, message: string): void { const remoteHost = getRemoteHost(); if (remoteHost && isSSH() && process.platform === "darwin") { exec( `ssh -o ConnectTimeout=2 -o BatchMode=yes thomasgl@${remoteHost} "notify-send -i ~/.pi/agent/extensions/assets/pi-logo.svg '${title}' '${message}'" 2>/dev/null || true`, { timeout: 5000 } ); return; } if (remoteHost && isSSH() && process.platform === "linux") { exec( `ssh -o ConnectTimeout=2 -o BatchMode=yes ${remoteHost} "osascript -e 'display notification "${message}" with title "${title}"'" 2>/dev/null || true`, { timeout: 5000 } ); return; } if (process.platform === "linux") { exec( `notify-send -i ~/.pi/agent/extensions/assets/pi-logo.svg '${title}' '${message}' 2>/dev/null || true`, { timeout: 5000 } ); } else if (process.platform === "darwin") { exec( `osascript -e 'display notification "${message}" with title "${title}"' 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 getPackDisplayName(packName: string): string { const manifest = loadManifest(packName); return manifest?.display_name || packName; } function resolveCategory(manifest: OpenPeonManifest, category: Category): CategoryConfig | null { if (manifest.categories[category]) { return manifest.categories[category]; } 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 || sounds.length === 0) return null; const lastSound = lastSoundPerCategory.get(category); let candidates = sounds; if (lastSound && sounds.length > 1) { candidates = sounds.filter((s) => s.file !== lastSound); } 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; const now = Date.now(); if (now - lastPlayed < DEBOUNCE_MS) { return; } lastPlayed = now; const manifest = loadManifest(config.activePack); if (!manifest) { console.error(`[peon] Pack not found: ${config.activePack}`); return; } const categoryConfig = resolveCategory(manifest, category); if (!categoryConfig) { return; } const sound = pickSound(categoryConfig, category); if (!sound) return; const soundPath = path.join(getPackPath(config.activePack), sound.file); if (!fs.existsSync(soundPath)) { 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); const notificationMessages: Record = { "session.start": null, "task.acknowledge": null, "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) { // /peon:status - Show current pack and settings pi.registerCommand("peon:status", { description: "Show current sound pack and settings", handler: async (_args: string, ctx: ExtensionCommandContext) => { 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 ? "yes" : "no"}`, "", "Enabled categories:", ]; for (const cat of CORE_CATEGORIES) { const enabled = config.enabledCategories[cat] ? "✓" : "✗"; lines.push(` ${enabled} ${cat}`); } ctx.ui.notify(lines.join("\n"), "info"); }, }); // /peon:list - List installed packs pi.registerCommand("peon:list", { description: "List installed sound packs", handler: async (_args: string, ctx: ExtensionCommandContext) => { 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"); } }, }); // /peon:set - Interactively select a sound pack pi.registerCommand("peon:set", { description: "Select a sound pack interactively", handler: async (args: string, ctx: ExtensionCommandContext) => { await handleSetCommand(args.trim(), ctx); }, }); // /peon:settings - Interactive settings editor pi.registerCommand("peon:settings", { description: "Edit peon settings interactively", handler: async (_args: string, ctx: ExtensionCommandContext) => { await showSettingsMenu(ctx); }, }); // /peon:volume - Set or adjust volume pi.registerCommand("peon:volume", { description: "Set master volume (0-100) or adjust interactively", handler: async (args: string, ctx: ExtensionCommandContext) => { await handleVolumeCommand(args.trim(), ctx); }, }); // /peon:mute - Toggle mute pi.registerCommand("peon:mute", { description: "Toggle global mute", handler: async (_args: string, ctx: ExtensionCommandContext) => { config.muted = !config.muted; saveConfig(); ctx.ui.notify(config.muted ? "Muted" : "Unmuted", "info"); }, }); // /peon:toggle - Toggle sound categories pi.registerCommand("peon:toggle", { description: "Toggle sound categories on/off", handler: async (args: string, ctx: ExtensionCommandContext) => { await handleToggleCommand(args.trim(), ctx); }, }); // /peon:test - Test sound categories pi.registerCommand("peon:test", { description: "Test a sound category", handler: async (args: string, ctx: ExtensionCommandContext) => { await handleTestCommand(args.trim(), ctx); }, }); } // ============ COMMAND HANDLERS ============ async function handleSetCommand(packName: string, ctx: ExtensionCommandContext) { const packs = getInstalledPacks(); if (packs.length === 0) { ctx.ui.notify("No packs installed. Add packs to ~/.config/openpeon/packs/", "warning"); return; } // If pack name provided directly, use it if (packName) { if (!packs.includes(packName)) { ctx.ui.notify(`Pack '${packName}' not found.`, "error"); return; } config.activePack = packName; manifestCache.delete(packName); saveConfig(); ctx.ui.notify(`Switched to pack: ${getPackDisplayName(packName)}`, "info"); return; } // Interactive selection using SelectList const items: SelectItem[] = packs.map((pack) => ({ value: pack, label: getPackDisplayName(pack), description: pack === config.activePack ? "currently active" : undefined, })); const result = await ctx.ui.custom((tui, theme, _kb, done) => { const container = new Container(); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); container.addChild(new Text(theme.fg("accent", theme.bold("Select Sound Pack")), 1, 0)); const selectList = new SelectList(items, Math.min(items.length, 10), { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0)); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); return { render: (w) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); }, }; }); if (!result) return; config.activePack = result; manifestCache.delete(result); saveConfig(); ctx.ui.notify(`Switched to pack: ${getPackDisplayName(result)}`, "info"); } async function handleVolumeCommand(volStr: string, ctx: ExtensionCommandContext) { if (!volStr) { // Interactive volume selection const volumes = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; const currentVol = Math.round(config.volume * 100); const items: SelectItem[] = volumes.map((v) => ({ value: String(v), label: `${v}%`, description: v === currentVol ? "current" : undefined, })); const result = await ctx.ui.custom((tui, theme, _kb, done) => { const container = new Container(); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); container.addChild(new Text(theme.fg("accent", theme.bold("Select Volume")), 1, 0)); const selectList = new SelectList(items, Math.min(items.length, 10), { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0)); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); return { render: (w) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); }, }; }); if (!result) return; const vol = parseInt(result, 10); config.volume = vol / 100; saveConfig(); ctx.ui.notify(`Volume set to ${vol}%`, "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"); } async function showSettingsMenu(ctx: ExtensionCommandContext) { type SettingsAction = "volume" | "mute" | "pack" | "categories" | "test" | "done"; const result = await ctx.ui.custom((tui, theme, _kb, done) => { const items: SelectItem[] = [ { value: "volume", label: "Volume", description: `${Math.round(config.volume * 100)}%` }, { value: "mute", label: "Muted", description: config.muted ? "yes" : "no" }, { value: "pack", label: "Active Pack", description: getPackDisplayName(config.activePack) }, { value: "categories", label: "Toggle Categories..." }, { value: "test", label: "Test Sounds..." }, { value: "done", label: "Done" }, ]; const container = new Container(); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); container.addChild(new Text(theme.fg("accent", theme.bold("Peon Settings")), 1, 0)); const selectList = new SelectList(items, Math.min(items.length, 10), { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value as SettingsAction); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0)); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); return { render: (w) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); }, }; }); if (!result || result === "done") { if (result === "done") { ctx.ui.notify("Settings saved", "info"); } return; } // Handle submenu switch (result) { case "volume": await handleVolumeCommand("", ctx); break; case "mute": config.muted = !config.muted; saveConfig(); ctx.ui.notify(config.muted ? "Muted" : "Unmuted", "info"); break; case "pack": await handleSetCommand("", ctx); break; case "categories": await showCategoryToggle(ctx); break; case "test": await handleTestCommand("", ctx); break; } // Return to settings menu await showSettingsMenu(ctx); } async function showCategoryToggle(ctx: ExtensionCommandContext) { const result = await ctx.ui.custom((tui, theme, _kb, done) => { const items: SelectItem[] = [ ...CORE_CATEGORIES.map((cat) => ({ value: cat, label: `${config.enabledCategories[cat] ? "✓" : "✗"} ${cat}`, })), { value: "done", label: "← Back" }, ]; const container = new Container(); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); container.addChild(new Text(theme.fg("accent", theme.bold("Toggle Categories (Space to toggle)")), 1, 0)); const selectList = new SelectList(items, Math.min(items.length, 10), { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }); // Handle selection to toggle selectList.onSelect = (item) => { if (item.value === "done") { done("done"); return; } // Toggle the category const cat = item.value as Category; config.enabledCategories[cat] = !config.enabledCategories[cat]; saveConfig(); // Update the label item.label = `${config.enabledCategories[cat] ? "✓" : "✗"} ${cat}`; tui.requestRender(); }; selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter toggle • esc back"), 1, 0)); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); return { render: (w) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); }, }; }); if (result === null) return; if (result === "done") return; // If a category was selected, stay in the toggle menu await showCategoryToggle(ctx); } async function handleToggleCommand(cat: string, ctx: ExtensionCommandContext) { if (!cat) { await showCategoryToggle(ctx); return; } if (!CORE_CATEGORIES.includes(cat as Category)) { ctx.ui.notify(`Unknown category: ${cat}\nAvailable: ${CORE_CATEGORIES.join(", ")}`, "error"); return; } config.enabledCategories[cat as Category] = !config.enabledCategories[cat as Category]; saveConfig(); ctx.ui.notify(`${cat}: ${config.enabledCategories[cat as Category] ? "enabled" : "disabled"}`, "info"); } async function handleTestCommand(cat: string, ctx: ExtensionCommandContext) { if (!cat) { // Interactive test selection - stays open after testing await showTestMenu(ctx); return; } if (!CORE_CATEGORIES.includes(cat as Category)) { ctx.ui.notify(`Unknown category: ${cat}\nAvailable: ${CORE_CATEGORIES.join(", ")}`, "error"); return; } ctx.ui.notify(`Testing ${cat}...`, "info"); play(cat as Category); } async function showTestMenu(ctx: ExtensionCommandContext) { const result = await ctx.ui.custom((tui, theme, _kb, done) => { const items: SelectItem[] = [ ...CORE_CATEGORIES.map((category) => ({ value: category, label: category, })), { value: "done", label: "← Back" }, ]; const container = new Container(); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); container.addChild(new Text(theme.fg("accent", theme.bold("Test Sound Category")), 1, 0)); const selectList = new SelectList(items, Math.min(items.length, 10), { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }); // Play sound but stay open selectList.onSelect = (item) => { if (item.value === "done") { done("done"); return; } // Play the sound but don't close const category = item.value as Category; ctx.ui.notify(`Testing ${category}...`, "info"); play(category); }; selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter test • esc back"), 1, 0)); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); return { render: (w) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); }, }; }); if (result === null) return; if (result === "done") return; // If a category was tested, stay in the test menu await showTestMenu(ctx); } // ============ EVENT WIRING ============ const INTERACTIVE_TOOLS = new Set(["question", "questionnaire"]); export default function (pi: ExtensionAPI) { registerCommands(pi); pi.on("session_start", async (_event, ctx) => { config = loadConfig(); if (!ctx.hasUI) return; play("session.start"); }); pi.on("agent_start", async (_event, ctx) => { if (!ctx.hasUI) return; play("task.acknowledge"); }); pi.on("agent_end", async (_event, ctx) => { if (!ctx.hasUI) return; play("task.complete"); }); pi.on("tool_result", async (event, ctx) => { if (!ctx.hasUI) return; if (event.isError) { play("task.error"); } }); pi.on("tool_call", async (event, ctx) => { if (!ctx.hasUI) return; if (INTERACTIVE_TOOLS.has(event.toolName)) { play("input.required"); } }); pi.on("tool_result", async (event, ctx) => { if (!ctx.hasUI) return; 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"); } }); pi.events.on("peon:input_required", (_data) => { play("input.required"); }); }