peon is done
This commit is contained in:
@@ -6,14 +6,14 @@
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
|
||||
import { sendNotification } from "./notify.js";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
if (event.reason === "new") {
|
||||
sendNotification("Clear session confirmation");
|
||||
// Emit event for sound extensions (before hasUI check)
|
||||
pi.events.emit("peon:input_required", { source: "confirm-destructive", action: "clear-session" });
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Clear session?",
|
||||
"This will delete all messages in the current session.",
|
||||
@@ -33,7 +33,10 @@ export default function (pi: ExtensionAPI) {
|
||||
);
|
||||
|
||||
if (hasUnsavedWork) {
|
||||
sendNotification("Switch session confirmation");
|
||||
// Emit event for sound extensions (before hasUI check)
|
||||
pi.events.emit("peon:input_required", { source: "confirm-destructive", action: "switch-session" });
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Switch session?",
|
||||
"You have messages in the current session. Switch anyway?",
|
||||
@@ -47,9 +50,10 @@ export default function (pi: ExtensionAPI) {
|
||||
});
|
||||
|
||||
pi.on("session_before_fork", async (event, ctx) => {
|
||||
// Emit event for sound extensions (before hasUI check)
|
||||
pi.events.emit("peon:input_required", { source: "confirm-destructive", action: "fork-session" });
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
sendNotification("Fork session confirmation");
|
||||
const choice = await ctx.ui.select(`Fork from entry ${event.entryId.slice(0, 8)}?`, [
|
||||
"Yes, create fork",
|
||||
"No, stay in current session",
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* 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 <pack> - Switch to a different pack
|
||||
* /peon volume <0-100> - Set master volume
|
||||
* /peon mute - Toggle global mute
|
||||
* /peon test <category> - 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<string, CategoryConfig>;
|
||||
category_aliases?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PeonConfig {
|
||||
activePack: string;
|
||||
volume: number; // 0.0 to 1.0
|
||||
muted: boolean;
|
||||
enabledCategories: Record<Category, boolean>;
|
||||
}
|
||||
|
||||
// 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<Category, string> = new Map();
|
||||
let manifestCache: Map<string, OpenPeonManifest> = 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<Category, { title: string; message: string } | null> = {
|
||||
"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 <pack-name>", "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 <pack>|volume <0-100>|mute|toggle <category>|test <category>]",
|
||||
"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");
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { sendNotification } from "./notify.js";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
||||
@@ -18,12 +17,14 @@ export default function (pi: ExtensionAPI) {
|
||||
const isDangerous = dangerousPatterns.some((p) => p.test(command));
|
||||
|
||||
if (isDangerous) {
|
||||
// Emit event for sound extensions (play sound regardless of UI mode)
|
||||
pi.events.emit("peon:input_required", { source: "permission-gate", action: "dangerous-command", command });
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
// In non-interactive mode, block by default
|
||||
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
|
||||
}
|
||||
|
||||
sendNotification("Destructive command pending");
|
||||
const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);
|
||||
|
||||
if (choice !== "Yes") {
|
||||
|
||||
Reference in New Issue
Block a user