diff --git a/pi/files/agent/extensions/slowtool.ts b/pi/files/agent/extensions/slowtool.ts new file mode 100644 index 0000000..5aa9a5a --- /dev/null +++ b/pi/files/agent/extensions/slowtool.ts @@ -0,0 +1,243 @@ +/** + * Slow Tool Monitor Extension + * + * Notifies you when a tool command is taking too long, useful for detecting + * interactive commands (like git without --non-interactive, or commands waiting for stdin). + * + * Also integrates with Peon for sound notifications. + * + * Configuration: + * /slowtool:timeout - Set the timeout threshold (default: 30s) + * /slowtool:enable - Enable notifications + * /slowtool:disable - Disable notifications + * /slowtool:status - Show current settings + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; + +interface ToolTimeout { + toolCallId: string; + toolName: string; + command?: string; + args: unknown; + timeoutId: ReturnType; + startTime: number; + notified: boolean; +} + +// Configuration +let timeoutSeconds = 30; +let enabled = true; + +// Track running tools +const runningTools: Map = new Map(); + +// ============ HELPERS ============ + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} + +function getCommandPreview(args: unknown): string | undefined { + if (!args) return undefined; + const anyArgs = args as Record; + if (typeof anyArgs.command === "string") { + const cmd = anyArgs.command; + // Truncate long commands + if (cmd.length > 60) { + return cmd.slice(0, 60) + "..."; + } + return cmd; + } + return undefined; +} + +// ============ NOTIFICATIONS ============ + +function notifyTimeout(pi: ExtensionAPI, tool: ToolTimeout): void { + if (tool.notified) return; + tool.notified = true; + + const duration = Date.now() - tool.startTime; + + // Emit event for Peon integration and other listeners + pi.events.emit("slowtool:timeout", { + toolCallId: tool.toolCallId, + toolName: tool.toolName, + duration, + command: tool.command, + }); +} + +// ============ EVENT HANDLERS ============ + +export default function(pi: ExtensionAPI) { + // Register commands + pi.registerCommand("slowtool:timeout", { + description: "Set timeout threshold in seconds (default: 30)", + handler: async (args: string, ctx: ExtensionCommandContext) => { + if (!args.trim()) { + ctx.ui.notify(`Current timeout: ${timeoutSeconds}s`, "info"); + return; + } + const newTimeout = parseInt(args.trim(), 10); + if (isNaN(newTimeout) || newTimeout < 1) { + ctx.ui.notify("Usage: /slowtool:timeout (minimum 1)", "error"); + return; + } + timeoutSeconds = newTimeout; + ctx.ui.notify(`Timeout set to ${timeoutSeconds}s`, "info"); + }, + }); + + pi.registerCommand("slowtool:enable", { + description: "Enable slow tool notifications", + handler: async (_args: string, ctx: ExtensionCommandContext) => { + enabled = true; + ctx.ui.notify("Slow tool notifications enabled", "info"); + }, + }); + + pi.registerCommand("slowtool:disable", { + description: "Disable slow tool notifications", + handler: async (_args: string, ctx: ExtensionCommandContext) => { + enabled = false; + ctx.ui.notify("Slow tool notifications disabled", "info"); + }, + }); + + pi.registerCommand("slowtool:status", { + description: "Show current settings", + handler: async (_args: string, ctx: ExtensionCommandContext) => { + const status = enabled ? "enabled" : "disabled"; + ctx.ui.notify( + `Slow tool monitor: ${status}\nTimeout: ${timeoutSeconds}s`, + "info" + ); + }, + }); + + // Track tool execution start + pi.on("tool_execution_start", async (event, ctx) => { + if (!enabled) return; + + const toolCallId = event.toolCallId; + const toolName = event.toolName; + const command = getCommandPreview(event.args); + const startTime = Date.now(); + + // Check if this is an interactive command that likely needs input + // These are prime candidates for getting stuck + const interactivePatterns = [ + // Git interactive commands + /^git\s+(push|pull|fetch|rebase|merge|cherry-pick|restore|checkout)\b/i, + /^git\b.*-i\b/i, + /^git\b.*--interactive\b/i, + // SSH + /^ssh\b/i, + // Commands that wait for stdin + /^cat\s*>?\s*/i, + /^tee\b/i, + // Package managers that might prompt + /^(sudo\s+)?apt(?:\s+get)?\s+(install|upgrade|remove)/i, + /^(sudo\s+)?yum\s+(install|update|remove)/i, + /^(sudo\s+)?brew\s+(install|update)/i, + /^(sudo\s+)?pnpm\s+(install|add)/i, + /^(sudo\s+)?npm\s+(install|add)/i, + /^docker\s+(run|pull|build)/i, + // Password prompts + /^(sudo\s+)?passwd\b/i, + // Yes/no prompts + /\|\s*yes\b/i, + // Editor launches + /^(vim|nano|emacs|code\s+-w)\b/i, + // Less/more pager + /^\s*(less|more)\b/i, + ]; + + // If it's an interactive command, use a shorter timeout + const isLikelyInteractive = command && interactivePatterns.some(p => p.test(command)); + const effectiveTimeout = isLikelyInteractive ? Math.min(timeoutSeconds, 10) : timeoutSeconds; + + // Set up the timeout + const timeoutId = setTimeout(() => { + // Create a minimal context for notification + notifyTimeout(pi, runningTools.get(toolCallId)!); + }, effectiveTimeout * 1000); + + const toolTimeout: ToolTimeout = { + toolCallId, + toolName, + command, + args: event.args, + timeoutId, + startTime, + notified: false, + }; + + runningTools.set(toolCallId, toolTimeout); + }); + + // Handle tool execution updates (reset timeout on each update) + pi.on("tool_execution_update", async (event, ctx) => { + const tool = runningTools.get(event.toolCallId); + if (!tool) return; + + // If we've notified, don't reset - let it continue running + // But we could extend the timeout if there's activity + // For now, just track that there's progress + }); + + // Clean up when tool finishes + pi.on("tool_execution_end", async (event, ctx) => { + const tool = runningTools.get(event.toolCallId); + if (!tool) return; + + // Clear the timeout + clearTimeout(tool.timeoutId); + + // If we notified about a slow tool, send a completion notification + if (tool.notified) { + const duration = Date.now() - tool.startTime; + const toolName = event.toolName; + const preview = tool.command ? `\nCommand: ${tool.command}` : ""; + const resultText = event.isError ? "\n\n⚠️ Tool execution failed" : "\n\n✓ Tool completed"; + + ctx.ui.notify( + `${toolName} completed after ${formatDuration(duration)}${preview}${resultText}`, + event.isError ? "warning" : "info" + ); + + // Emit completion event for Peon integration + if (!event.isError) { + pi.events.emit("slowtool:completed", { + toolCallId: event.toolCallId, + toolName: event.toolName, + duration, + command: tool.command, + }); + } + } + + runningTools.delete(event.toolCallId); + }); + + // Handle the slowtool:timeout event - show notification + pi.events.on("slowtool:timeout", async (data) => { + const timeoutData = data as { toolCallId: string; toolName: string; duration: number; command?: string }; + // This is handled in tool_execution_end for context + // But we can also emit for Peon integration + pi.events.emit("peon:input_required", { + source: "slowtool", + action: "slow_command", + toolName: timeoutData.toolName, + command: timeoutData.command, + duration: timeoutData.duration, + }); + }); +}