Add background mode to bash tool (#122)
* Implement background bash * Tweaks
This commit is contained in:
committed by
pullfrog[bot]
parent
f65cb4d2e3
commit
fa01f9c06d
@@ -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/<issue-number>-<kebab-case-description>\` (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.
|
||||
|
||||
Reference in New Issue
Block a user