import type { AgentToolResult, ExtensionAPI, Theme, ToolRenderResultOptions, } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { type Static, Type } from "typebox"; import type { ProcessesDetails } from "../constants"; import type { ProcessManager } from "../manager"; import { formatRuntime, formatTimestamp, hasAnsi, sanitizeLine, truncateCmd, } from "../utils"; import { executeAction } from "./actions"; import { ToolBody, ToolCallHeader, ToolFooter } from "./tool-rendering"; const ProcessesParams = Type.Object({ action: Type.Union( [ Type.Literal("start"), Type.Literal("list"), Type.Literal("output"), Type.Literal("logs"), Type.Literal("kill"), Type.Literal("clear"), ], { description: "Action: start (run command), list (show all), output (get recent output), logs (get log file paths), kill (terminate or force-kill), clear (remove finished)", }, ), command: Type.Optional( Type.String({ description: "Command to run (required for start)" }), ), name: Type.Optional( Type.String({ description: "Friendly name for the process (required for start, e.g. 'backend-dev', 'test-runner')", }), ), id: Type.Optional( Type.String({ description: "Exact process ID or exact friendly name to match (required for output/kill/logs).", }), ), force: Type.Optional( Type.Boolean({ description: "Force-kill the process with SIGKILL for kill action. Use after a normal terminate times out, or when you need an immediate hard stop.", }), ), continueAfterStart: Type.Optional( Type.Boolean({ description: "For start only. Leave unset/false when the next step is to wait for process completion; process start will end this agent turn and the extension will resume you on exit. Set true only when you have immediate, specific, non-polling work to do after starting.", }), ), }); type ProcessesParamsType = Static; export function setupProcessesTools(pi: ExtensionAPI, manager: ProcessManager) { pi.registerTool({ name: "process", label: "Process", description: `Manage background processes. Actions: start, list, output, logs, kill, clear. - start requires 'name' and 'command' - output/logs/kill require 'id' (exact process ID or exact friendly name) - kill supports optional 'force=true' for SIGKILL - start supports optional 'continueAfterStart=true' only when there is immediate non-polling work to do Important behavior: - By default, 'start' ends the current agent turn. If the next step is waiting, call 'start' by itself and wait for the automatic process-end notification instead of calling 'list', 'output', or 'logs' repeatedly. - Set 'continueAfterStart=true' only when you have specific useful work to do immediately after starting the process; do not use it to poll. - This tool is event-driven: the agent is notified automatically when a process exits, fails, or is externally killed. - Tool-triggered kills never notify. - Use 'output' or 'logs' only on demand: when the user asks, when you need a one-off diagnostic snapshot, or when investigating a problem. Preferred pattern: start the process once, let the turn stop, and resume from the automatic notification instead of polling.`, promptSnippet: "Start and manage background processes without blocking the conversation; process start waits for notifications by default", promptGuidelines: [ "Use the process tool instead of bash for dev servers, watch mode, log tails, port-forwards, or commands that should keep running.", "After process start, do not call process list/output/logs just to wait. If the next step is waiting, call process start by itself; by default it ends the turn and the extension will resume the agent when the process exits.", "Set process continueAfterStart=true only when there is immediate, specific, non-polling work to do after start.", "Use process output or process logs only for a one-off inspection, explicit user request, or debugging.", ], parameters: ProcessesParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { return executeAction(params, manager, ctx); }, renderCall(args: ProcessesParamsType, theme: Theme) { const longArgs: Array<{ label?: string; value: string }> = []; const optionArgs: Array<{ label: string; value: string }> = []; let mainArg: string | undefined; if (args.action === "start") { if (args.name) { mainArg = `"${args.name}"`; } if (args.command) { if (!mainArg && args.command.length <= 60) { mainArg = args.command; } else if (args.command.length <= 60) { optionArgs.push({ label: "command", value: args.command }); } else { longArgs.push({ label: "command", value: args.command }); } } } if ( (args.action === "output" || args.action === "kill" || args.action === "logs") && args.id ) { mainArg = args.id; } if (args.action === "kill" && args.force) { optionArgs.push({ label: "force", value: "true" }); } if (args.action === "start" && args.continueAfterStart) { optionArgs.push({ label: "continueAfterStart", value: "true" }); } return new ToolCallHeader( { toolName: "Process", action: args.action, mainArg, optionArgs, longArgs, }, theme, ); }, renderResult( result: AgentToolResult, options: ToolRenderResultOptions, theme: Theme, ) { const { details } = result; if (!details) { const text = result.content[0]; return new Text( text?.type === "text" && text.text ? text.text : "No result", 0, 0, ); } const fields: Array< { label: string; value: string; showCollapsed?: boolean } | Text > = []; if (!details.success) { fields.push({ label: "Error", value: theme.fg("error", details.message), showCollapsed: true, }); } else if (details.action === "start" && details.process) { const process = details.process; fields.push({ label: "Status", value: theme.fg("success", "Started") + ` ${theme.fg("accent", `"${sanitizeLine(process.name)}"`)} (${process.id}, PID: ${process.pid})`, showCollapsed: true, }); fields.push({ label: "Started at", value: theme.fg("muted", formatTimestamp(process.startTime)), showCollapsed: true, }); } else if (details.action === "output" && details.output) { const lines: string[] = [theme.fg("muted", details.message)]; let hadAnsi = false; if (details.output.stdout.length > 0) { lines.push("", theme.fg("accent", "stdout:")); for (const line of details.output.stdout.slice(-20)) { if (!hadAnsi && hasAnsi(line)) hadAnsi = true; lines.push(sanitizeLine(line)); } if (details.output.stdout.length > 20) { lines.push( theme.fg( "muted", `... (${details.output.stdout.length - 20} more lines)`, ), ); } } if (details.output.stderr.length > 0) { lines.push("", theme.fg("warning", "stderr:")); for (const line of details.output.stderr.slice(-10)) { if (!hadAnsi && hasAnsi(line)) hadAnsi = true; lines.push(theme.fg("warning", sanitizeLine(line))); } if (details.output.stderr.length > 10) { lines.push( theme.fg( "muted", `... (${details.output.stderr.length - 10} more lines)`, ), ); } } if (hadAnsi) { lines.push( "", theme.fg("muted", "ANSI escape codes were stripped from output"), ); } fields.push(new Text(lines.join("\n"), 0, 0)); // Collapsed summary const previewSource = details.output.stdout.length > 0 ? details.output.stdout : details.output.stderr; const preview = previewSource .slice(-2) .map((l) => sanitizeLine(l)) .join("\n"); fields.push({ label: "Output", value: preview ? `${theme.fg("muted", preview)}` : theme.fg("muted", "(empty)"), showCollapsed: true, }); } else if ( details.action === "list" && details.processes && details.processes.length > 0 ) { const lines: string[] = [ theme.fg("success", `${details.processes.length} process(es):`), ]; for (const process of details.processes) { let status: string; switch (process.status) { case "running": status = theme.fg("accent", "running"); break; case "terminating": status = theme.fg("warning", "terminating"); break; case "terminate_timeout": status = theme.fg("error", "terminate_timeout"); break; case "killed": status = theme.fg("warning", "killed"); break; case "exited": status = process.success ? theme.fg("success", "exit(0)") : theme.fg("error", `exit(${process.exitCode ?? "?"})`); break; default: status = theme.fg("muted", process.status); } lines.push( ` ${process.id} ${theme.fg("accent", `"${sanitizeLine(process.name)}"`)}: ${truncateCmd(sanitizeLine(process.command))} [${status}] ${formatRuntime(process.startTime, process.endTime)}`, ); } fields.push(new Text(lines.join("\n"), 0, 0)); // Collapsed summary: first 3 processes const summary = details.processes .slice(0, 3) .map((p) => { const s = p.status === "running" ? theme.fg("accent", "running") : p.status === "exited" && p.success ? theme.fg("success", "exit(0)") : p.status === "exited" ? theme.fg("error", `exit(${p.exitCode ?? "?"})`) : theme.fg("muted", p.status); return `${theme.fg("accent", `"${sanitizeLine(p.name)}"`)} [${s}]`; }) .join(", "); const more = details.processes.length > 3 ? theme.fg("muted", ` +${details.processes.length - 3} more`) : ""; fields.push({ label: "Processes", value: summary + more, showCollapsed: true, }); } else if (details.action === "logs" && details.logFiles) { fields.push( new Text( [ theme.fg("success", "Log files:"), ` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`, ` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`, ` combined: ${theme.fg("accent", details.logFiles.combinedFile)}`, ].join("\n"), 0, 0, ), ); } else { fields.push({ label: "Result", value: details.message, showCollapsed: true, }); } const footerItems: Array<{ label: string; value: string; tone: "accent" | "success" | "error" | "warning" | "muted"; }> = []; if (!details.success) { footerItems.push({ label: "status", value: "error", tone: "error", }); } const footer = footerItems.length > 0 ? new ToolFooter(theme, { items: footerItems }) : undefined; return new ToolBody({ fields, footer }, options, theme); }, }); }