diff --git a/entry b/entry index 4c4efee..68ba60d 100755 --- a/entry +++ b/entry @@ -117237,11 +117237,15 @@ var Effort = type.enumerated("mini", "auto", "max"); // mcp/bash.ts import { spawn } from "node:child_process"; +import { randomUUID as randomUUID2 } from "node:crypto"; +import { closeSync, openSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; var BashParams = type({ command: "string", description: "string", "timeout?": "number", - "working_directory?": "string" + "working_directory?": "string", + "background?": "boolean" }); var SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i]; function isSensitive(key) { @@ -117259,11 +117263,10 @@ function filterEnv(isPublicRepo) { } return filtered; } -function spawnSandboxed(command, options) { - const stdio = ["ignore", "pipe", "pipe"]; - const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true }; - const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo; - return useNamespaceIsolation ? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts) : spawn("bash", ["-c", command], spawnOpts); +function spawnSandboxed(params) { + const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true }; + const useNamespaceIsolation = process.env.CI === "true" && params.isPublicRepo; + return useNamespaceIsolation ? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", params.command], spawnOpts) : spawn("bash", ["-c", params.command], spawnOpts); } async function killProcessGroup(proc) { if (!proc.pid) return; @@ -117278,6 +117281,13 @@ async function killProcessGroup(proc) { } } } +function getTempDir() { + const tempDir = process.env.PULLFROG_TEMP_DIR; + if (!tempDir) { + throw new Error("PULLFROG_TEMP_DIR not set"); + } + return tempDir; +} function BashTool(ctx) { const isPublicRepo = !ctx.repo.repo.private; return tool({ @@ -117294,10 +117304,45 @@ Use this tool to: execute: execute(async (params) => { const timeout = Math.min(params.timeout ?? 12e4, 6e5); const cwd2 = params.working_directory ?? process.cwd(); - const proc = spawnSandboxed(params.command, { - env: filterEnv(isPublicRepo), + const env3 = filterEnv(isPublicRepo); + if (params.background) { + const tempDir = getTempDir(); + const handle = `bg-${randomUUID2().slice(0, 8)}`; + const outputPath = join(tempDir, `${handle}.log`); + const pidPath = join(tempDir, `${handle}.pid`); + const logFd = openSync(outputPath, "a"); + let proc2; + try { + proc2 = spawnSandboxed({ + command: params.command, + env: env3, + cwd: cwd2, + isPublicRepo, + stdio: ["ignore", logFd, logFd] + }); + } finally { + closeSync(logFd); + } + if (!proc2.pid) { + throw new Error("failed to start background process"); + } + proc2.unref(); + writeFileSync(pidPath, `${proc2.pid} +`); + ctx.toolState.backgroundProcesses.set(handle, { pid: proc2.pid, outputPath, pidPath }); + return { + handle, + outputPath, + pidPath, + message: `started background process ${handle} (pid ${proc2.pid})` + }; + } + const proc = spawnSandboxed({ + command: params.command, + env: env3, cwd: cwd2, - isPublicRepo + isPublicRepo, + stdio: ["ignore", "pipe", "pipe"] }); let stdout = "", stderr = "", timedOut = false, exited = false; proc.stdout?.on("data", (chunk) => { @@ -117334,10 +117379,43 @@ ${stderr}` : stderr : stdout; }) }); } +var KillBackgroundParams = type({ + handle: type.string.describe("The handle of the background process to kill (e.g., bg-a1b2c3d4)") +}); +function KillBackgroundTool(ctx) { + return tool({ + name: "kill_background", + description: `Kill a background process by its handle. Use this to stop dev servers or other long-running processes started with bash({ background: true }).`, + parameters: KillBackgroundParams, + execute: execute(async (params) => { + const proc = ctx.toolState.backgroundProcesses.get(params.handle); + if (!proc) { + return { + success: false, + message: `no background process with handle ${params.handle}` + }; + } + try { + process.kill(-proc.pid, "SIGTERM"); + } catch { + } + await new Promise((resolve2) => setTimeout(resolve2, 200)); + try { + process.kill(-proc.pid, "SIGKILL"); + } catch { + } + ctx.toolState.backgroundProcesses.delete(params.handle); + return { + success: true, + message: `killed background process ${params.handle} (pid ${proc.pid})` + }; + }) + }); +} // mcp/checkout.ts -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { writeFileSync as writeFileSync2 } from "node:fs"; +import { join as join2 } from "node:path"; // utils/shell.ts import { spawnSync } from "node:child_process"; @@ -117530,8 +117608,8 @@ ${diffPreview}`); "PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context" ); } - const diffPath = join(tempDir, `pr-${pull_number}.diff`); - writeFileSync(diffPath, diffContent); + const diffPath = join2(tempDir, `pr-${pull_number}.diff`); + writeFileSync2(diffPath, diffContent); log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`); return { success: true, @@ -117652,7 +117730,7 @@ function DebugShellCommandTool(_ctx) { // prep/installNodeDependencies.ts import { existsSync, readFileSync } from "node:fs"; -import { join as join2 } from "node:path"; +import { join as join3 } from "node:path"; // node_modules/.pnpm/@ark+util@0.53.0/node_modules/@ark/util/out/domain.js var domainDescriptions2 = { @@ -118239,7 +118317,7 @@ async function isCommandAvailable(command) { return result.exitCode === 0; } function getPackageManagerFromPackageJson() { - const packageJsonPath = join2(process.cwd(), "package.json"); + const packageJsonPath = join3(process.cwd(), "package.json"); try { const content = readFileSync(packageJsonPath, "utf-8"); const pkg = JSON.parse(content); @@ -118270,7 +118348,7 @@ async function installPackageManager(name, installSpec) { return result.stderr || `failed to install ${name}`; } if (name === "deno") { - const denoPath = join2(process.env.HOME || "", ".deno", "bin"); + const denoPath = join3(process.env.HOME || "", ".deno", "bin"); process.env.PATH = `${denoPath}:${process.env.PATH}`; } log.info(`\xBB installed ${name}`); @@ -118279,7 +118357,7 @@ async function installPackageManager(name, installSpec) { var installNodeDependencies = { name: "installNodeDependencies", shouldRun: () => { - const packageJsonPath = join2(process.cwd(), "package.json"); + const packageJsonPath = join3(process.cwd(), "package.json"); return existsSync(packageJsonPath); }, run: async () => { @@ -118347,7 +118425,7 @@ ${errorMessage}`] // prep/installPythonDependencies.ts import { existsSync as existsSync2 } from "node:fs"; -import { join as join3 } from "node:path"; +import { join as join4 } from "node:path"; var PYTHON_CONFIGS = [ { file: "requirements.txt", @@ -118419,11 +118497,11 @@ var installPythonDependencies = { return false; } const cwd2 = process.cwd(); - return PYTHON_CONFIGS.some((config4) => existsSync2(join3(cwd2, config4.file))); + return PYTHON_CONFIGS.some((config4) => existsSync2(join4(cwd2, config4.file))); }, run: async () => { const cwd2 = process.cwd(); - const config4 = PYTHON_CONFIGS.find((c) => existsSync2(join3(cwd2, c.file))); + const config4 = PYTHON_CONFIGS.find((c) => existsSync2(join4(cwd2, c.file))); if (!config4) { return { language: "python", @@ -119213,8 +119291,8 @@ function CreatePullRequestReviewTool(ctx) { } // mcp/reviewComments.ts -import { writeFileSync as writeFileSync2 } from "node:fs"; -import { join as join4 } from "node:path"; +import { writeFileSync as writeFileSync3 } from "node:fs"; +import { join as join5 } from "node:path"; var GetReviewComments = type({ pull_number: type.number.describe("The pull request number"), review_id: type.number.describe("The review ID to get comments for"), @@ -119281,8 +119359,8 @@ function GetReviewCommentsTool(ctx) { throw new Error("PULLFROG_TEMP_DIR not set"); } const filename = approved_by ? `review-${review_id}-approved-by-${approved_by}.txt` : `review-${review_id}-comments.txt`; - const commentsPath = join4(tempDir, filename); - writeFileSync2(commentsPath, content); + const commentsPath = join5(tempDir, filename); + writeFileSync3(commentsPath, content); log.debug(`wrote ${reviewComments.length} comments to ${commentsPath}`); return { review_id, @@ -119360,7 +119438,8 @@ function initToolState(ctx) { progressComment: { id: Number.isNaN(progressCommentId) ? null : progressCommentId, wasUpdated: false - } + }, + backgroundProcesses: /* @__PURE__ */ new Map() }; } async function findAvailablePort(startPort) { @@ -119387,6 +119466,24 @@ async function findAvailablePort(startPort) { } throw new Error(`Could not find available port starting from ${startPort}`); } +async function killBackgroundProcesses(toolState) { + const backgroundProcesses = toolState.backgroundProcesses; + if (backgroundProcesses.size === 0) return; + for (const proc of backgroundProcesses.values()) { + try { + process.kill(-proc.pid, "SIGTERM"); + } catch { + } + } + await new Promise((resolve2) => setTimeout(resolve2, 200)); + for (const proc of backgroundProcesses.values()) { + try { + process.kill(-proc.pid, "SIGKILL"); + } catch { + } + } + backgroundProcesses.clear(); +} async function startMcpHttpServer(ctx) { const server = new FastMCP({ name: ghPullfrogMcpName, @@ -119419,6 +119516,7 @@ async function startMcpHttpServer(ctx) { const bash = ctx.payload.bash ?? "enabled"; if (bash !== "disabled") { tools.push(BashTool(ctx)); + tools.push(KillBackgroundTool(ctx)); } tools.push(ReportProgressTool(ctx)); addTools(ctx, server, tools); @@ -119437,6 +119535,7 @@ async function startMcpHttpServer(ctx) { return { url: url4, [Symbol.asyncDispose]: async () => { + await killBackgroundProcesses(ctx.toolState); await server.stop(); } }; @@ -119593,7 +119692,7 @@ function computeModes() { var modes = computeModes(); // node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.2.7_zod@4.3.5/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs -import { join as join5 } from "path"; +import { join as join52 } from "path"; import { fileURLToPath as fileURLToPath2 } from "url"; import { setMaxListeners } from "events"; import { spawn as spawn3 } from "child_process"; @@ -119605,11 +119704,11 @@ import { homedir } from "os"; import { dirname, join as join22 } from "path"; import { cwd } from "process"; import { realpathSync as realpathSync2 } from "fs"; -import { randomUUID as randomUUID2 } from "crypto"; +import { randomUUID as randomUUID3 } from "crypto"; import { randomUUID as randomUUID22 } from "crypto"; import { appendFileSync as appendFileSync2, existsSync as existsSync22, mkdirSync as mkdirSync2 } from "fs"; import { join as join32 } from "path"; -import { randomUUID as randomUUID3 } from "crypto"; +import { randomUUID as randomUUID32 } from "crypto"; var __create3 = Object.create; var __getProtoOf3 = Object.getPrototypeOf; var __defProp3 = Object.defineProperty; @@ -126417,7 +126516,7 @@ function getInitialState() { tokenCounter: null, codeEditToolDecisionCounter: null, activeTimeCounter: null, - sessionId: randomUUID2(), + sessionId: randomUUID3(), loggerProvider: null, eventLogger: null, meterProvider: null, @@ -127729,7 +127828,7 @@ var Query = class { } const controlRequest = { type: "control_request", - request_id: randomUUID3(), + request_id: randomUUID32(), request: { subtype: "mcp_message", server_name: serverName, @@ -135989,8 +136088,8 @@ function query({ let pathToClaudeCodeExecutable = rest.pathToClaudeCodeExecutable; if (!pathToClaudeCodeExecutable) { const filename = fileURLToPath2(import.meta.url); - const dirname2 = join5(filename, ".."); - pathToClaudeCodeExecutable = join5(dirname2, "cli.js"); + const dirname2 = join52(filename, ".."); + pathToClaudeCodeExecutable = join52(dirname2, "cli.js"); } process.env.CLAUDE_AGENT_SDK_VERSION = "0.2.7"; const { @@ -136554,7 +136653,7 @@ var messageHandlers = { }; // agents/codex.ts -import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs"; +import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "node:fs"; import { join as join8 } from "node:path"; // node_modules/.pnpm/@openai+codex-sdk@0.80.0/node_modules/@openai/codex-sdk/dist/index.js @@ -136929,7 +137028,7 @@ url = "${ctx.mcpServerUrl}"`]; } const featuresSection = features.length > 0 ? `[features] ${features.join("\n")}` : ""; - writeFileSync3( + writeFileSync4( configPath, `# written by pullfrog ${featuresSection} @@ -137097,7 +137196,7 @@ var messageHandlers2 = { // agents/cursor.ts import { spawn as spawn5 } from "node:child_process"; -import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs"; +import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync5 } from "node:fs"; import { homedir as homedir2 } from "node:os"; import { join as join9 } from "node:path"; var cursorEffortModels = { @@ -137289,7 +137388,7 @@ function configureCursorMcpServers(ctx) { const mcpServers = { [ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl } }; - writeFileSync4(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8"); + writeFileSync5(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8"); log.info(`\xBB MCP config written to ${mcpConfigPath}`); } function configureCursorTools(ctx) { @@ -137314,12 +137413,12 @@ function configureCursorTools(ctx) { networkAccess: "allowlist" }; } - writeFileSync4(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8"); + writeFileSync5(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8"); log.info(`\xBB CLI config written to ${cliConfigPath}`, JSON.stringify(config4, null, 2)); } // agents/gemini.ts -import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs"; +import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync6 } from "node:fs"; import { homedir as homedir3 } from "node:os"; import { join as join10 } from "node:path"; var geminiEffortConfig = { @@ -137530,7 +137629,7 @@ function configureGeminiSettings(ctx) { // v0.3.0+ nested format ...exclude.length > 0 && { tools: { exclude } } }; - writeFileSync5(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); + writeFileSync6(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); log.info(`\xBB Gemini settings written to ${settingsPath}`); if (exclude.length > 0) { log.info(`\xBB excluded tools: ${exclude.join(", ")}`); @@ -137539,7 +137638,7 @@ function configureGeminiSettings(ctx) { } // agents/opencode.ts -import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs"; +import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync7 } from "node:fs"; import { join as join11 } from "node:path"; async function installOpencode() { return await installFromNpmTarball({ @@ -137686,7 +137785,7 @@ function configureOpenCode(ctx) { }; const configJson = JSON.stringify(config4, null, 2); try { - writeFileSync6(configPath, configJson, "utf-8"); + writeFileSync7(configPath, configJson, "utf-8"); } catch (error50) { log.error( `failed to write OpenCode config to ${configPath}: ${error50 instanceof Error ? error50.message : String(error50)}` @@ -137997,13 +138096,14 @@ function buildRuntimeContext(ctx) { return lines.join("\n"); } function getShellInstructions(bash) { + const backgroundInstructions = `For long-running processes (dev servers, watchers), use \`bash({ command, background: true })\` which returns a handle. Use \`${ghPullfrogMcpName}/kill_background\` to stop background processes by handle.`; switch (bash) { case "disabled": return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`; case "restricted": - return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`; + return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security. ${backgroundInstructions}`; case "enabled": - return `**Shell commands**: Use your native bash/shell tool for shell command execution.`; + return `**Shell commands**: Use your native bash/shell tool for shell command execution. ${backgroundInstructions}`; default: { const _exhaustive = bash; return _exhaustive; @@ -138039,6 +138139,7 @@ Your code is focused, elegant, and production-ready. You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so. You adapt your writing style to match existing patterns in the codebase (commit messages, PR descriptions, code comments) while never being unprofessional. You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions. +You are running inside a GitHub Actions ephemeral environment. All processes and resources will be cleaned up at the end of the run. You make assumptions when details are missing by preferring the most common convention unless repo-specific patterns exist. Fail with an explicit error only if critical information is missing (e.g. user asks to review a PR but does not provide a link or ID). Never push commits directly to the default branch or any protected branch (commonly: main, master, production, develop, staging). Always create a feature branch. Branch names must follow the pattern: \`pullfrog/-\` (e.g., \`pullfrog/123-fix-login-bug\`). Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages. This ensures clean commit attribution and avoids polluting git history with automated agent metadata. diff --git a/mcp/bash.ts b/mcp/bash.ts index 0a6afa3..2b57b7c 100644 --- a/mcp/bash.ts +++ b/mcp/bash.ts @@ -1,4 +1,7 @@ -import { type ChildProcess, spawn } from "node:child_process"; +import { type ChildProcess, type StdioOptions, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { closeSync, openSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { type } from "arktype"; import type { ToolContext } from "./server.ts"; import { execute, tool } from "./shared.ts"; @@ -8,6 +11,7 @@ export const BashParams = type({ description: "string", "timeout?": "number", "working_directory?": "string", + "background?": "boolean", }); // patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes @@ -34,21 +38,25 @@ function filterEnv(isPublicRepo: boolean): Record { return filtered; } +type SpawnSandboxedParams = { + command: string; + env: Record; + cwd: string; + isPublicRepo: boolean; + stdio: StdioOptions; +}; + /** * spawn command with filtered env. in CI, also use PID namespace isolation * to prevent child from reading /proc/$PPID/environ (only for public repos) */ -function spawnSandboxed( - command: string, - options: { env: Record; cwd: string; isPublicRepo: boolean } -): ChildProcess { - const stdio: ["ignore", "pipe", "pipe"] = ["ignore", "pipe", "pipe"]; - const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true }; +function spawnSandboxed(params: SpawnSandboxedParams): ChildProcess { + const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true }; // only use PID namespace isolation for public repos in CI - const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo; + const useNamespaceIsolation = process.env.CI === "true" && params.isPublicRepo; return useNamespaceIsolation - ? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts) - : spawn("bash", ["-c", command], spawnOpts); + ? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", params.command], spawnOpts) + : spawn("bash", ["-c", params.command], spawnOpts); } /** kill process and its entire process group */ @@ -67,6 +75,14 @@ async function killProcessGroup(proc: ChildProcess): Promise { } } +function getTempDir(): string { + const tempDir = process.env.PULLFROG_TEMP_DIR; + if (!tempDir) { + throw new Error("PULLFROG_TEMP_DIR not set"); + } + return tempDir; +} + export function BashTool(ctx: ToolContext) { const isPublicRepo = !ctx.repo.repo.private; @@ -84,10 +100,46 @@ Use this tool to: execute: execute(async (params) => { const timeout = Math.min(params.timeout ?? 120000, 600000); const cwd = params.working_directory ?? process.cwd(); - const proc = spawnSandboxed(params.command, { - env: filterEnv(isPublicRepo), + const env = filterEnv(isPublicRepo); + + if (params.background) { + const tempDir = getTempDir(); + const handle = `bg-${randomUUID().slice(0, 8)}`; + const outputPath = join(tempDir, `${handle}.log`); + const pidPath = join(tempDir, `${handle}.pid`); + const logFd = openSync(outputPath, "a"); + let proc: ChildProcess; + try { + proc = spawnSandboxed({ + command: params.command, + env, + cwd, + isPublicRepo, + stdio: ["ignore", logFd, logFd], + }); + } finally { + closeSync(logFd); + } + if (!proc.pid) { + throw new Error("failed to start background process"); + } + proc.unref(); + writeFileSync(pidPath, `${proc.pid}\n`); + ctx.toolState.backgroundProcesses.set(handle, { pid: proc.pid, outputPath, pidPath }); + return { + handle, + outputPath, + pidPath, + message: `started background process ${handle} (pid ${proc.pid})`, + }; + } + + const proc = spawnSandboxed({ + command: params.command, + env, cwd, isPublicRepo, + stdio: ["ignore", "pipe", "pipe"], }); let stdout = "", @@ -132,3 +184,42 @@ Use this tool to: }), }); } + +export const KillBackgroundParams = type({ + handle: type.string.describe("The handle of the background process to kill (e.g., bg-a1b2c3d4)"), +}); + +export function KillBackgroundTool(ctx: ToolContext) { + return tool({ + name: "kill_background", + description: `Kill a background process by its handle. Use this to stop dev servers or other long-running processes started with bash({ background: true }).`, + parameters: KillBackgroundParams, + execute: execute(async (params) => { + const proc = ctx.toolState.backgroundProcesses.get(params.handle); + if (!proc) { + return { + success: false, + message: `no background process with handle ${params.handle}`, + }; + } + + try { + process.kill(-proc.pid, "SIGTERM"); + } catch { + // already dead + } + await new Promise((resolve) => setTimeout(resolve, 200)); + try { + process.kill(-proc.pid, "SIGKILL"); + } catch { + // already dead + } + + ctx.toolState.backgroundProcesses.delete(params.handle); + return { + success: true, + message: `killed background process ${params.handle} (pid ${proc.pid})`, + }; + }), + }); +} diff --git a/mcp/server.ts b/mcp/server.ts index f5710b6..1356ac9 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -10,10 +10,17 @@ import type { OctokitWithPlugins } from "../utils/github.ts"; import type { ResolvedPayload } from "../utils/payload.ts"; import type { RepoData } from "../utils/repoData.ts"; +export type BackgroundProcess = { + pid: number; + outputPath: string; + pidPath: string; +}; + export interface ToolState { prNumber?: number; issueNumber?: number; selectedMode?: string; + backgroundProcesses: Map; review?: { id: number; nodeId: string; @@ -45,6 +52,7 @@ export function initToolState(ctx: InitToolStateParams): ToolState { id: Number.isNaN(progressCommentId) ? null : progressCommentId, wasUpdated: false, }, + backgroundProcesses: new Map(), }; } @@ -60,7 +68,7 @@ export interface ToolContext { jobId: string | undefined; } -import { BashTool } from "./bash.ts"; +import { BashTool, KillBackgroundTool } from "./bash.ts"; import { CheckoutPrTool } from "./checkout.ts"; import { GetCheckSuiteLogsTool } from "./checkSuite.ts"; import { @@ -116,6 +124,27 @@ async function findAvailablePort(startPort: number): Promise { throw new Error(`Could not find available port starting from ${startPort}`); } +async function killBackgroundProcesses(toolState: ToolState): Promise { + const backgroundProcesses = toolState.backgroundProcesses; + if (backgroundProcesses.size === 0) return; + for (const proc of backgroundProcesses.values()) { + try { + process.kill(-proc.pid, "SIGTERM"); + } catch { + // already dead + } + } + await new Promise((resolve) => setTimeout(resolve, 200)); + for (const proc of backgroundProcesses.values()) { + try { + process.kill(-proc.pid, "SIGKILL"); + } catch { + // already dead + } + } + backgroundProcesses.clear(); +} + /** * Start the MCP HTTP server and return the URL and close function */ @@ -153,13 +182,14 @@ export async function startMcpHttpServer( PushBranchTool(ctx), ]; - // only add BashTool if bash is not disabled + // only add BashTool and KillBackgroundTool if bash is not disabled // - "enabled": native bash + MCP bash // - "restricted": MCP bash only (native blocked by agent) // - "disabled": no bash at all const bash = ctx.payload.bash ?? "enabled"; if (bash !== "disabled") { tools.push(BashTool(ctx)); + tools.push(KillBackgroundTool(ctx)); } tools.push(ReportProgressTool(ctx)); @@ -184,6 +214,7 @@ export async function startMcpHttpServer( return { url, [Symbol.asyncDispose]: async () => { + await killBackgroundProcesses(ctx.toolState); await server.stop(); }, }; diff --git a/utils/instructions.ts b/utils/instructions.ts index d59ef5f..ce9b179 100644 --- a/utils/instructions.ts +++ b/utils/instructions.ts @@ -45,13 +45,15 @@ function buildRuntimeContext(ctx: InstructionsContext): string { } function getShellInstructions(bash: ResolvedPayload["bash"]): string { + const backgroundInstructions = `For long-running processes (dev servers, watchers), use \`bash({ command, background: true })\` which returns a handle. Use \`${ghPullfrogMcpName}/kill_background\` to stop background processes by handle.`; + switch (bash) { case "disabled": return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`; case "restricted": - return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`; + return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security. ${backgroundInstructions}`; case "enabled": - return `**Shell commands**: Use your native bash/shell tool for shell command execution.`; + return `**Shell commands**: Use your native bash/shell tool for shell command execution. ${backgroundInstructions}`; default: { const _exhaustive: never = bash; return _exhaustive satisfies never; @@ -107,6 +109,7 @@ Your code is focused, elegant, and production-ready. You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so. You adapt your writing style to match existing patterns in the codebase (commit messages, PR descriptions, code comments) while never being unprofessional. You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions. +You are running inside a GitHub Actions ephemeral environment. All processes and resources will be cleaned up at the end of the run. You make assumptions when details are missing by preferring the most common convention unless repo-specific patterns exist. Fail with an explicit error only if critical information is missing (e.g. user asks to review a PR but does not provide a link or ID). Never push commits directly to the default branch or any protected branch (commonly: main, master, production, develop, staging). Always create a feature branch. Branch names must follow the pattern: \`pullfrog/-\` (e.g., \`pullfrog/123-fix-login-bug\`). Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages. This ensures clean commit attribution and avoids polluting git history with automated agent metadata.