502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
// 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<string, string>;
|
|
volume?: number;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
// ── Catalog Fetching ─────────────────────────────────────────────────
|
|
|
|
let cachedCatalog: CatalogItem[] | null = null;
|
|
|
|
async function fetchCatalog(): Promise<CatalogItem[]> {
|
|
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<SoundsViewerResult> }> {
|
|
return new Promise((resolveSetup) => {
|
|
let resolveResult: (result: SoundsViewerResult) => void;
|
|
const resultPromise = new Promise<SoundsViewerResult>((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<SoundsViewerResult> {
|
|
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<Record<HookName, string>>,
|
|
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();
|
|
});
|
|
}
|