// ABOUTME: Soundcn Extension — Browser-based sound viewer with Pi lifecycle hook notifications. // ABOUTME: /sounds command opens browser UI to browse, preview, and assign sounds from soundcn.xyz to Pi events. import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; import { readFileSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { execSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http"; import { outputLine } from "./lib/output-box.ts"; import { applyExtensionDefaults } from "./lib/themeMap.ts"; import { generateSoundsViewerHTML, type CatalogItem } from "./lib/sounds-viewer-html.ts"; import { loadConfig, saveConfig, getActiveAssignmentCount, getAssignedSoundNames, type SoundsConfig, type HookName, ALL_HOOKS, HOOK_DISPLAY_NAMES, } from "./lib/sounds-config.ts"; import { playInstalledSound, installSound, uninstallSound, isSoundInstalled, cleanupAllPlayback, } from "./lib/sounds-player.ts"; import { registerActiveViewer, clearActiveViewer, notifyViewerOpen } from "./lib/viewer-session.ts"; // ── Types ──────────────────────────────────────────────────────────── interface SoundsViewerResult { action: "applied" | "cancelled"; assignments?: Record; volume?: number; enabled?: boolean; } // ── Catalog Fetching ───────────────────────────────────────────────── let cachedCatalog: CatalogItem[] | null = null; async function fetchCatalog(): Promise { if (cachedCatalog) return cachedCatalog; const resp = await fetch( "https://raw.githubusercontent.com/ruizrica/soundcn/main/registry.json", ); if (!resp.ok) throw new Error(`Failed to fetch catalog: ${resp.status}`); const data = await resp.json(); const items: CatalogItem[] = (data.items || []) .filter((item: any) => item.type === "registry:block") .map((item: any) => ({ name: item.name, title: item.title || item.name, description: item.description || "", categories: item.categories || [], author: item.author, meta: item.meta, })); cachedCatalog = items; return items; } // ── HTTP Server ────────────────────────────────────────────────────── function startSoundsServer( catalog: CatalogItem[], config: SoundsConfig, ): Promise<{ port: number; server: Server; waitForResult: () => Promise }> { return new Promise((resolveSetup) => { let resolveResult: (result: SoundsViewerResult) => void; const resultPromise = new Promise((res) => { resolveResult = res; }); let lastHeartbeat = Date.now(); const heartbeatCheck = setInterval(() => { if (Date.now() - lastHeartbeat > 15_000) { clearInterval(heartbeatCheck); resolveResult!({ action: "cancelled" }); } }, 5_000); const server = createServer((req: IncomingMessage, res: ServerResponse) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } const url = new URL(req.url || "/", "http://localhost"); // Serve main HTML page if (req.method === "GET" && url.pathname === "/") { const port = (server.address() as any)?.port || 0; res.setHeader("Cache-Control", "no-store"); const html = generateSoundsViewerHTML({ catalog, config, port }); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(html); return; } // Serve logo if (req.method === "GET" && url.pathname === "/logo.png") { try { const logoPath = join(dirname(fileURLToPath(import.meta.url)), "assets", "agent-logo.png"); const logoData = readFileSync(logoPath); res.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600" }); res.end(logoData); } catch { res.writeHead(404); res.end(); } return; } // Heartbeat keep-alive if (req.method === "POST" && url.pathname === "/heartbeat") { lastHeartbeat = Date.now(); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); return; } // CORS proxy: fetch sound data from soundcn.xyz server-side if (req.method === "GET" && url.pathname.startsWith("/api/sound/")) { const name = decodeURIComponent(url.pathname.slice("/api/sound/".length)); if (!name || name.includes("/") || name.includes("..")) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid sound name" })); return; } (async () => { try { const upstream = await fetch(`https://soundcn.xyz/r/${encodeURIComponent(name)}.json`); if (!upstream.ok) { res.writeHead(upstream.status, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: `Upstream returned ${upstream.status}` })); return; } const body = await upstream.text(); res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "public, max-age=3600", }); res.end(body); } catch (err: any) { res.writeHead(502, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err?.message || "Proxy fetch failed" })); } })(); return; } // Handle result submission (apply/cancel) if (req.method === "POST" && url.pathname === "/result") { let body = ""; req.on("data", (chunk) => { body += chunk; }); req.on("end", () => { try { const data = JSON.parse(body); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); resolveResult!({ action: data.action || "cancelled", assignments: data.assignments, volume: data.volume, enabled: data.enabled, }); } catch { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid JSON" })); } }); return; } // Install sound (save base64 data to cache) if (req.method === "POST" && url.pathname === "/install") { let body = ""; req.on("data", (chunk) => { body += chunk; }); req.on("end", () => { try { const data = JSON.parse(body); if (data.name && data.dataUri) { installSound(data.name, data.dataUri); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); } else { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing name or dataUri" })); } } catch (err: any) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err?.message || "Install failed" })); } }); return; } // Uninstall sound (remove from cache) if (req.method === "POST" && url.pathname === "/uninstall") { let body = ""; req.on("data", (chunk) => { body += chunk; }); req.on("end", () => { try { const data = JSON.parse(body); if (data.name) { uninstallSound(data.name); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); } else { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing name" })); } } catch (err: any) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err?.message || "Uninstall failed" })); } }); return; } res.writeHead(404); res.end("Not found"); }); server.listen(0, "127.0.0.1", () => { const addr = server.address() as any; resolveSetup({ port: addr.port, server, waitForResult: () => resultPromise.finally(() => clearInterval(heartbeatCheck)), }); }); }); } function openBrowser(url: string): void { try { execSync(`open "${url}"`, { stdio: "ignore" }); } catch { try { execSync(`xdg-open "${url}"`, { stdio: "ignore" }); } catch { try { execSync(`start "${url}"`, { stdio: "ignore" }); } catch {} } } } // ── Extension ──────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { let activeServer: Server | null = null; let activeSession: any | null = null; let currentConfig: SoundsConfig = loadConfig(); function cleanupServer() { const server = activeServer; activeServer = null; if (server) { try { server.close(); } catch {} } if (activeSession) { clearActiveViewer(activeSession); activeSession = null; } } function updateStatus(ctx: ExtensionContext) { if (!ctx.hasUI) return; const count = getActiveAssignmentCount(currentConfig); if (!currentConfig.enabled) { ctx.ui.setStatus("sounds", "🔇 Sounds OFF"); } else if (count > 0) { ctx.ui.setStatus("sounds", `🔊 ${count} hook${count !== 1 ? "s" : ""}`); } else { ctx.ui.setStatus("sounds", "🔊 Sounds"); } } // ── Core viewer logic ──────────────────────────────────────────── async function runSoundsViewer(ctx: ExtensionContext): Promise { cleanupServer(); // Fetch catalog ctx.ui.notify("Loading sound catalog from soundcn.xyz...", "info"); let catalog: CatalogItem[]; try { catalog = await fetchCatalog(); } catch (err: any) { ctx.ui.notify(`Failed to fetch catalog: ${err.message}`, "error"); return { action: "cancelled" }; } ctx.ui.notify(`Loaded ${catalog.length} sounds. Opening browser...`, "info"); // Start server const { port, server, waitForResult } = await startSoundsServer(catalog, currentConfig); activeServer = server; const url = `http://127.0.0.1:${port}`; activeSession = { kind: "sounds" as const, title: "Sound Browser", url, server, onClose: () => { activeServer = null; activeSession = null; }, }; registerActiveViewer(activeSession); openBrowser(url); notifyViewerOpen(ctx, activeSession); try { const result = await waitForResult(); // Apply config if user clicked "Apply" if (result.action === "applied" && result.assignments) { currentConfig = { assignments: result.assignments as Partial>, volume: typeof result.volume === "number" ? result.volume : currentConfig.volume, enabled: typeof result.enabled === "boolean" ? result.enabled : currentConfig.enabled, }; saveConfig(currentConfig); updateStatus(ctx); } return result; } finally { cleanupServer(); } } // ── /sounds command ────────────────────────────────────────────── pi.registerCommand("sounds", { description: "Open the sound browser, or use: /sounds toggle | /sounds status", handler: async (args, ctx) => { if (!ctx.hasUI) { ctx.ui.notify("/sounds requires interactive mode", "error"); return; } const arg = args.trim().toLowerCase(); // /sounds toggle if (arg === "toggle") { currentConfig = { ...currentConfig, enabled: !currentConfig.enabled }; saveConfig(currentConfig); updateStatus(ctx); ctx.ui.notify( currentConfig.enabled ? "🔊 Sounds enabled" : "🔇 Sounds disabled", "info", ); return; } // /sounds status if (arg === "status") { const count = getActiveAssignmentCount(currentConfig); const lines: string[] = [ `Sounds: ${currentConfig.enabled ? "Enabled" : "Disabled"}`, `Volume: ${Math.round(currentConfig.volume * 100)}%`, `Hooks: ${count}/${ALL_HOOKS.length} assigned`, ]; for (const hook of ALL_HOOKS) { const sound = currentConfig.assignments[hook]; const label = HOOK_DISPLAY_NAMES[hook]; lines.push(` ${label}: ${sound || "(none)"}`); } ctx.ui.notify(lines.join("\n"), "info"); return; } // /sounds — open browser const result = await runSoundsViewer(ctx); if (result.action === "applied") { const count = getActiveAssignmentCount(currentConfig); ctx.ui.notify(`✓ Sound config applied — ${count} hook${count !== 1 ? "s" : ""} assigned`, "info"); } }, }); // ── show_sounds tool ───────────────────────────────────────────── pi.registerTool({ name: "show_sounds", label: "Show Sounds", description: "Open the sound browser to let the user browse, preview, and assign sounds from soundcn.xyz to Pi lifecycle hooks like task completion, agent start, tool calls, etc.", parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { if (!ctx.hasUI) { return { content: [{ type: "text" as const, text: "Sound browser requires interactive mode." }], }; } const result = await runSoundsViewer(ctx); if (result.action === "applied") { const count = getActiveAssignmentCount(currentConfig); const assigned = Object.entries(currentConfig.assignments) .map(([hook, sound]) => ` ${HOOK_DISPLAY_NAMES[hook as HookName]}: ${sound}`) .join("\n"); return { content: [{ type: "text" as const, text: `Sound config applied. ${count} hook${count !== 1 ? "s" : ""} assigned:\n${assigned || " (none)"}`, }], details: { action: "applied", config: currentConfig }, }; } return { content: [{ type: "text" as const, text: "Sound browser closed without applying changes." }], details: { action: "cancelled" }, }; }, renderCall(_args, theme) { const text = theme.fg("toolTitle", theme.bold("show_sounds ")) + theme.fg("dim", "Opening sound browser..."); return new Text(outputLine(theme, "accent", text), 0, 0); }, renderResult(result, _options, theme) { const details = result.details as any; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } if (details.action === "applied") { const count = getActiveAssignmentCount(details.config || {}); return new Text(outputLine(theme, "success", `Sound config applied — ${count} hooks`), 0, 0); } return new Text(outputLine(theme, "warning", "Sound browser closed"), 0, 0); }, }); // ── Lifecycle Hook Sound Playback ──────────────────────────────── function playHookSound(hookName: HookName): void { if (!currentConfig.enabled) return; const soundName = currentConfig.assignments[hookName]; if (!soundName) return; if (!isSoundInstalled(soundName)) return; // Fire and forget — don't block the hook playInstalledSound(soundName, currentConfig.volume).catch(() => {}); } pi.on("agent_end", async () => { playHookSound("agent_end"); }); pi.on("agent_start", async () => { playHookSound("agent_start"); }); pi.on("tool_execution_start", async () => { playHookSound("tool_execution_start"); }); pi.on("tool_execution_end", async () => { playHookSound("tool_execution_end"); }); pi.on("turn_start", async () => { playHookSound("turn_start"); }); pi.on("turn_end", async () => { playHookSound("turn_end"); }); pi.on("session_compact", async () => { playHookSound("session_compact"); }); // ── Session Lifecycle ──────────────────────────────────────────── pi.on("session_start", async (_event, ctx) => { applyExtensionDefaults(import.meta.url, ctx); currentConfig = loadConfig(); updateStatus(ctx); // Play session start sound if assigned playHookSound("session_start"); }); pi.on("session_shutdown", async () => { cleanupServer(); cleanupAllPlayback(); }); }