add slowtool extension to monitor hanging commands
This commit is contained in:
@@ -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 <seconds> - 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<typeof setTimeout>;
|
||||
startTime: number;
|
||||
notified: boolean;
|
||||
}
|
||||
|
||||
// Configuration
|
||||
let timeoutSeconds = 30;
|
||||
let enabled = true;
|
||||
|
||||
// Track running tools
|
||||
const runningTools: Map<string, ToolTimeout> = 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<string, unknown>;
|
||||
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 <seconds> (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,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user