From 20179227802d828833d330872219d72740b1ed93 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Mon, 23 Feb 2026 23:41:27 +0000 Subject: [PATCH] Improve delegate (#377) * Improve delegate * fix stale log regexes in delegate tests and add test-coupling comments Co-authored-by: Cursor --------- Co-authored-by: Cursor --- agents/codex.ts | 10 +- agents/shared.ts | 1 + entry | 613 +++++++++++++++---------- main.ts | 1 - mcp/askQuestion.ts | 20 +- mcp/delegate.ts | 109 +++-- mcp/file.ts | 11 +- mcp/output.ts | 11 +- mcp/selectMode.ts | 119 +++-- mcp/server.ts | 121 ++--- mcp/subagent.ts | 93 +++- mcp/toolFiltering.test.ts | 54 +-- modes.ts | 6 +- package.json | 2 +- play.ts | 8 +- post | 2 +- test/adhoc/delegateAskQuestion.ts | 4 +- test/adhoc/delegateContextIsolation.ts | 2 +- test/adhoc/delegateErrorHandling.ts | 2 +- test/adhoc/delegateFileRead.ts | 2 +- test/adhoc/delegateSynthesis.ts | 2 +- test/adhoc/delegateTimeout.ts | 2 +- test/adhoc/delegateTwoPhase.ts | 2 +- test/agnostic/delegate.ts | 2 +- test/agnostic/delegateMulti.ts | 14 +- utils/activity.ts | 1 + utils/install.ts | 91 ++-- utils/instructions.ts | 49 +- utils/setup.ts | 20 +- utils/subprocess.ts | 1 + 30 files changed, 853 insertions(+), 522 deletions(-) diff --git a/agents/codex.ts b/agents/codex.ts index 6716e82..228eeb1 100644 --- a/agents/codex.ts +++ b/agents/codex.ts @@ -120,21 +120,13 @@ ${mcpServerSections.join("\n\n")} return codexDir; } -// cache the installed CLI path so subagents don't re-download -let cachedCliPath: string | null = null; - async function installCodex(): Promise { - if (cachedCliPath) return cachedCliPath; - - const cliPath = await installFromNpmTarball({ + return await installFromNpmTarball({ packageName: "@openai/codex", version: CODEX_CLI_VERSION, executablePath: "bin/codex.js", installDependencies: true, }); - - cachedCliPath = cliPath; - return cliPath; } export const codex = agent({ diff --git a/agents/shared.ts b/agents/shared.ts index 80b62f9..6718f25 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -42,6 +42,7 @@ export const agent = (input: input): defineAgent ...input, run: async (ctx: AgentRunContext): Promise => { log.info(`» agent: ${input.name}`); + // matched by delegateEffort test validator — update tests if changed log.info(`» effort: ${ctx.payload.effort}`); if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`); log.info(`» web: ${ctx.payload.web}`); diff --git a/entry b/entry index 8712573..8632202 100755 --- a/entry +++ b/entry @@ -95647,14 +95647,14 @@ var require_turndown_cjs = __commonJS({ } else if (node2.nodeType === 1) { replacement = replacementForNode.call(self2, node2); } - return join16(output, replacement); + return join17(output, replacement); }, ""); } function postProcess(output) { var self2 = this; this.rules.forEach(function(rule) { if (typeof rule.append === "function") { - output = join16(output, rule.append(self2.options)); + output = join17(output, rule.append(self2.options)); } }); return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, ""); @@ -95666,7 +95666,7 @@ var require_turndown_cjs = __commonJS({ if (whitespace.leading || whitespace.trailing) content = content.trim(); return whitespace.leading + rule.replacement(content, node2, this.options) + whitespace.trailing; } - function join16(output, replacement) { + function join17(output, replacement) { var s1 = trimTrailingNewlines(output); var s2 = trimLeadingNewlines(replacement); var nls = Math.max(output.length - s1.length, replacement.length - s2.length); @@ -136284,8 +136284,9 @@ var addTools = (ctx, server, tools) => { }; // mcp/subagent.ts +import { execSync } from "node:child_process"; import { randomUUID as randomUUID2 } from "node:crypto"; -import { writeFileSync } from "node:fs"; +import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; // utils/activity.ts @@ -136356,11 +136357,16 @@ function createProcessOutputActivityTimeout(ctx) { } // mcp/subagent.ts +function slugify2(text) { + return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60); +} function createSubagentState(params) { const id = randomUUID2(); - const stdoutFilePath = join(params.ctx.tmpdir, `subagent-${id}.log`); + const slug = slugify2(params.label); + const stdoutFilePath = join(params.ctx.tmpdir, `subagent-${slug || id}.log`); const state = { id, + label: params.label, status: "running", mode: params.mode, stdoutFilePath, @@ -136370,7 +136376,6 @@ function createSubagentState(params) { keepAliveInterval: void 0 }; params.ctx.toolState.subagents.set(id, state); - params.ctx.toolState.activeSubagentId = id; return state; } function completeSubagent(params) { @@ -136382,13 +136387,54 @@ function completeSubagent(params) { if (params.subagent.usage) { params.ctx.toolState.usageEntries.push(params.subagent.usage); } - params.ctx.toolState.activeSubagentId = void 0; } -function buildSubagentInstructions(orchestratorPrompt) { +function hasRunningSubagents(ctx) { + for (const s of ctx.toolState.subagents.values()) { + if (s.status === "running") return true; + } + return false; +} +var subagentSystemPreamble = `You are a focused subagent. Complete the task autonomously \u2014 no follow-up questions. Minimize token usage. + +## Tools + +Your tools are limited to: +- **File operations**: \`${ghPullfrogMcpName}/file_read\`, \`file_write\`, \`file_edit\`, \`file_delete\`, \`list_directory\`. Native file tools (Read, Write, StrReplace, etc.) are disabled \u2014 use the MCP versions. +- **Shell**: \`${ghPullfrogMcpName}/bash\` (if available). Use this for local git operations (\`git add\`, \`git commit\`, \`git diff\`, \`git log\`, \`git status\`), running tests, builds, and linters. +- **Read-only GitHub**: \`get_pull_request\`, \`get_issue\`, \`get_issue_comments\`, \`get_issue_events\`, \`get_review_comments\`, \`list_pull_request_reviews\`, \`get_check_suite_logs\`, \`get_commit_info\`. +- **Output**: \`${ghPullfrogMcpName}/upload_file\`, \`${ghPullfrogMcpName}/set_output\`. + +## Output + +When you finish, you MUST call \`${ghPullfrogMcpName}/set_output\` with your results. This is how your work gets back to the orchestrator \u2014 if you don't call it, your output is lost. Structure output as the instructions request. For research tasks, use well-organized markdown.`; +function buildResolvedContext(params) { + let branch = "unknown"; + try { + branch = execSync("git branch --show-current", { encoding: "utf-8", stdio: "pipe" }).trim(); + } catch { + } + const lines = [ + `repo: ${params.ctx.repo.owner}/${params.ctx.repo.name}`, + `branch: ${branch}`, + `working_directory: ${process.cwd()}`, + `subagent_label: ${params.label}` + ]; + return `[CONTEXT] +${lines.join("\n")}`; +} +function buildSubagentInstructions(params) { + const resolvedContext = buildResolvedContext(params); + const full = `${resolvedContext} + +${subagentSystemPreamble} + +--- + +${params.instructions}`; return { - full: orchestratorPrompt, - system: "", - user: orchestratorPrompt, + full, + system: subagentSystemPreamble, + user: params.instructions, eventInstructions: "", repo: "", event: "", @@ -136397,14 +136443,23 @@ function buildSubagentInstructions(orchestratorPrompt) { } async function runSubagent(params) { params.subagent.keepAliveInterval = setInterval(markActivity, 3e4); - const mcpServer = await startSubagentMcpServer(params.ctx); + const mcpServer = await startSubagentMcpServer({ + ctx: params.ctx, + subagentId: params.subagent.id + }); + const subagentTmpdir = join(params.ctx.tmpdir, params.subagent.id); + mkdirSync(subagentTmpdir, { recursive: true }); try { const subagentPayload = { ...params.ctx.payload, effort: params.effort }; - const subagentInstructions = buildSubagentInstructions(params.instructions); + const subagentInstructions = buildSubagentInstructions({ + ctx: params.ctx, + label: params.subagent.label, + instructions: params.instructions + }); const result = await params.ctx.agent.run({ payload: subagentPayload, mcpServerUrl: mcpServer.url, - tmpdir: params.ctx.tmpdir, + tmpdir: subagentTmpdir, instructions: subagentInstructions }); params.subagent.usage = result.usage; @@ -136431,9 +136486,9 @@ var AskQuestionParams = type({ ) }); function buildQuestionPrompt(question) { - return `You are a focused research subagent. Answer the following question by exploring the codebase using the available MCP tools (${ghPullfrogMcpName}/file_read, ${ghPullfrogMcpName}/list_directory, etc.). + return `Answer the following question by exploring the codebase using the available MCP tools (${ghPullfrogMcpName}/file_read, ${ghPullfrogMcpName}/list_directory, etc.). -Be thorough in your investigation but concise in your answer. When done, call ${ghPullfrogMcpName}/set_output with a maximally concise answer \u2014 key facts only, no filler, no preamble. +Be thorough in your investigation but concise in your answer. Key facts only, no filler, no preamble. Question: ${question}`; } @@ -136443,11 +136498,12 @@ function AskQuestionTool(ctx) { description: "Ask a question about the codebase and get a concise answer from a lightweight research subagent. The intermediate exploration context stays in the subagent \u2014 only the concise answer returns to you.", parameters: AskQuestionParams, execute: execute(async (params) => { - if (ctx.toolState.activeSubagentId) { - return { error: "cannot ask questions while a subagent is already running" }; + if (hasRunningSubagents(ctx)) { + return { error: "cannot ask questions while subagents are running" }; } - const subagent = createSubagentState({ ctx, mode: "ask_question" }); - log.info(`\xBB ask_question subagent=${subagent.id}: ${params.question.slice(0, 100)}`); + const label = `ask-${params.question.slice(0, 40).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`; + const subagent = createSubagentState({ ctx, mode: "ask_question", label }); + log.info(`\xBB ask_question "${label}": ${params.question.slice(0, 100)}`); const result = await runSubagent({ ctx, subagent, @@ -136469,7 +136525,7 @@ import { writeFileSync as writeFileSync2 } from "node:fs"; import { join as join2 } from "node:path"; // utils/gitAuth.ts -import { execSync, spawnSync } from "node:child_process"; +import { execSync as execSync2, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { readFileSync, realpathSync } from "node:fs"; @@ -140516,7 +140572,7 @@ function hashFile(path3) { return createHash("sha256").update(readFileSync(path3)).digest("hex"); } function resolveGit() { - const whichPath = execSync("which git", { encoding: "utf-8" }).trim(); + const whichPath = execSync2("which git", { encoding: "utf-8" }).trim(); const resolvedPath = realpathSync(whichPath); const sha256 = hashFile(resolvedPath); gitBinary = { path: resolvedPath, sha256 }; @@ -141026,7 +141082,7 @@ ${diffPreview}`); } // mcp/checkSuite.ts -import { mkdirSync, writeFileSync as writeFileSync3 } from "node:fs"; +import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "node:fs"; import { join as join3 } from "node:path"; var GetCheckSuiteLogs = type({ check_suite_id: type.number.describe("the id from check_suite.id") @@ -141123,7 +141179,7 @@ function GetCheckSuiteLogsTool(ctx) { throw new Error("PULLFROG_TEMP_DIR not set"); } const logsDir = join3(tempDir, "ci-logs"); - mkdirSync(logsDir, { recursive: true }); + mkdirSync2(logsDir, { recursive: true }); const jobResults = []; for (const run2 of failedRuns) { const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, { @@ -141526,47 +141582,78 @@ function CommitInfoTool(ctx) { } // mcp/delegate.ts -var DelegateParams = type({ +var DelegateTask = type({ + label: type.string.describe( + "short label identifying this task (e.g. 'frontend-review', 'schema-check'). returned in results for easy matching." + ), instructions: type.string.describe( - "the complete prompt for the subagent. the subagent receives ONLY this text \u2014 include all context it needs (file paths, constraints, conventions, tool usage instructions). craft a focused, self-contained task description." + "the complete prompt for the subagent. the subagent receives ONLY this text (plus a system preamble) \u2014 include all context it needs (file paths, constraints, conventions, tool usage instructions). specify exactly what information to return. craft a focused, self-contained task description." ), "effort?": Effort.describe( - `effort level for the subagent: "mini" (low-effort and fast, only for simple tasks), "auto" (medium-effort, good for typical tasks that don't require significant reasoning), or "max" (high-effort, good for PR reviews and complex coding tasks)` + `effort level for the subagent: "mini" (low-effort and fast, only for simple tasks), "auto" (medium-effort, good for typical tasks that don't require significant reasoning), or "max" (high-effort, good for PR reviews and complex coding tasks). defaults to "auto".` ) }); +var DelegateParams = type({ + tasks: DelegateTask.array().atLeastLength(1).describe( + "array of tasks to delegate. all tasks run as parallel subagents and results are returned together." + ) +}); +function buildTaskResult(label, effort, subagent, error49) { + return { + label, + success: subagent.status === "completed", + effort, + summary: subagent.output ?? error49 ?? "no output produced \u2014 the subagent may not have called set_output. check stdoutFile for full logs.", + stdoutFile: subagent.stdoutFilePath, + error: error49 + }; +} function DelegateTool(ctx) { return tool({ name: "delegate", - description: "Delegate a task to a subagent. The subagent receives ONLY the instructions you provide \u2014 no other context is added. Use select_mode first to get guidance on how to craft the instructions for a given mode. Subagents have access to file operations, local git, bash, commenting, and review tools. They do NOT have push_branch, create_pull_request, update_pull_request_body, delete_branch, push_tags, delegate, ask_question, or select_mode \u2014 remote-mutating operations are your responsibility as orchestrator.", + description: "Delegate research, local coding tasks, and codebase investigations to subagents. Accepts an array of tasks that run in parallel \u2014 use this to fan out work (e.g. reviewing different areas of a PR simultaneously). Each subagent receives ONLY the instructions you provide (plus a system preamble enforcing set_output). Use select_mode first to get guidance on how to craft instructions. Subagents have file operations, bash, read-only GitHub tools (PR/issue info, review comments, check suite logs), and upload_file. They have NO git/checkout tools (would conflict between parallel subagents), NO dependency tools, and NO GitHub-write tools (commenting, reviews, labels, issues). All state-mutating and user-facing operations are your responsibility as orchestrator.", parameters: DelegateParams, execute: execute(async (params) => { - if (ctx.toolState.activeSubagentId) { + if (ctx.toolState.selfSubagentId) { return { error: "delegation is not available inside a subagent. you are already running as a delegated subagent. complete the task directly using the available tools." }; } - const effort = params.effort ?? "auto"; + if (hasRunningSubagents(ctx)) { + return { error: "delegation is already in progress" }; + } const mode = ctx.toolState.selectedMode ?? "unknown"; if (!ctx.toolState.selectedMode) { log.info(`\xBB warning: delegating without calling select_mode first (mode=${mode})`); } - const subagent = createSubagentState({ ctx, mode }); - log.info(`\xBB delegating subagent=${subagent.id} (mode=${mode}, effort=${effort})`); - const result = await runSubagent({ - ctx, - subagent, - effort, - instructions: params.instructions + log.info(`\xBB delegating ${params.tasks.length} task(s) in parallel (mode=${mode})`); + const taskEntries = params.tasks.map((task) => { + const effort = task.effort ?? "auto"; + const subagent = createSubagentState({ ctx, mode, label: task.label }); + log.info(`\xBB task "${task.label}" (effort=${effort})`); + return { task, effort, subagent }; }); - log.info(`\xBB delegation completed (mode=${mode}, success=${result.success})`); - return { - success: result.success, - mode, - effort, - summary: subagent.output ?? result.error ?? "no output produced \u2014 the subagent may not have called set_output. check stdoutFile for full logs.", - stdoutFile: subagent.stdoutFilePath, - error: result.error - }; + const settled = await Promise.allSettled( + taskEntries.map( + (entry) => runSubagent({ + ctx, + subagent: entry.subagent, + effort: entry.effort, + instructions: entry.task.instructions + }) + ) + ); + const results = taskEntries.map((entry, i) => { + const outcome = settled[i]; + const error49 = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error; + log.debug( + `\xBB task "${entry.task.label}" result: output=${entry.subagent.output !== void 0}, status=${entry.subagent.status}` + ); + return buildTaskResult(entry.task.label, entry.effort, entry.subagent, error49); + }); + const succeeded = results.filter((r) => r.success).length; + log.info(`\xBB delegation completed: ${succeeded}/${results.length} succeeded (mode=${mode})`); + return { mode, results }; }) }); } @@ -142318,14 +142405,14 @@ function AwaitDependencyInstallationTool(ctx) { // mcp/file.ts import { existsSync as existsSync4, - mkdirSync as mkdirSync2, + mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync3, realpathSync as realpathSync2, unlinkSync, writeFileSync as writeFileSync5 } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { dirname, join as join7, resolve } from "node:path"; var FileReadParams = type({ path: "string", "offset?": "number", @@ -142355,6 +142442,13 @@ function resolveReadPath(filePath) { if (tempDir && (resolved === tempDir || resolved.startsWith(tempDir + "/"))) { return resolved; } + const home = process.env.HOME; + if (home) { + const cursorProjectsDir = join7(home, ".cursor", "projects"); + if (resolved.startsWith(cursorProjectsDir + "/")) { + return resolved; + } + } if (existsSync4(resolved)) { const real = realpathSync2(resolved); if (real === cwd || real.startsWith(cwd + "/")) { @@ -142439,7 +142533,7 @@ function FileWriteTool(ctx) { execute: execute(async (params) => { const resolved = resolveWritePath(params.path, ctx.payload.shell); const dir = dirname(resolved); - mkdirSync2(dir, { recursive: true }); + mkdirSync3(dir, { recursive: true }); writeFileSync5(resolved, params.content, "utf-8"); return { path: params.path, written: true }; }) @@ -142942,15 +143036,18 @@ function SetOutputTool(ctx) { description: "Set the action output. When called by a subagent, returns a summary result to the orchestrator. When called in standalone mode, exposes the value as the 'result' GitHub Action output.", parameters: SetOutputParams, execute: execute(async (params) => { - const activeId = ctx.toolState.activeSubagentId; - if (activeId) { - const subagent = ctx.toolState.subagents.get(activeId); + const selfId = ctx.toolState.selfSubagentId; + if (selfId) { + const subagent = ctx.toolState.subagents.get(selfId); if (subagent) { subagent.output = params.value; + log.debug( + `set_output: routed to subagent ${selfId} (value=${params.value.slice(0, 80)})` + ); return { success: true, routed: "subagent" }; } log.warning( - `set_output: activeSubagentId=${activeId} but subagent not found in map \u2014 routing to action output` + `set_output: selfSubagentId=${selfId} but subagent not found in map \u2014 routing to action output` ); } ctx.toolState.output = params.value; @@ -143214,7 +143311,7 @@ function CreatePullRequestReviewTool(ctx) { // mcp/reviewComments.ts import { writeFileSync as writeFileSync6 } from "node:fs"; -import { join as join7 } from "node:path"; +import { join as join8 } from "node:path"; var REVIEW_THREADS_QUERY = ` query ($owner: String!, $name: String!, $prNumber: Int!) { repository(owner: $owner, name: $name) { @@ -143549,7 +143646,7 @@ function GetReviewCommentsTool(ctx) { throw new Error("PULLFROG_TEMP_DIR not set"); } const filename = `review-${params.review_id}-threads.md`; - const commentsPath = join7(tempDir, filename); + const commentsPath = join8(tempDir, filename); writeFileSync6(commentsPath, formatted.content); log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`); return { @@ -143643,70 +143740,89 @@ function ResolveReviewThreadTool(ctx) { // mcp/selectMode.ts var SelectModeParams = type({ mode: type.string.describe( - "the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'Fix', 'AddressReviews', 'Prompt')" + "the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'Fix', 'AddressReviews', 'Task')" ) }); function resolveMode(modes2, modeName) { return modes2.find((m) => m.name.toLowerCase() === modeName.toLowerCase()) ?? null; } function defaultGuidance(mode) { - return `Delegate a single subagent for this "${mode.name}" task. Craft a self-contained prompt that includes all context the subagent needs. Include \`${ghPullfrogMcpName}/report_progress\` for user-facing updates and \`${ghPullfrogMcpName}/set_output\` to return results back to you. Subagents do NOT have push or PR creation tools \u2014 if the task involves code changes, you must push and create the PR yourself after the subagent completes.`; + return `Delegate a subagent for this "${mode.name}" task via the \`tasks\` array. Craft a self-contained prompt that includes all context the subagent needs. Subagents have file ops, bash, and read-only GitHub tools \u2014 but NO git/checkout, dependency, GitHub-write, or remote-mutating tools. All state-mutating and user-facing operations are your responsibility as orchestrator.`; } var modeGuidance = { Build: `For Build tasks, consider a multi-phase approach: 1. **plan phase** (optional, for complex tasks): delegate a subagent to analyze the requirements, read AGENTS.md and relevant code, and produce a step-by-step implementation plan. Include \`${ghPullfrogMcpName}/set_output\` with the plan so it returns to you. Use mini or auto effort. You can also use \`ask_question\` for codebase questions/investigations. -2. **build phase**: delegate a subagent with the implementation task. Include in its prompt: +2. **setup** (your responsibility as orchestrator): before the build phase, checkout or create the branch: + - **PR event, modifying the existing PR**: call \`${ghPullfrogMcpName}/checkout_pr\` + - **new branch**: use \`${ghPullfrogMcpName}/git\` to create a branch (\`git checkout -b pullfrog/branch-name\`) + Subagents have no git/checkout tools \u2014 the working tree must be ready before delegation. + +3. **build phase**: delegate a subagent with the implementation task. Include in its prompt: - the plan (if you ran a plan phase) - specific files to modify and why - - branch naming: \`pullfrog/-\` - instruct the subagent to plan its approach before writing code: identify which files need to change, key design decisions, and edge cases. for non-trivial changes, consider whether there's a more elegant approach before committing to implementation. - testing expectations: run relevant tests/lints before committing - pre-commit quality check: instruct the subagent to review its own diff before committing \u2014 verify only intended changes are present, no debug artifacts or commented-out code remain, and no unrelated files were modified. the change should be clean enough that a senior engineer would approve it without hesitation. for non-trivial changes, ask whether there's a simpler way to achieve the same result. - - for multi-file changes, call \`${ghPullfrogMcpName}/report_progress\` with a summary of progress before the final commit - - commit changes locally (do NOT instruct to push or create a PR \u2014 subagents cannot do that) + - commit locally via bash (\`git add . && git commit -m "..."\`) - call \`${ghPullfrogMcpName}/set_output\` with a concise summary including the branch name (this is how results get back to you) -3. **review phase** (optional, for non-trivial changes): before pushing, delegate a review subagent to check the pending diff. Use \`ask_question\` for quick spot-checks, or delegate a full Review subagent for high-stakes changes. This catches issues before they're public. +4. **review phase** (optional, for non-trivial changes): before pushing, delegate a review subagent to check the pending diff. Use \`ask_question\` for quick spot-checks, or delegate a full Review subagent for high-stakes changes. This catches issues before they're public. -4. **finalize** (your responsibility as orchestrator): after the build (and optional review) completes: +5. **finalize** (your responsibility as orchestrator): after the build (and optional review) completes: - push the branch via \`${ghPullfrogMcpName}/push_branch\` - create a PR via \`${ghPullfrogMcpName}/create_pull_request\` - call \`${ghPullfrogMcpName}/report_progress\` with the final summary including PR link For simple, well-defined tasks, a single build subagent is sufficient \u2014 skip the plan and review phases. -Your subagent receives ONLY what you write. Include file paths, constraints, conventions, and any context from AGENTS.md or the codebase directly in the prompt. Subagents do NOT have push_branch, create_pull_request, or other remote-mutating tools.`, - AddressReviews: `Delegate a single subagent to address PR review feedback: +Your subagent receives ONLY what you write. Include file paths, constraints, conventions, and any context from AGENTS.md or the codebase directly in the prompt. Subagents have file ops, bash, and read-only GitHub tools \u2014 but NO git/checkout, dependency, GitHub-write, or remote-mutating tools.`, + AddressReviews: `Delegate a single subagent to address PR review feedback. + +Before delegating, checkout the PR branch yourself via \`${ghPullfrogMcpName}/checkout_pr\` \u2014 subagents have no git/checkout tools. Include in its prompt: -- the PR number to checkout via \`${ghPullfrogMcpName}/checkout_pr\` -- instruct it to fetch review comments via \`${ghPullfrogMcpName}/get_review_comments\` -- reply to EACH comment individually via \`${ghPullfrogMcpName}/reply_to_review_comment\` -- resolve threads via \`${ghPullfrogMcpName}/resolve_review_thread\` after addressing them +- instruct it to fetch review comments via \`${ghPullfrogMcpName}/get_review_comments\` (subagents have read-only GitHub tools) +- for each comment: understand the feedback, make the code change, and record what was done - test changes, then review the diff before committing \u2014 verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation -- commit locally (do NOT instruct to push \u2014 subagents cannot do that) -- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary -- call \`${ghPullfrogMcpName}/set_output\` with a concise summary of what was addressed (this is how results get back to you) +- commit locally via bash (\`git add . && git commit -m "..."\`) +- call \`${ghPullfrogMcpName}/set_output\` with a JSON object: \`{ "summary": "...", "replies": [{ "comment_id": 123, "thread_id": "...", "reply": "Fixed by ..." }, ...] }\` \u2014 this is how results get back to you -After the subagent completes, push the changes via \`${ghPullfrogMcpName}/push_branch\`. +After the subagent completes: +- push changes via \`${ghPullfrogMcpName}/push_branch\` +- reply to each comment using \`${ghPullfrogMcpName}/reply_to_review_comment\` with the subagent's suggested replies +- resolve addressed threads via \`${ghPullfrogMcpName}/resolve_review_thread\` +- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary Use auto or max effort depending on review complexity.`, - Review: `Before delegating, use \`ask_question\` to understand unfamiliar parts of the codebase the PR touches. This gives you context to craft a more focused review prompt (e.g., "pay special attention to how X interacts with Y"). For complex or high-stakes PRs, consider a two-phase approach: delegate a Plan subagent to analyze the PR and identify high-risk areas, then delegate a Review subagent with those focus areas as instructions. + Review: `For reviews, delegate multiple focused subagents in parallel \u2014 each investigating a different area or aspect of the PR. -Delegate a review subagent with: +### Approach -Include in its prompt: -- the PR number to checkout via \`${ghPullfrogMcpName}/checkout_pr\` -- what aspects to focus on (if any specific concerns exist, or high-risk areas you identified) -- instruct it to plan its investigation before diving in: after reading the diff, identify the highest-risk areas (tricky state transitions, boundary crossings, assumption chains) and prioritize depth over breadth -- instruct it to read the diff, trace data flow, check boundaries, and verify assumptions +1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` \u2014 this returns PR metadata and a \`diffPath\`. Read the diff to identify the major areas of change. +2. Delegate multiple subagents in a single \`${ghPullfrogMcpName}/delegate\` call, each focused on a specific area. For example, a PR touching action/, components/, and prisma/ might get three subagents: "action-review", "frontend-review", "schema-review". +3. After all subagents return, consolidate their findings into a single review. + +### Crafting each task + +Each task in the \`tasks\` array should include: +- the diff file path so the subagent can read it +- what specific area/aspect to focus on (e.g., "review the database migration and schema changes in prisma/") +- instruct it to read the diff, trace data flow, check boundaries, and verify assumptions within its area. subagents have read-only GitHub tools (\`${ghPullfrogMcpName}/get_pull_request\`, etc.) for fetching additional context. +- instruct it to plan its investigation before diving in: identify the highest-risk areas (tricky state transitions, boundary crossings, assumption chains) and prioritize depth over breadth - draft inline comments with NEW line numbers from the diff \u2014 every comment must be actionable (2-3 sentences max) -- after drafting, instruct it to critique its own comments: drop any that are praise, style preferences, speculative/unverified claims, about pre-existing code unrelated to the PR, or not actionable. if no comments survive, do not submit a review \u2014 use \`report_progress\` instead. -- submit surviving comments via \`${ghPullfrogMcpName}/create_pull_request_review\` +- after drafting, instruct it to critique its own comments: drop any that are praise, style preferences, speculative/unverified claims, about pre-existing code unrelated to the PR, or not actionable - use GitHub permalink format for code references -- call \`${ghPullfrogMcpName}/set_output\` with a concise review summary (this is how results get back to you) +- call \`${ghPullfrogMcpName}/set_output\` with a JSON object: \`{ "summary": "...", "comments": [{ "path": "file.ts", "line": 42, "body": "..." }, ...] }\` \u2014 this is how findings get back to you + +### Post-delegation + +After all tasks complete, consolidate into a **single** review: +- merge the \`comments\` arrays from all subagent outputs +- submit one \`${ghPullfrogMcpName}/create_pull_request_review\` with the merged comments and a unified summary body +- call \`${ghPullfrogMcpName}/report_progress\` with the summary +- if no subagent found actionable issues, skip the review \u2014 just call \`report_progress\` noting the PR was reviewed Use max effort for thorough reviews.`, Plan: `Delegate a single planning subagent: @@ -143715,38 +143831,52 @@ Include in its prompt: - the task to plan for - relevant codebase context (file paths, architecture notes from AGENTS.md) - instruct it to produce a structured, actionable plan with clear milestones -- call \`${ghPullfrogMcpName}/report_progress\` with the plan - call \`${ghPullfrogMcpName}/set_output\` with the plan (this is how results get back to you \u2014 you'll need the plan to craft the next subagent's prompt) +After the subagent completes, call \`${ghPullfrogMcpName}/report_progress\` with the plan. + Use mini or auto effort. After receiving the plan, you may delegate a Build subagent to implement it.`, - Fix: `For CI fix tasks, consider a focused single-phase approach: + Fix: `For CI fix tasks, consider a focused single-phase approach. + +Before delegating, checkout the PR branch yourself via \`${ghPullfrogMcpName}/checkout_pr\` \u2014 subagents have no git/checkout tools. Delegate a single fix subagent with: -- the check_suite_id to fetch logs via \`${ghPullfrogMcpName}/get_check_suite_logs\` -- the PR number to checkout via \`${ghPullfrogMcpName}/checkout_pr\` +- the check_suite_id to fetch logs via \`${ghPullfrogMcpName}/get_check_suite_logs\` (subagents have read-only GitHub tools) +- the PR diff file path (from checkout_pr result) so it can understand what the PR changed - CRITICAL: instruct it to verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report. - instruct it to read the workflow file, reproduce locally with the EXACT same commands CI runs -- after analyzing the failure, call \`${ghPullfrogMcpName}/report_progress\` with the diagnosis: what failed, why, and the planned fix \u2014 this gives the PR author visibility before code changes begin - fix the issue, then verify the fix by re-running the exact CI command - pre-commit quality check: review the diff before committing \u2014 verify only the fix is present, no debug artifacts, no unrelated changes. the fix should be clean enough that a senior engineer would approve it without hesitation. -- commit locally (do NOT instruct to push \u2014 subagents cannot do that) -- call \`${ghPullfrogMcpName}/set_output\` with a concise summary of the fix (this is how results get back to you) +- commit locally via bash (\`git add . && git commit -m "..."\`) +- call \`${ghPullfrogMcpName}/set_output\` with a concise summary: what failed, why, and the fix applied (this is how results get back to you) -After the subagent completes, push the changes via \`${ghPullfrogMcpName}/push_branch\`. +After the subagent completes: +- push changes via \`${ghPullfrogMcpName}/push_branch\` +- call \`${ghPullfrogMcpName}/report_progress\` with the diagnosis and fix summary Use auto effort.`, - Prompt: `Delegate a single subagent for this general-purpose task: + Task: `Handle this general-purpose task. For simple operations (labeling, commenting, answering questions, running a single command), you can often handle it directly without delegation. -Include in its prompt: -- the full task description with all relevant context +When the task involves **substantial work** \u2014 code changes across multiple files, multi-step investigations, or tasks that benefit from focused context \u2014 use \`delegate\` and \`ask_question\` liberally: + +- \`ask_question\`: quick codebase research, finding files, understanding architecture. Use freely \u2014 multiple calls in sequence is fine. +- \`delegate\`: research, local coding tasks, and codebase investigations. Each subagent gets dedicated context, so break complex work into focused subtasks and delegate each one. For independent subtasks, batch them in a single \`${ghPullfrogMcpName}/delegate\` call to run in parallel. + +### When delegating + +Include in each task's prompt: +- the full subtask description with all relevant context +- exactly what information to return. the subagent's output is your only way to get results back \u2014 be precise about what you need. - if code changes are needed: branch naming, testing, commit instructions (do NOT instruct to push or create PR) - if code changes are needed: instruct it to review its own diff before committing \u2014 verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation + +### Post-delegation + - call \`${ghPullfrogMcpName}/report_progress\` with results -- call \`${ghPullfrogMcpName}/set_output\` with a concise summary (this is how results get back to you) +- if the task involved code changes, push via \`${ghPullfrogMcpName}/push_branch\` and create a PR via \`${ghPullfrogMcpName}/create_pull_request\` +- if the task involved labeling, commenting, or other GitHub operations, perform those directly -If the task involved code changes, push via \`${ghPullfrogMcpName}/push_branch\` and create a PR via \`${ghPullfrogMcpName}/create_pull_request\` after the subagent completes. - -Use mini effort for simple tasks (labeling, commenting), auto for typical tasks.` +Use mini effort for simple research tasks, auto for typical tasks, max for complex multi-file changes.` }; function buildOrchestratorGuidance(mode) { const guidance = modeGuidance[mode.name] ?? defaultGuidance(mode); @@ -143783,7 +143913,7 @@ function SelectModeTool(ctx) { import { spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process"; import { randomUUID as randomUUID3 } from "node:crypto"; import { closeSync, openSync, writeFileSync as writeFileSync7 } from "node:fs"; -import { join as join8 } from "node:path"; +import { join as join9 } from "node:path"; var ShellParams = type({ command: "string", description: "string", @@ -143903,8 +144033,8 @@ Use this tool to: if (params.background) { const tempDir = getTempDir(); const handle = `bg-${randomUUID3().slice(0, 8)}`; - const outputPath = join8(tempDir, `${handle}.log`); - const pidPath = join8(tempDir, `${handle}.pid`); + const outputPath = join9(tempDir, `${handle}.log`); + const pidPath = join9(tempDir, `${handle}.pid`); const logFd = openSync(outputPath, "a"); let proc2; try { @@ -144075,7 +144205,7 @@ function initToolState(params) { return { progressCommentId: resolvedId, subagents: /* @__PURE__ */ new Map(), - activeSubagentId: void 0, + selfSubagentId: void 0, backgroundProcesses: /* @__PURE__ */ new Map(), usageEntries: [] }; @@ -144112,26 +144242,43 @@ function isAddressInUse(error49) { const message = getErrorMessage(error49).toLowerCase(); return message.includes("eaddrinuse") || message.includes("address already in use"); } -function buildCommonTools(ctx) { +function buildSubagentTools(ctx) { const tools = [ - StartDependencyInstallationTool(ctx), - AwaitDependencyInstallationTool(ctx), - CreateCommentTool(ctx), - EditCommentTool(ctx), - ReplyToReviewCommentTool(ctx), - IssueTool(ctx), IssueInfoTool(ctx), GetIssueCommentsTool(ctx), GetIssueEventsTool(ctx), - CreatePullRequestReviewTool(ctx), + PullRequestInfoTool(ctx), + CommitInfoTool(ctx), + GetReviewCommentsTool(ctx), + ListPullRequestReviewsTool(ctx), + GetCheckSuiteLogsTool(ctx), + UploadFileTool(ctx), + SetOutputTool(ctx), + FileReadTool(ctx), + FileWriteTool(ctx), + FileEditTool(ctx), + FileDeleteTool(ctx), + ListDirectoryTool(ctx) + ]; + if (ctx.payload.bash === "restricted") { + tools.push(BashTool(ctx)); + tools.push(KillBackgroundTool(ctx)); + } + return tools; +} +function buildOrchestratorTools(ctx) { + const tools = [ + StartDependencyInstallationTool(ctx), + AwaitDependencyInstallationTool(ctx), + IssueInfoTool(ctx), + GetIssueCommentsTool(ctx), + GetIssueEventsTool(ctx), PullRequestInfoTool(ctx), CommitInfoTool(ctx), CheckoutPrTool(ctx), GetReviewCommentsTool(ctx), ListPullRequestReviewsTool(ctx), - ResolveReviewThreadTool(ctx), GetCheckSuiteLogsTool(ctx), - AddLabelsTool(ctx), GitTool(ctx), GitFetchTool(ctx), UploadFileTool(ctx), @@ -144141,17 +144288,14 @@ function buildCommonTools(ctx) { FileEditTool(ctx), FileDeleteTool(ctx), ListDirectoryTool(ctx), - ReportProgressTool(ctx) - ]; - if (ctx.payload.shell === "restricted") { - tools.push(ShellTool(ctx)); - tools.push(KillBackgroundTool(ctx)); - } - return tools; -} -function buildOrchestratorTools(ctx) { - return [ - ...buildCommonTools(ctx), + CreateCommentTool(ctx), + EditCommentTool(ctx), + ReplyToReviewCommentTool(ctx), + CreatePullRequestReviewTool(ctx), + ResolveReviewThreadTool(ctx), + IssueTool(ctx), + AddLabelsTool(ctx), + ReportProgressTool(ctx), SelectModeTool(ctx), DelegateTool(ctx), AskQuestionTool(ctx), @@ -144161,9 +144305,11 @@ function buildOrchestratorTools(ctx) { CreatePullRequestTool(ctx), UpdatePullRequestBodyTool(ctx) ]; -} -function buildSubagentTools(ctx) { - return buildCommonTools(ctx); + if (ctx.payload.shell === "restricted") { + tools.push(ShellTool(ctx)); + tools.push(KillBackgroundTool(ctx)); + } + return tools; } async function tryStartMcpServer(ctx, tools, port) { const server = new FastMCP({ name: ghPullfrogMcpName, version: "0.0.1" }); @@ -144253,12 +144399,13 @@ async function startMcpHttpServer(ctx) { } }; } -async function startSubagentMcpServer(ctx) { +async function startSubagentMcpServer(params) { const subagentToolState = { - ...ctx.toolState, + ...params.ctx.toolState, + selfSubagentId: params.subagentId, backgroundProcesses: /* @__PURE__ */ new Map() }; - const subagentCtx = { ...ctx, toolState: subagentToolState }; + const subagentCtx = { ...params.ctx, toolState: subagentToolState }; const tools = buildSubagentTools(subagentCtx); const startResult = await selectMcpPort(subagentCtx, tools); return { url: startResult.url, stop: () => startResult.server.stop() }; @@ -144280,8 +144427,8 @@ function computeModes() { description: "Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details", prompt: `Follow these steps exactly. -1. **BRANCH** - Determine whether to work on the current branch or create a new one: - - **PR event, modifying the existing PR**: The PR branch is probably already checked out. Continue on this branch. +1. **CHECKOUT** - Determine whether to checkout the existing PR branch or create a new one: + - **PR event, modifying the existing PR**: Call \`${ghPullfrogMcpName}/checkout_pr\` with the PR number to checkout the PR branch. - **PR event, but user wants a NEW branch/PR**: Create a new branch with \`git checkout -b pullfrog/branch-name\` via the \`${ghPullfrogMcpName}/git\` tool. Branch names must be prefixed with "pullfrog/" and be specific enough to avoid collisions. Never commit directly to main/master/production. @@ -144461,7 +144608,7 @@ ${permalinkTip}` Your job is to fix issues THIS PR introduced, not to fix all CI failures. If in doubt about causation, abort and explain rather than making speculative changes.` }, { - name: "Prompt", + name: "Task", description: "General-purpose tasks that don't fit other modes: answering questions, adding comments, labeling, running ad-hoc commands, or any direct request", prompt: `Follow these steps. THINK HARDER. @@ -144490,13 +144637,13 @@ Do NOT overwrite a good comment with links/details with a generic message like " var modes = computeModes(); // agents/claude.ts -import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync8 } from "node:fs"; -import { join as join10 } from "node:path"; +import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync8 } from "node:fs"; +import { join as join11 } from "node:path"; // package.json var package_default = { name: "@pullfrog/pullfrog", - version: "0.0.169", + version: "0.0.170", type: "module", files: [ "index.js", @@ -144586,12 +144733,20 @@ var package_default = { // utils/install.ts import { spawnSync as spawnSync4 } from "node:child_process"; -import { chmodSync, createWriteStream, existsSync as existsSync5, mkdirSync as mkdirSync3 } from "node:fs"; +import { chmodSync, createWriteStream, existsSync as existsSync5, mkdirSync as mkdirSync4 } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join as join9 } from "node:path"; +import { join as join10 } from "node:path"; import { pipeline } from "node:stream/promises"; async function installFromNpmTarball(params) { + const tempDir = process.env.PULLFROG_TEMP_DIR; + if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set"); + const extractedDir = join10(tempDir, "package"); + const cliPath = join10(extractedDir, params.executablePath); + if (existsSync5(cliPath)) { + log.debug(`\xBB using cached binary at ${cliPath}`); + return cliPath; + } let resolvedVersion = params.version; if (params.version.startsWith("^") || params.version.startsWith("~") || params.version === "latest") { const npmRegistry2 = process.env.NPM_REGISTRY || "https://registry.npmjs.org"; @@ -144612,9 +144767,7 @@ async function installFromNpmTarball(params) { } } log.debug(`\xBB installing ${params.packageName}@${resolvedVersion}...`); - const tempDir = process.env.PULLFROG_TEMP_DIR; - if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set"); - const tarballPath = join9(tempDir, "package.tgz"); + const tarballPath = join10(tempDir, "package.tgz"); const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org"; let tarballUrl; if (params.packageName.startsWith("@")) { @@ -144643,8 +144796,6 @@ async function installFromNpmTarball(params) { `Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}` ); } - const extractedDir = join9(tempDir, "package"); - const cliPath = join9(extractedDir, params.executablePath); if (!existsSync5(cliPath)) { throw new Error(`Executable not found in extracted package at ${cliPath}`); } @@ -144689,6 +144840,13 @@ async function fetchWithRetry(url4, headers, errorMessage) { return response; } async function installFromGithub(params) { + const pullfrogTemp = process.env.PULLFROG_TEMP_DIR; + const installDir = pullfrogTemp ? join10(pullfrogTemp, `github-${params.owner}-${params.repo}`) : await mkdtemp(join10(tmpdir(), `${params.owner}-${params.repo}-github-`)); + const expectedCliPath = join10(installDir, params.executablePath ?? params.assetName ?? "asset"); + if (existsSync5(expectedCliPath)) { + log.debug(`\xBB using cached binary at ${expectedCliPath}`); + return expectedCliPath; + } log.info(`\xBB installing ${params.owner}/${params.repo} from GitHub releases...`); const releaseUrl = params.tag ? `https://api.github.com/repos/${params.owner}/${params.repo}/releases/tags/${params.tag}` : `https://api.github.com/repos/${params.owner}/${params.repo}/releases/latest`; log.debug(`\xBB fetching release from ${releaseUrl}...`); @@ -144705,22 +144863,16 @@ async function installFromGithub(params) { } const assetUrl = asset.browser_download_url; log.debug(`\xBB downloading asset from ${assetUrl}...`); - const tempDirPrefix = `${params.owner}-${params.repo}-github-`; - const tempDirPath = await mkdtemp(join9(tmpdir(), tempDirPrefix)); + mkdirSync4(installDir, { recursive: true }); const urlPath = new URL(assetUrl).pathname; const fileName2 = urlPath.split("/").pop() || "asset"; - const downloadPath = join9(tempDirPath, fileName2); + const downloadPath = join10(installDir, fileName2); const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset"); if (!assetResponse.body) throw new Error("Response body is null"); const fileStream = createWriteStream(downloadPath); await pipeline(assetResponse.body, fileStream); log.debug(`\xBB downloaded asset to ${downloadPath}`); - let cliPath; - if (params.executablePath) { - cliPath = join9(tempDirPath, params.executablePath); - } else { - cliPath = downloadPath; - } + const cliPath = params.executablePath ? join10(installDir, params.executablePath) : downloadPath; if (!existsSync5(cliPath)) { throw new Error(`Executable not found at ${cliPath}`); } @@ -144729,17 +144881,22 @@ async function installFromGithub(params) { return cliPath; } async function installFromDirectTarball(params) { - log.info(`\xBB downloading tarball from ${params.url}...`); const tempDir = process.env.PULLFROG_TEMP_DIR; if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set"); - const tarballPath = join9(tempDir, "direct-package.tgz"); + const extractDir = join10(tempDir, "direct-package"); + const cliPath = join10(extractDir, params.executablePath); + if (existsSync5(cliPath)) { + log.debug(`\xBB using cached binary at ${cliPath}`); + return cliPath; + } + log.info(`\xBB downloading tarball from ${params.url}...`); + const tarballPath = join10(tempDir, "direct-package.tgz"); const response = await fetchWithRetry(params.url, {}, "failed to download tarball"); if (!response.body) throw new Error("response body is null"); const fileStream = createWriteStream(tarballPath); await pipeline(response.body, fileStream); log.debug(`\xBB downloaded tarball to ${tarballPath}`); - const extractDir = join9(tempDir, "direct-package"); - mkdirSync3(extractDir, { recursive: true }); + mkdirSync4(extractDir, { recursive: true }); const tarArgs = ["-xzf", tarballPath, "-C", extractDir]; if (params.stripComponents !== void 0 && params.stripComponents > 0) { tarArgs.push(`--strip-components=${Math.floor(params.stripComponents)}`); @@ -144754,7 +144911,6 @@ async function installFromDirectTarball(params) { `failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "unknown error"}` ); } - const cliPath = join9(extractDir, params.executablePath); if (!existsSync5(cliPath)) { throw new Error(`executable not found in extracted tarball at ${cliPath}`); } @@ -144846,9 +145002,9 @@ function buildDisallowedTools(ctx) { return disallowed; } function writeMcpConfig(ctx) { - const configDir = join10(ctx.tmpdir, ".claude"); - mkdirSync4(configDir, { recursive: true }); - const configPath = join10(configDir, "mcp.json"); + const configDir = join11(ctx.tmpdir, ".claude"); + mkdirSync5(configDir, { recursive: true }); + const configPath = join11(configDir, "mcp.json"); const mcpConfig = { mcpServers: { [ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl } @@ -145064,8 +145220,8 @@ var messageHandlers = { }; // agents/codex.ts -import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync9 } from "node:fs"; -import { join as join11 } from "node:path"; +import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync9 } from "node:fs"; +import { join as join12 } from "node:path"; var CODEX_CLI_VERSION = "0.101.0"; var PREFERRED_MODEL = "gpt-5.3-codex"; var FALLBACK_MODEL = "gpt-5.2-codex"; @@ -145105,9 +145261,9 @@ async function resolveModel(apiKey) { return FALLBACK_MODEL; } function writeCodexConfig(ctx) { - const codexDir = join11(ctx.tmpdir, ".codex"); - mkdirSync5(codexDir, { recursive: true }); - const configPath = join11(codexDir, "config.toml"); + const codexDir = join12(ctx.tmpdir, ".codex"); + mkdirSync6(codexDir, { recursive: true }); + const configPath = join12(codexDir, "config.toml"); log.info(`\xBB adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}`); const mcpServerSections = [`[mcp_servers.${ghPullfrogMcpName}] url = "${ctx.mcpServerUrl}"`]; @@ -145140,17 +145296,13 @@ ${mcpServerSections.join("\n\n")} ); return codexDir; } -var cachedCliPath = null; async function installCodex() { - if (cachedCliPath) return cachedCliPath; - const cliPath = await installFromNpmTarball({ + return await installFromNpmTarball({ packageName: "@openai/codex", version: CODEX_CLI_VERSION, executablePath: "bin/codex.js", installDependencies: true }); - cachedCliPath = cliPath; - return cliPath; } var codex = agent({ name: "codex", @@ -145361,9 +145513,9 @@ function createMessageHandlers() { // agents/cursor.ts import { spawn as spawn3 } from "node:child_process"; -import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync10 } from "node:fs"; +import { existsSync as existsSync6, mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync10 } from "node:fs"; import { homedir } from "node:os"; -import { join as join12 } from "node:path"; +import { join as join13 } from "node:path"; import { performance as performance6 } from "node:perf_hooks"; var CURSOR_CLI_VERSION = "2026.01.28-fd13201"; var cursorEffortModels = { @@ -145393,7 +145545,7 @@ var cursor = agent({ const cliPath = await installCursor(); configureCursorMcpServers(ctx); configureCursorTools(ctx); - const projectCliConfigPath = join12(process.cwd(), ".cursor", "cli.json"); + const projectCliConfigPath = join13(process.cwd(), ".cursor", "cli.json"); let modelOverride = null; if (existsSync6(projectCliConfigPath)) { try { @@ -145578,12 +145730,12 @@ var cursor = agent({ } }); function getCursorConfigDir() { - return join12(homedir(), ".cursor"); + return join13(homedir(), ".cursor"); } function configureCursorMcpServers(ctx) { const cursorConfigDir = getCursorConfigDir(); - const mcpConfigPath = join12(cursorConfigDir, "mcp.json"); - mkdirSync6(cursorConfigDir, { recursive: true }); + const mcpConfigPath = join13(cursorConfigDir, "mcp.json"); + mkdirSync7(cursorConfigDir, { recursive: true }); const mcpServers = { [ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl } }; @@ -145592,8 +145744,8 @@ function configureCursorMcpServers(ctx) { } function configureCursorTools(ctx) { const cursorConfigDir = getCursorConfigDir(); - const cliConfigPath = join12(cursorConfigDir, "cli-config.json"); - mkdirSync6(cursorConfigDir, { recursive: true }); + const cliConfigPath = join13(cursorConfigDir, "cli-config.json"); + mkdirSync7(cursorConfigDir, { recursive: true }); const shell = ctx.payload.shell; const deny = []; if (ctx.payload.search === "disabled") deny.push("WebSearch"); @@ -145619,9 +145771,9 @@ function configureCursorTools(ctx) { } // agents/gemini.ts -import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync11 } from "node:fs"; +import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync11 } from "node:fs"; import { homedir as homedir2 } from "node:os"; -import { join as join13 } from "node:path"; +import { join as join14 } from "node:path"; var geminiEffortConfig = { // https://ai.google.dev/gemini-api/docs/models // the docs mention needing to enable preview features for these models but if you @@ -145845,9 +145997,9 @@ function configureGeminiSettings(ctx) { const thinkingLevel = effortConfig.thinkingLevel; log.info(`\xBB model: ${model} (thinkingLevel: ${thinkingLevel})`); const realHome = homedir2(); - const geminiConfigDir = join13(realHome, ".gemini"); - const settingsPath = join13(geminiConfigDir, "settings.json"); - mkdirSync7(geminiConfigDir, { recursive: true }); + const geminiConfigDir = join14(realHome, ".gemini"); + const settingsPath = join14(geminiConfigDir, "settings.json"); + mkdirSync8(geminiConfigDir, { recursive: true }); let existingSettings = {}; try { const content = readFileSync6(settingsPath, "utf-8"); @@ -145892,8 +146044,8 @@ function configureGeminiSettings(ctx) { } // agents/opencode.ts -import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync12 } from "node:fs"; -import { join as join14 } from "node:path"; +import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync12 } from "node:fs"; +import { join as join15 } from "node:path"; import { performance as performance7 } from "node:perf_hooks"; var OPENCODE_CLI_VERSION = "1.1.56"; var PROVIDER_ERROR_PATTERNS = [ @@ -145927,8 +146079,8 @@ var opencode = agent({ run: async (ctx) => { const cliPath = await installOpencode(); const tempHome = ctx.tmpdir; - const configDir = join14(tempHome, ".config", "opencode"); - mkdirSync8(configDir, { recursive: true }); + const configDir = join15(tempHome, ".config", "opencode"); + mkdirSync9(configDir, { recursive: true }); configureOpenCode(ctx); const args2 = ["run", ctx.instructions.full, "--format", "json", "--print-logs"]; const modelOverride = process.env.OPENCODE_MODEL; @@ -145942,7 +146094,7 @@ var opencode = agent({ const env2 = { ...process.env, HOME: tempHome, - XDG_CONFIG_HOME: join14(tempHome, ".config"), + XDG_CONFIG_HOME: join15(tempHome, ".config"), // set GOOGLE_GENERATIVE_AI_API_KEY alias for Google provider compatibility (if not already set) GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY }; @@ -146095,9 +146247,9 @@ ${stderrContext}` } }); function configureOpenCode(ctx) { - const configDir = join14(ctx.tmpdir, ".config", "opencode"); - mkdirSync8(configDir, { recursive: true }); - const configPath = join14(configDir, "opencode.json"); + const configDir = join15(ctx.tmpdir, ".config", "opencode"); + mkdirSync9(configDir, { recursive: true }); + const configPath = join15(configDir, "opencode.json"); const opencodeMcpServers = { [ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl } }; @@ -146515,7 +146667,7 @@ ${ctx.error}` : ctx.error; } // utils/instructions.ts -import { execSync as execSync2 } from "node:child_process"; +import { execSync as execSync3 } from "node:child_process"; function buildRuntimeContext(ctx) { const { "~pullfrog": _, @@ -146527,7 +146679,7 @@ function buildRuntimeContext(ctx) { } = ctx.payload; let gitStatus; try { - gitStatus = execSync2("git status --short", { encoding: "utf-8", stdio: "pipe" }).trim() || "(clean)"; + gitStatus = execSync3("git status --short", { encoding: "utf-8", stdio: "pipe" }).trim() || "(clean)"; } catch { } const data = { @@ -146770,48 +146922,53 @@ ${ctx.contextSections}`; } function resolveInstructions(ctx) { const inputs = buildCommonInputs(ctx); - const orchestratorTaskSection = `**Required!** You are an orchestrator. Evaluate the task below, then delegate to specialized subagents. + const orchestratorTaskSection = `**Required!** You are an orchestrator. You do not perform tasks directly \u2014 you delegate to specialized subagents and handle all state-mutating and user-facing GitHub operations yourself. ### Step 1: Select a mode -Call \`${ghPullfrogMcpName}/select_mode\` with the appropriate mode name. This returns orchestrator-level guidance on how to handle the task \u2014 including suggested delegation phases and prompt-crafting tips. +Call \`${ghPullfrogMcpName}/select_mode\` with the appropriate mode name. This returns **your workflow** \u2014 a step-by-step playbook you must follow, including: +- **Pre-delegation actions** you must perform (checkout, branch creation, setup) +- **Delegation instructions** (how to craft subagent prompts, what to include) +- **Post-delegation actions** you must perform (push, PR creation, review submission, progress reporting) + +**Follow the returned guidance as your primary instruction set.** Do not improvise \u2014 the guidance defines what you do vs. what subagents do. Available modes: ${ctx.modes.map((m) => `- "${m.name}": ${m.description}`).join("\n")} -### Step 2: Craft subagent prompts and delegate +### Step 2: Delegate -Based on the guidance from select_mode, craft a focused, self-contained prompt for each subagent, then call \`${ghPullfrogMcpName}/delegate\` with: -- \`instructions\`: Your crafted prompt. **The subagent receives ONLY this text \u2014 no other context is added.** Include everything it needs: file paths, constraints, conventions, tool usage instructions, and any relevant context from the codebase or previous phases. -- \`effort\`: \`"mini"\` (simple tasks), \`"auto"\` (typical tasks), or \`"max"\` (complex tasks requiring deep reasoning). +Call \`${ghPullfrogMcpName}/delegate\` to fan out research, local coding tasks, and codebase investigations to subagents. Pass a \`tasks\` array. Each task has: +- \`label\`: Short identifier (e.g. "frontend-review", "schema-check"). Returned in results for matching. +- \`instructions\`: The subagent receives ONLY this text (plus a system preamble with tool documentation and resolved context). Include everything it needs: file paths, constraints, conventions, and any context from the codebase or previous phases. +- \`effort\` (optional): \`"mini"\`, \`"auto"\` (default), or \`"max"\`. -Subagents are designed for research and local work: reading files, exploring the codebase, writing and editing code, running tests, creating reviews, and posting comments. They do NOT have access to remote-mutating operations like pushing branches, creating PRs, or updating PR bodies \u2014 those are your responsibility as the orchestrator. +All tasks in a single \`delegate\` call run as **parallel subagents**. For sequential phases (plan \u2192 build \u2192 review), use separate \`delegate\` calls. -To investigate questions (e.g. web research, codebase investigations), prefer \`${ghPullfrogMcpName}/ask_question\` over \`${ghPullfrogMcpName}/delegate\`. +To investigate questions, prefer \`${ghPullfrogMcpName}/ask_question\` over \`${ghPullfrogMcpName}/delegate\`. -### Step 3: Post-delegation (your responsibility) +### Step 3: Post-delegation -After each delegation, you receive the subagent's summary (via set_output) and a path to its full stdout log (which you can inspect via \`${ghPullfrogMcpName}/file_read\` if needed). Use this to decide whether to delegate again or finalize. +After each \`delegate\` call, you receive a \`results\` array \u2014 one entry per task with \`label\`, \`success\`, \`summary\` (from set_output), and \`stdoutFile\` (inspectable via \`${ghPullfrogMcpName}/file_read\`). Follow the post-delegation steps from the select_mode guidance. -**Remote operations are YOUR job.** Subagents do NOT have push, PR creation, or other remote-mutating tools. After a subagent that makes code changes completes, you must: -- Push the branch via \`${ghPullfrogMcpName}/push_branch\` -- Create a PR via \`${ghPullfrogMcpName}/create_pull_request\` (if needed) -- Call \`${ghPullfrogMcpName}/report_progress\` with the final summary including PR links +When all delegations are complete, call \`${ghPullfrogMcpName}/set_output\` with the final result. This makes it available as the GitHub Action output. -When all delegations are complete, call \`${ghPullfrogMcpName}/set_output\` with the final result \u2014 the last subagent's summary or a synthesis of all phases. This is required: it makes the result available as the GitHub Action output for downstream steps. +### Subagent capabilities + +Subagents have: file operations, bash (for local git, tests, builds), read-only GitHub queries, and upload_file. They do NOT have: \`git\`, \`checkout_pr\`, \`push_branch\`, \`create_pull_request\`, \`create_pull_request_review\`, \`report_progress\`, \`create_issue_comment\`, \`reply_to_review_comment\`, \`resolve_review_thread\`, \`delegate\`, \`ask_question\`, or any dependency/remote-mutating tools. All GitHub-write and state-mutating operations are your responsibility. ### Prompt-crafting rules -- Your subagent has NO context beyond what you write. No repo instructions, no event instructions, no user prompt \u2014 only your crafted instructions. -- Include MCP tool names when the subagent needs them (e.g., "commit via \`${ghPullfrogMcpName}/git\`"). -- Subagents do NOT have \`push_branch\`, \`create_pull_request\`, \`update_pull_request_body\`, \`delete_branch\`, or \`push_tags\`. Never instruct a subagent to push or create PRs \u2014 that is your job as the orchestrator. -- Include branch naming conventions, testing expectations, and commit instructions when relevant. -- For multi-phase flows, pass results from earlier phases directly into the next subagent's prompt. -- The subagent should call \`${ghPullfrogMcpName}/set_output\` with a concise summary when done (include the branch name if code changes were made). +- Subagents have NO context beyond what you write. No repo instructions, no event data, no user prompt. +- Specify exactly what information the subagent should return. The subagent's \`set_output\` call is your only way to get results back \u2014 be precise about what you need. +- Instruct subagents to use bash for local git (\`git add\`, \`git commit\`, \`git diff\`, \`git status\`). +- Never instruct a subagent to push, create PRs, submit reviews, or post comments. +- For multi-phase flows, pass results from earlier phases into the next delegate call's prompts. +- You do NOT need to instruct subagents to call \`set_output\` \u2014 the system preamble handles this. ### No-action cases -If the task clearly requires no work (e.g., irrelevant event, duplicate request), skip delegation entirely. Call \`${ghPullfrogMcpName}/report_progress\` directly to explain why no action is needed.`; +If the task clearly requires no work, skip delegation. Call \`${ghPullfrogMcpName}/report_progress\` directly to explain why no action is needed.`; const system = buildSystemPrompt({ shell: ctx.payload.shell, trigger: ctx.payload.event.trigger, @@ -147117,12 +147274,12 @@ async function resolveRunContextData(params) { } // utils/setup.ts -import { execSync as execSync3 } from "node:child_process"; +import { execSync as execSync4 } from "node:child_process"; import { mkdtempSync } from "node:fs"; import { tmpdir as tmpdir2 } from "node:os"; -import { join as join15 } from "node:path"; +import { join as join16 } from "node:path"; function createTempDirectory() { - const sharedTempDir = mkdtempSync(join15(tmpdir2(), "pullfrog-")); + const sharedTempDir = mkdtempSync(join16(tmpdir2(), "pullfrog-")); process.env.PULLFROG_TEMP_DIR = sharedTempDir; log.info(`\xBB created temp dir at ${sharedTempDir}`); return sharedTempDir; @@ -147133,7 +147290,7 @@ async function setupGit(params) { try { let currentEmail = ""; try { - currentEmail = execSync3("git config user.email", { + currentEmail = execSync4("git config user.email", { cwd: repoDir, stdio: "pipe", encoding: "utf-8" @@ -147142,11 +147299,11 @@ async function setupGit(params) { } const shouldSetDefaults = !currentEmail || currentEmail === "github-actions[bot]@users.noreply.github.com"; if (shouldSetDefaults) { - execSync3('git config --local user.email "226033991+pullfrog[bot]@users.noreply.github.com"', { + execSync4('git config --local user.email "226033991+pullfrog[bot]@users.noreply.github.com"', { cwd: repoDir, stdio: "pipe" }); - execSync3('git config --local user.name "pullfrog[bot]"', { + execSync4('git config --local user.name "pullfrog[bot]"', { cwd: repoDir, stdio: "pipe" }); @@ -147155,7 +147312,7 @@ async function setupGit(params) { log.debug(`\xBB git user already configured (${currentEmail}), skipping`); } if (params.shell === "disabled") { - execSync3("git config --local core.hooksPath /dev/null", { + execSync4("git config --local core.hooksPath /dev/null", { cwd: repoDir, stdio: "pipe" }); @@ -147165,7 +147322,7 @@ async function setupGit(params) { log.info(`Failed to set git config: ${error49 instanceof Error ? error49.message : String(error49)}`); } try { - execSync3("git config --local --unset-all http.https://github.com/.extraheader", { + execSync4("git config --local --unset-all http.https://github.com/.extraheader", { cwd: repoDir, stdio: "pipe" }); @@ -147174,7 +147331,7 @@ async function setupGit(params) { log.debug("\xBB no existing authentication headers to remove"); } try { - const configOutput = execSync3("git config --local --get-regexp ^includeif\\.", { + const configOutput = execSync4("git config --local --get-regexp ^includeif\\.", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" @@ -147182,7 +147339,7 @@ async function setupGit(params) { for (const line of configOutput.trim().split("\n")) { const key = line.split(" ")[0]; if (!key) continue; - execSync3(`git config --local --unset "${key}"`, { + execSync4(`git config --local --unset "${key}"`, { cwd: repoDir, stdio: "pipe" }); @@ -147195,12 +147352,7 @@ async function setupGit(params) { $("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir }); params.toolState.pushUrl = originUrl; $("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir }); - if (params.event.is_pr !== true || !params.event.issue_number) { - log.info("\xBB git authentication configured"); - return; - } - const prNumber = params.event.issue_number; - await checkoutPrBranch(prNumber, params); + log.info("\xBB git authentication configured"); } // utils/time.ts @@ -147304,7 +147456,6 @@ async function main() { gitToken: tokenRef.gitToken, owner: runContext.repo.owner, name: runContext.repo.name, - event: payload.event, octokit, toolState, shell: payload.shell, diff --git a/main.ts b/main.ts index 291bfac..1ae3697 100644 --- a/main.ts +++ b/main.ts @@ -128,7 +128,6 @@ export async function main(): Promise { gitToken: tokenRef.gitToken, owner: runContext.repo.owner, name: runContext.repo.name, - event: payload.event, octokit, toolState, shell: payload.shell, diff --git a/mcp/askQuestion.ts b/mcp/askQuestion.ts index 8c50c74..db7de10 100644 --- a/mcp/askQuestion.ts +++ b/mcp/askQuestion.ts @@ -3,7 +3,7 @@ import { ghPullfrogMcpName } from "../external.ts"; import { log } from "../utils/cli.ts"; import type { ToolContext } from "./server.ts"; import { execute, tool } from "./shared.ts"; -import { createSubagentState, runSubagent } from "./subagent.ts"; +import { createSubagentState, hasRunningSubagents, runSubagent } from "./subagent.ts"; export const AskQuestionParams = type({ question: type.string.describe( @@ -12,9 +12,9 @@ export const AskQuestionParams = type({ }); function buildQuestionPrompt(question: string): string { - return `You are a focused research subagent. Answer the following question by exploring the codebase using the available MCP tools (${ghPullfrogMcpName}/file_read, ${ghPullfrogMcpName}/list_directory, etc.). + return `Answer the following question by exploring the codebase using the available MCP tools (${ghPullfrogMcpName}/file_read, ${ghPullfrogMcpName}/list_directory, etc.). -Be thorough in your investigation but concise in your answer. When done, call ${ghPullfrogMcpName}/set_output with a maximally concise answer — key facts only, no filler, no preamble. +Be thorough in your investigation but concise in your answer. Key facts only, no filler, no preamble. Question: ${question}`; } @@ -26,12 +26,18 @@ export function AskQuestionTool(ctx: ToolContext) { "Ask a question about the codebase and get a concise answer from a lightweight research subagent. The intermediate exploration context stays in the subagent — only the concise answer returns to you.", parameters: AskQuestionParams, execute: execute(async (params) => { - if (ctx.toolState.activeSubagentId) { - return { error: "cannot ask questions while a subagent is already running" }; + if (hasRunningSubagents(ctx)) { + return { error: "cannot ask questions while subagents are running" }; } - const subagent = createSubagentState({ ctx, mode: "ask_question" }); - log.info(`» ask_question subagent=${subagent.id}: ${params.question.slice(0, 100)}`); + const label = `ask-${params.question + .slice(0, 40) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "")}`; + const subagent = createSubagentState({ ctx, mode: "ask_question", label }); + // matched by delegateAskQuestion test validator — update tests if changed + log.info(`» ask_question "${label}": ${params.question.slice(0, 100)}`); const result = await runSubagent({ ctx, diff --git a/mcp/delegate.ts b/mcp/delegate.ts index f9d787c..fdb50ec 100644 --- a/mcp/delegate.ts +++ b/mcp/delegate.ts @@ -1,60 +1,115 @@ import { type } from "arktype"; import { Effort } from "../external.ts"; import { log } from "../utils/cli.ts"; -import type { ToolContext } from "./server.ts"; +import type { SubagentState, ToolContext } from "./server.ts"; import { execute, tool } from "./shared.ts"; -import { createSubagentState, runSubagent } from "./subagent.ts"; +import { createSubagentState, hasRunningSubagents, runSubagent } from "./subagent.ts"; -export const DelegateParams = type({ +const DelegateTask = type({ + label: type.string.describe( + "short label identifying this task (e.g. 'frontend-review', 'schema-check'). returned in results for easy matching." + ), instructions: type.string.describe( - "the complete prompt for the subagent. the subagent receives ONLY this text — include all context it needs (file paths, constraints, conventions, tool usage instructions). craft a focused, self-contained task description." + "the complete prompt for the subagent. the subagent receives ONLY this text (plus a system preamble) — include all context it needs (file paths, constraints, conventions, tool usage instructions). specify exactly what information to return. craft a focused, self-contained task description." ), "effort?": Effort.describe( - 'effort level for the subagent: "mini" (low-effort and fast, only for simple tasks), "auto" (medium-effort, good for typical tasks that don\'t require significant reasoning), or "max" (high-effort, good for PR reviews and complex coding tasks)' + 'effort level for the subagent: "mini" (low-effort and fast, only for simple tasks), "auto" (medium-effort, good for typical tasks that don\'t require significant reasoning), or "max" (high-effort, good for PR reviews and complex coding tasks). defaults to "auto".' ), }); +export const DelegateParams = type({ + tasks: DelegateTask.array() + .atLeastLength(1) + .describe( + "array of tasks to delegate. all tasks run as parallel subagents and results are returned together." + ), +}); + +type DelegateTaskResult = { + label: string; + success: boolean; + effort: string; + summary: string; + stdoutFile: string; + error: string | undefined; +}; + +function buildTaskResult( + label: string, + effort: string, + subagent: SubagentState, + error: string | undefined +): DelegateTaskResult { + return { + label, + success: subagent.status === "completed", + effort, + summary: + subagent.output ?? + error ?? + "no output produced — the subagent may not have called set_output. check stdoutFile for full logs.", + stdoutFile: subagent.stdoutFilePath, + error, + }; +} + export function DelegateTool(ctx: ToolContext) { return tool({ name: "delegate", description: - "Delegate a task to a subagent. The subagent receives ONLY the instructions you provide — no other context is added. Use select_mode first to get guidance on how to craft the instructions for a given mode. Subagents have access to file operations, local git, bash, commenting, and review tools. They do NOT have push_branch, create_pull_request, update_pull_request_body, delete_branch, push_tags, delegate, ask_question, or select_mode — remote-mutating operations are your responsibility as orchestrator.", + "Delegate research, local coding tasks, and codebase investigations to subagents. Accepts an array of tasks that run in parallel — use this to fan out work (e.g. reviewing different areas of a PR simultaneously). Each subagent receives ONLY the instructions you provide (plus a system preamble enforcing set_output). Use select_mode first to get guidance on how to craft instructions. Subagents have file operations, bash, read-only GitHub tools (PR/issue info, review comments, check suite logs), and upload_file. They have NO git/checkout tools (would conflict between parallel subagents), NO dependency tools, and NO GitHub-write tools (commenting, reviews, labels, issues). All state-mutating and user-facing operations are your responsibility as orchestrator.", parameters: DelegateParams, execute: execute(async (params) => { - if (ctx.toolState.activeSubagentId) { + if (ctx.toolState.selfSubagentId) { return { error: "delegation is not available inside a subagent. you are already running as a delegated subagent. complete the task directly using the available tools.", }; } - const effort = params.effort ?? "auto"; + if (hasRunningSubagents(ctx)) { + return { error: "delegation is already in progress" }; + } + const mode = ctx.toolState.selectedMode ?? "unknown"; if (!ctx.toolState.selectedMode) { log.info(`» warning: delegating without calling select_mode first (mode=${mode})`); } - const subagent = createSubagentState({ ctx, mode }); - log.info(`» delegating subagent=${subagent.id} (mode=${mode}, effort=${effort})`); - const result = await runSubagent({ - ctx, - subagent, - effort, - instructions: params.instructions, + // matched by delegate test validators — update tests if changed + log.info(`» delegating ${params.tasks.length} task(s) in parallel (mode=${mode})`); + + const taskEntries = params.tasks.map((task) => { + const effort = task.effort ?? "auto"; + const subagent = createSubagentState({ ctx, mode, label: task.label }); + log.info(`» task "${task.label}" (effort=${effort})`); + return { task, effort, subagent }; }); - log.info(`» delegation completed (mode=${mode}, success=${result.success})`); - return { - success: result.success, - mode, - effort, - summary: - subagent.output ?? - result.error ?? - "no output produced — the subagent may not have called set_output. check stdoutFile for full logs.", - stdoutFile: subagent.stdoutFilePath, - error: result.error, - }; + const settled = await Promise.allSettled( + taskEntries.map((entry) => + runSubagent({ + ctx, + subagent: entry.subagent, + effort: entry.effort, + instructions: entry.task.instructions, + }) + ) + ); + + const results: DelegateTaskResult[] = taskEntries.map((entry, i) => { + const outcome = settled[i]; + const error = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error; + log.debug( + `» task "${entry.task.label}" result: output=${entry.subagent.output !== undefined}, status=${entry.subagent.status}` + ); + return buildTaskResult(entry.task.label, entry.effort, entry.subagent, error); + }); + + const succeeded = results.filter((r) => r.success).length; + log.info(`» delegation completed: ${succeeded}/${results.length} succeeded (mode=${mode})`); + + return { mode, results }; }), }); } diff --git a/mcp/file.ts b/mcp/file.ts index fdbdf4b..1f25570 100644 --- a/mcp/file.ts +++ b/mcp/file.ts @@ -7,7 +7,7 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { type } from "arktype"; import type { ShellPermission } from "../external.ts"; import type { ToolContext } from "./server.ts"; @@ -59,6 +59,15 @@ function resolveReadPath(filePath: string): string { return resolved; } + // allow reads from Cursor's project directory (internal agent coordination files) + const home = process.env.HOME; + if (home) { + const cursorProjectsDir = join(home, ".cursor", "projects"); + if (resolved.startsWith(cursorProjectsDir + "/")) { + return resolved; + } + } + // allow reads from the repo with symlink protection. // threat model: a malicious PR plants symlinks (e.g. `secrets -> /etc/shadow`). // git materializes symlinks on linux, so after checkout the working tree contains diff --git a/mcp/output.ts b/mcp/output.ts index 31c61eb..0e25bb4 100644 --- a/mcp/output.ts +++ b/mcp/output.ts @@ -14,15 +14,18 @@ export function SetOutputTool(ctx: ToolContext) { "Set the action output. When called by a subagent, returns a summary result to the orchestrator. When called in standalone mode, exposes the value as the 'result' GitHub Action output.", parameters: SetOutputParams, execute: execute(async (params) => { - const activeId = ctx.toolState.activeSubagentId; - if (activeId) { - const subagent = ctx.toolState.subagents.get(activeId); + const selfId = ctx.toolState.selfSubagentId; + if (selfId) { + const subagent = ctx.toolState.subagents.get(selfId); if (subagent) { subagent.output = params.value; + log.debug( + `set_output: routed to subagent ${selfId} (value=${params.value.slice(0, 80)})` + ); return { success: true, routed: "subagent" }; } log.warning( - `set_output: activeSubagentId=${activeId} but subagent not found in map — routing to action output` + `set_output: selfSubagentId=${selfId} but subagent not found in map — routing to action output` ); } ctx.toolState.output = params.value; diff --git a/mcp/selectMode.ts b/mcp/selectMode.ts index d16101d..6fb1d94 100644 --- a/mcp/selectMode.ts +++ b/mcp/selectMode.ts @@ -6,7 +6,7 @@ import { execute, tool } from "./shared.ts"; export const SelectModeParams = type({ mode: type.string.describe( - "the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'Fix', 'AddressReviews', 'Prompt')" + "the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'Fix', 'AddressReviews', 'Task')" ), }); @@ -15,7 +15,7 @@ function resolveMode(modes: Mode[], modeName: string): Mode | null { } function defaultGuidance(mode: Mode): string { - return `Delegate a single subagent for this "${mode.name}" task. Craft a self-contained prompt that includes all context the subagent needs. Include \`${ghPullfrogMcpName}/report_progress\` for user-facing updates and \`${ghPullfrogMcpName}/set_output\` to return results back to you. Subagents do NOT have push or PR creation tools — if the task involves code changes, you must push and create the PR yourself after the subagent completes.`; + return `Delegate a subagent for this "${mode.name}" task via the \`tasks\` array. Craft a self-contained prompt that includes all context the subagent needs. Subagents have file ops, bash, and read-only GitHub tools — but NO git/checkout, dependency, GitHub-write, or remote-mutating tools. All state-mutating and user-facing operations are your responsibility as orchestrator.`; } const modeGuidance: Record = { @@ -23,58 +23,77 @@ const modeGuidance: Record = { 1. **plan phase** (optional, for complex tasks): delegate a subagent to analyze the requirements, read AGENTS.md and relevant code, and produce a step-by-step implementation plan. Include \`${ghPullfrogMcpName}/set_output\` with the plan so it returns to you. Use mini or auto effort. You can also use \`ask_question\` for codebase questions/investigations. -2. **build phase**: delegate a subagent with the implementation task. Include in its prompt: +2. **setup** (your responsibility as orchestrator): before the build phase, checkout or create the branch: + - **PR event, modifying the existing PR**: call \`${ghPullfrogMcpName}/checkout_pr\` + - **new branch**: use \`${ghPullfrogMcpName}/git\` to create a branch (\`git checkout -b pullfrog/branch-name\`) + Subagents have no git/checkout tools — the working tree must be ready before delegation. + +3. **build phase**: delegate a subagent with the implementation task. Include in its prompt: - the plan (if you ran a plan phase) - specific files to modify and why - - branch naming: \`pullfrog/-\` - instruct the subagent to plan its approach before writing code: identify which files need to change, key design decisions, and edge cases. for non-trivial changes, consider whether there's a more elegant approach before committing to implementation. - testing expectations: run relevant tests/lints before committing - pre-commit quality check: instruct the subagent to review its own diff before committing — verify only intended changes are present, no debug artifacts or commented-out code remain, and no unrelated files were modified. the change should be clean enough that a senior engineer would approve it without hesitation. for non-trivial changes, ask whether there's a simpler way to achieve the same result. - - for multi-file changes, call \`${ghPullfrogMcpName}/report_progress\` with a summary of progress before the final commit - - commit changes locally (do NOT instruct to push or create a PR — subagents cannot do that) + - commit locally via bash (\`git add . && git commit -m "..."\`) - call \`${ghPullfrogMcpName}/set_output\` with a concise summary including the branch name (this is how results get back to you) -3. **review phase** (optional, for non-trivial changes): before pushing, delegate a review subagent to check the pending diff. Use \`ask_question\` for quick spot-checks, or delegate a full Review subagent for high-stakes changes. This catches issues before they're public. +4. **review phase** (optional, for non-trivial changes): before pushing, delegate a review subagent to check the pending diff. Use \`ask_question\` for quick spot-checks, or delegate a full Review subagent for high-stakes changes. This catches issues before they're public. -4. **finalize** (your responsibility as orchestrator): after the build (and optional review) completes: +5. **finalize** (your responsibility as orchestrator): after the build (and optional review) completes: - push the branch via \`${ghPullfrogMcpName}/push_branch\` - create a PR via \`${ghPullfrogMcpName}/create_pull_request\` - call \`${ghPullfrogMcpName}/report_progress\` with the final summary including PR link For simple, well-defined tasks, a single build subagent is sufficient — skip the plan and review phases. -Your subagent receives ONLY what you write. Include file paths, constraints, conventions, and any context from AGENTS.md or the codebase directly in the prompt. Subagents do NOT have push_branch, create_pull_request, or other remote-mutating tools.`, +Your subagent receives ONLY what you write. Include file paths, constraints, conventions, and any context from AGENTS.md or the codebase directly in the prompt. Subagents have file ops, bash, and read-only GitHub tools — but NO git/checkout, dependency, GitHub-write, or remote-mutating tools.`, - AddressReviews: `Delegate a single subagent to address PR review feedback: + AddressReviews: `Delegate a single subagent to address PR review feedback. + +Before delegating, checkout the PR branch yourself via \`${ghPullfrogMcpName}/checkout_pr\` — subagents have no git/checkout tools. Include in its prompt: -- the PR number to checkout via \`${ghPullfrogMcpName}/checkout_pr\` -- instruct it to fetch review comments via \`${ghPullfrogMcpName}/get_review_comments\` -- reply to EACH comment individually via \`${ghPullfrogMcpName}/reply_to_review_comment\` -- resolve threads via \`${ghPullfrogMcpName}/resolve_review_thread\` after addressing them +- instruct it to fetch review comments via \`${ghPullfrogMcpName}/get_review_comments\` (subagents have read-only GitHub tools) +- for each comment: understand the feedback, make the code change, and record what was done - test changes, then review the diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation -- commit locally (do NOT instruct to push — subagents cannot do that) -- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary -- call \`${ghPullfrogMcpName}/set_output\` with a concise summary of what was addressed (this is how results get back to you) +- commit locally via bash (\`git add . && git commit -m "..."\`) +- call \`${ghPullfrogMcpName}/set_output\` with a JSON object: \`{ "summary": "...", "replies": [{ "comment_id": 123, "thread_id": "...", "reply": "Fixed by ..." }, ...] }\` — this is how results get back to you -After the subagent completes, push the changes via \`${ghPullfrogMcpName}/push_branch\`. +After the subagent completes: +- push changes via \`${ghPullfrogMcpName}/push_branch\` +- reply to each comment using \`${ghPullfrogMcpName}/reply_to_review_comment\` with the subagent's suggested replies +- resolve addressed threads via \`${ghPullfrogMcpName}/resolve_review_thread\` +- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary Use auto or max effort depending on review complexity.`, - Review: `Before delegating, use \`ask_question\` to understand unfamiliar parts of the codebase the PR touches. This gives you context to craft a more focused review prompt (e.g., "pay special attention to how X interacts with Y"). For complex or high-stakes PRs, consider a two-phase approach: delegate a Plan subagent to analyze the PR and identify high-risk areas, then delegate a Review subagent with those focus areas as instructions. + Review: `For reviews, delegate multiple focused subagents in parallel — each investigating a different area or aspect of the PR. -Delegate a review subagent with: +### Approach -Include in its prompt: -- the PR number to checkout via \`${ghPullfrogMcpName}/checkout_pr\` -- what aspects to focus on (if any specific concerns exist, or high-risk areas you identified) -- instruct it to plan its investigation before diving in: after reading the diff, identify the highest-risk areas (tricky state transitions, boundary crossings, assumption chains) and prioritize depth over breadth -- instruct it to read the diff, trace data flow, check boundaries, and verify assumptions +1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`. Read the diff to identify the major areas of change. +2. Delegate multiple subagents in a single \`${ghPullfrogMcpName}/delegate\` call, each focused on a specific area. For example, a PR touching action/, components/, and prisma/ might get three subagents: "action-review", "frontend-review", "schema-review". +3. After all subagents return, consolidate their findings into a single review. + +### Crafting each task + +Each task in the \`tasks\` array should include: +- the diff file path so the subagent can read it +- what specific area/aspect to focus on (e.g., "review the database migration and schema changes in prisma/") +- instruct it to read the diff, trace data flow, check boundaries, and verify assumptions within its area. subagents have read-only GitHub tools (\`${ghPullfrogMcpName}/get_pull_request\`, etc.) for fetching additional context. +- instruct it to plan its investigation before diving in: identify the highest-risk areas (tricky state transitions, boundary crossings, assumption chains) and prioritize depth over breadth - draft inline comments with NEW line numbers from the diff — every comment must be actionable (2-3 sentences max) -- after drafting, instruct it to critique its own comments: drop any that are praise, style preferences, speculative/unverified claims, about pre-existing code unrelated to the PR, or not actionable. if no comments survive, do not submit a review — use \`report_progress\` instead. -- submit surviving comments via \`${ghPullfrogMcpName}/create_pull_request_review\` +- after drafting, instruct it to critique its own comments: drop any that are praise, style preferences, speculative/unverified claims, about pre-existing code unrelated to the PR, or not actionable - use GitHub permalink format for code references -- call \`${ghPullfrogMcpName}/set_output\` with a concise review summary (this is how results get back to you) +- call \`${ghPullfrogMcpName}/set_output\` with a JSON object: \`{ "summary": "...", "comments": [{ "path": "file.ts", "line": 42, "body": "..." }, ...] }\` — this is how findings get back to you + +### Post-delegation + +After all tasks complete, consolidate into a **single** review: +- merge the \`comments\` arrays from all subagent outputs +- submit one \`${ghPullfrogMcpName}/create_pull_request_review\` with the merged comments and a unified summary body +- call \`${ghPullfrogMcpName}/report_progress\` with the summary +- if no subagent found actionable issues, skip the review — just call \`report_progress\` noting the PR was reviewed Use max effort for thorough reviews.`, @@ -84,40 +103,54 @@ Include in its prompt: - the task to plan for - relevant codebase context (file paths, architecture notes from AGENTS.md) - instruct it to produce a structured, actionable plan with clear milestones -- call \`${ghPullfrogMcpName}/report_progress\` with the plan - call \`${ghPullfrogMcpName}/set_output\` with the plan (this is how results get back to you — you'll need the plan to craft the next subagent's prompt) +After the subagent completes, call \`${ghPullfrogMcpName}/report_progress\` with the plan. + Use mini or auto effort. After receiving the plan, you may delegate a Build subagent to implement it.`, - Fix: `For CI fix tasks, consider a focused single-phase approach: + Fix: `For CI fix tasks, consider a focused single-phase approach. + +Before delegating, checkout the PR branch yourself via \`${ghPullfrogMcpName}/checkout_pr\` — subagents have no git/checkout tools. Delegate a single fix subagent with: -- the check_suite_id to fetch logs via \`${ghPullfrogMcpName}/get_check_suite_logs\` -- the PR number to checkout via \`${ghPullfrogMcpName}/checkout_pr\` +- the check_suite_id to fetch logs via \`${ghPullfrogMcpName}/get_check_suite_logs\` (subagents have read-only GitHub tools) +- the PR diff file path (from checkout_pr result) so it can understand what the PR changed - CRITICAL: instruct it to verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report. - instruct it to read the workflow file, reproduce locally with the EXACT same commands CI runs -- after analyzing the failure, call \`${ghPullfrogMcpName}/report_progress\` with the diagnosis: what failed, why, and the planned fix — this gives the PR author visibility before code changes begin - fix the issue, then verify the fix by re-running the exact CI command - pre-commit quality check: review the diff before committing — verify only the fix is present, no debug artifacts, no unrelated changes. the fix should be clean enough that a senior engineer would approve it without hesitation. -- commit locally (do NOT instruct to push — subagents cannot do that) -- call \`${ghPullfrogMcpName}/set_output\` with a concise summary of the fix (this is how results get back to you) +- commit locally via bash (\`git add . && git commit -m "..."\`) +- call \`${ghPullfrogMcpName}/set_output\` with a concise summary: what failed, why, and the fix applied (this is how results get back to you) -After the subagent completes, push the changes via \`${ghPullfrogMcpName}/push_branch\`. +After the subagent completes: +- push changes via \`${ghPullfrogMcpName}/push_branch\` +- call \`${ghPullfrogMcpName}/report_progress\` with the diagnosis and fix summary Use auto effort.`, - Prompt: `Delegate a single subagent for this general-purpose task: + Task: `Handle this general-purpose task. For simple operations (labeling, commenting, answering questions, running a single command), you can often handle it directly without delegation. -Include in its prompt: -- the full task description with all relevant context +When the task involves **substantial work** — code changes across multiple files, multi-step investigations, or tasks that benefit from focused context — use \`delegate\` and \`ask_question\` liberally: + +- \`ask_question\`: quick codebase research, finding files, understanding architecture. Use freely — multiple calls in sequence is fine. +- \`delegate\`: research, local coding tasks, and codebase investigations. Each subagent gets dedicated context, so break complex work into focused subtasks and delegate each one. For independent subtasks, batch them in a single \`${ghPullfrogMcpName}/delegate\` call to run in parallel. + +### When delegating + +Include in each task's prompt: +- the full subtask description with all relevant context +- exactly what information to return. the subagent's output is your only way to get results back — be precise about what you need. - if code changes are needed: branch naming, testing, commit instructions (do NOT instruct to push or create PR) - if code changes are needed: instruct it to review its own diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation + +### Post-delegation + - call \`${ghPullfrogMcpName}/report_progress\` with results -- call \`${ghPullfrogMcpName}/set_output\` with a concise summary (this is how results get back to you) +- if the task involved code changes, push via \`${ghPullfrogMcpName}/push_branch\` and create a PR via \`${ghPullfrogMcpName}/create_pull_request\` +- if the task involved labeling, commenting, or other GitHub operations, perform those directly -If the task involved code changes, push via \`${ghPullfrogMcpName}/push_branch\` and create a PR via \`${ghPullfrogMcpName}/create_pull_request\` after the subagent completes. - -Use mini effort for simple tasks (labeling, commenting), auto for typical tasks.`, +Use mini effort for simple research tasks, auto for typical tasks, max for complex multi-file changes.`, }; type OrchestratorGuidance = { diff --git a/mcp/server.ts b/mcp/server.ts index 101d7bb..f0edf71 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -25,6 +25,7 @@ export type SubagentStatus = "running" | "completed" | "failed"; export type SubagentState = { id: string; + label: string; status: SubagentStatus; mode: string; stdoutFilePath: string; @@ -46,8 +47,9 @@ export interface ToolState { selectedMode?: string; // per-subagent lifecycle tracking (keyed by subagent uuid) subagents: Map; - // set while a subagent is running — routes set_output to the correct subagent and prevents nesting - activeSubagentId: string | undefined; + // only set on subagent shallow copies — routes set_output to the owning subagent. + // never set on the orchestrator's shared state. + selfSubagentId: string | undefined; backgroundProcesses: Map; review?: { id: number; @@ -81,7 +83,7 @@ export function initToolState(params: InitToolStateParams): ToolState { return { progressCommentId: resolvedId, subagents: new Map(), - activeSubagentId: undefined, + selfSubagentId: undefined, backgroundProcesses: new Map(), usageEntries: [], }; @@ -105,24 +107,6 @@ export interface ToolContext { tmpdir: string; } -/** - * tool names that are only available to the orchestrator. - * subagent MCP servers are started with these tools excluded. - * - * - delegation tools: only the orchestrator can spawn/manage subagents - * - remote-mutating tools: subagents work locally; the orchestrator pushes and creates PRs - */ -export const ORCHESTRATOR_ONLY_TOOLS = [ - "select_mode", - "delegate", - "ask_question", - "push_branch", - "push_tags", - "delete_branch", - "create_pull_request", - "update_pull_request_body", -] as const; - import { log } from "../utils/cli.ts"; import type { RunContextData } from "../utils/runContextData.ts"; import { AskQuestionTool } from "./askQuestion.ts"; @@ -204,27 +188,50 @@ function isAddressInUse(error: unknown): boolean { return message.includes("eaddrinuse") || message.includes("address already in use"); } -// tools shared by both orchestrator and subagent servers -function buildCommonTools(ctx: ToolContext): Tool[] { +// subagent tools: file ops, bash, read-only GitHub, upload, set_output. +// no git/checkout (mutates shared state), no dependencies (shared state), +// no GitHub-write (user-facing side effects), no delegation/remote-mutating. +function buildSubagentTools(ctx: ToolContext): Tool[] { const tools: Tool[] = [ - StartDependencyInstallationTool(ctx), - AwaitDependencyInstallationTool(ctx), - CreateCommentTool(ctx), - EditCommentTool(ctx), - ReplyToReviewCommentTool(ctx), - IssueTool(ctx), IssueInfoTool(ctx), GetIssueCommentsTool(ctx), GetIssueEventsTool(ctx), - CreatePullRequestReviewTool(ctx), + PullRequestInfoTool(ctx), + CommitInfoTool(ctx), + GetReviewCommentsTool(ctx), + ListPullRequestReviewsTool(ctx), + GetCheckSuiteLogsTool(ctx), + UploadFileTool(ctx), + SetOutputTool(ctx), + FileReadTool(ctx), + FileWriteTool(ctx), + FileEditTool(ctx), + FileDeleteTool(ctx), + ListDirectoryTool(ctx), + ]; + + if (ctx.payload.bash === "restricted") { + tools.push(BashTool(ctx)); + tools.push(KillBackgroundTool(ctx)); + } + + return tools; +} + +// orchestrator gets everything: file ops, bash, git, GitHub, delegation, remote-mutating +function buildOrchestratorTools(ctx: ToolContext): Tool[] { + const tools: Tool[] = [ + StartDependencyInstallationTool(ctx), + AwaitDependencyInstallationTool(ctx), + IssueInfoTool(ctx), + GetIssueCommentsTool(ctx), + GetIssueEventsTool(ctx), PullRequestInfoTool(ctx), CommitInfoTool(ctx), CheckoutPrTool(ctx), GetReviewCommentsTool(ctx), ListPullRequestReviewsTool(ctx), - ResolveReviewThreadTool(ctx), GetCheckSuiteLogsTool(ctx), - AddLabelsTool(ctx), GitTool(ctx), GitFetchTool(ctx), UploadFileTool(ctx), @@ -234,7 +241,22 @@ function buildCommonTools(ctx: ToolContext): Tool[] { FileEditTool(ctx), FileDeleteTool(ctx), ListDirectoryTool(ctx), + CreateCommentTool(ctx), + EditCommentTool(ctx), + ReplyToReviewCommentTool(ctx), + CreatePullRequestReviewTool(ctx), + ResolveReviewThreadTool(ctx), + IssueTool(ctx), + AddLabelsTool(ctx), ReportProgressTool(ctx), + SelectModeTool(ctx), + DelegateTool(ctx), + AskQuestionTool(ctx), + PushBranchTool(ctx), + PushTagsTool(ctx), + DeleteBranchTool(ctx), + CreatePullRequestTool(ctx), + UpdatePullRequestBodyTool(ctx), ]; // only add ShellTool when shell is "restricted" @@ -249,26 +271,6 @@ function buildCommonTools(ctx: ToolContext): Tool[] { return tools; } -// orchestrator gets common tools + delegation + remote-mutating tools -function buildOrchestratorTools(ctx: ToolContext): Tool[] { - return [ - ...buildCommonTools(ctx), - SelectModeTool(ctx), - DelegateTool(ctx), - AskQuestionTool(ctx), - PushBranchTool(ctx), - PushTagsTool(ctx), - DeleteBranchTool(ctx), - CreatePullRequestTool(ctx), - UpdatePullRequestBodyTool(ctx), - ]; -} - -// subagent gets only common tools (no delegation, no remote mutation) -function buildSubagentTools(ctx: ToolContext): Tool[] { - return buildCommonTools(ctx); -} - type McpStartResult = { server: FastMCP; url: string; @@ -391,21 +393,30 @@ export type ManagedMcpServer = { stop: () => Promise; }; +type StartSubagentMcpServerParams = { + ctx: ToolContext; + subagentId: string; +}; + /** * Start a per-subagent MCP server (common tools only — no push/PR/delegation). * Each subagent gets its own server; call stop() when the subagent completes. * * The subagent gets its own shallow copy of toolState so scalar writes * (pushUrl, pushDest, selectedMode, etc.) don't mutate the orchestrator's state. + * selfSubagentId is set on the copy so set_output routes to the correct subagent. * Shared references (subagents Map, usageEntries array, dependencyInstallation) * are intentionally shared for coordination (set_output routing, usage tracking). */ -export async function startSubagentMcpServer(ctx: ToolContext): Promise { +export async function startSubagentMcpServer( + params: StartSubagentMcpServerParams +): Promise { const subagentToolState: ToolState = { - ...ctx.toolState, + ...params.ctx.toolState, + selfSubagentId: params.subagentId, backgroundProcesses: new Map(), }; - const subagentCtx: ToolContext = { ...ctx, toolState: subagentToolState }; + const subagentCtx: ToolContext = { ...params.ctx, toolState: subagentToolState }; const tools = buildSubagentTools(subagentCtx); const startResult = await selectMcpPort(subagentCtx, tools); return { url: startResult.url, stop: () => startResult.server.stop() }; diff --git a/mcp/subagent.ts b/mcp/subagent.ts index 90dad32..9b1eb5e 100644 --- a/mcp/subagent.ts +++ b/mcp/subagent.ts @@ -1,7 +1,9 @@ +import { execSync } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { writeFileSync } from "node:fs"; +import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { Effort } from "../external.ts"; +import { ghPullfrogMcpName } from "../external.ts"; import { markActivity } from "../utils/activity.ts"; import type { ResolvedInstructions } from "../utils/instructions.ts"; import { type SubagentState, startSubagentMcpServer, type ToolContext } from "./server.ts"; @@ -9,13 +11,24 @@ import { type SubagentState, startSubagentMcpServer, type ToolContext } from "./ type CreateSubagentParams = { ctx: ToolContext; mode: string; + label: string; }; +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 60); +} + export function createSubagentState(params: CreateSubagentParams): SubagentState { const id = randomUUID(); - const stdoutFilePath = join(params.ctx.tmpdir, `subagent-${id}.log`); + const slug = slugify(params.label); + const stdoutFilePath = join(params.ctx.tmpdir, `subagent-${slug || id}.log`); const state: SubagentState = { id, + label: params.label, status: "running", mode: params.mode, stdoutFilePath, @@ -25,7 +38,6 @@ export function createSubagentState(params: CreateSubagentParams): SubagentState keepAliveInterval: undefined, }; params.ctx.toolState.subagents.set(id, state); - params.ctx.toolState.activeSubagentId = id; return state; } @@ -44,15 +56,62 @@ function completeSubagent(params: CompleteSubagentParams): void { if (params.subagent.usage) { params.ctx.toolState.usageEntries.push(params.subagent.usage); } - params.ctx.toolState.activeSubagentId = undefined; - // keep completed subagents in the map for post-completion inspection } -export function buildSubagentInstructions(orchestratorPrompt: string): ResolvedInstructions { +export function hasRunningSubagents(ctx: ToolContext): boolean { + for (const s of ctx.toolState.subagents.values()) { + if (s.status === "running") return true; + } + return false; +} + +const subagentSystemPreamble = `You are a focused subagent. Complete the task autonomously — no follow-up questions. Minimize token usage. + +## Tools + +Your tools are limited to: +- **File operations**: \`${ghPullfrogMcpName}/file_read\`, \`file_write\`, \`file_edit\`, \`file_delete\`, \`list_directory\`. Native file tools (Read, Write, StrReplace, etc.) are disabled — use the MCP versions. +- **Shell**: \`${ghPullfrogMcpName}/bash\` (if available). Use this for local git operations (\`git add\`, \`git commit\`, \`git diff\`, \`git log\`, \`git status\`), running tests, builds, and linters. +- **Read-only GitHub**: \`get_pull_request\`, \`get_issue\`, \`get_issue_comments\`, \`get_issue_events\`, \`get_review_comments\`, \`list_pull_request_reviews\`, \`get_check_suite_logs\`, \`get_commit_info\`. +- **Output**: \`${ghPullfrogMcpName}/upload_file\`, \`${ghPullfrogMcpName}/set_output\`. + +## Output + +When you finish, you MUST call \`${ghPullfrogMcpName}/set_output\` with your results. This is how your work gets back to the orchestrator — if you don't call it, your output is lost. Structure output as the instructions request. For research tasks, use well-organized markdown.`; + +type BuildSubagentInstructionsParams = { + ctx: ToolContext; + label: string; + instructions: string; +}; + +function buildResolvedContext(params: BuildSubagentInstructionsParams): string { + let branch = "unknown"; + try { + branch = execSync("git branch --show-current", { encoding: "utf-8", stdio: "pipe" }).trim(); + } catch { + // git not available + } + + const lines = [ + `repo: ${params.ctx.repo.owner}/${params.ctx.repo.name}`, + `branch: ${branch}`, + `working_directory: ${process.cwd()}`, + `subagent_label: ${params.label}`, + ]; + + return `[CONTEXT]\n${lines.join("\n")}`; +} + +export function buildSubagentInstructions( + params: BuildSubagentInstructionsParams +): ResolvedInstructions { + const resolvedContext = buildResolvedContext(params); + const full = `${resolvedContext}\n\n${subagentSystemPreamble}\n\n---\n\n${params.instructions}`; return { - full: orchestratorPrompt, - system: "", - user: orchestratorPrompt, + full, + system: subagentSystemPreamble, + user: params.instructions, eventInstructions: "", repo: "", event: "", @@ -74,14 +133,24 @@ type RunSubagentResult = { export async function runSubagent(params: RunSubagentParams): Promise { params.subagent.keepAliveInterval = setInterval(markActivity, 30_000); - const mcpServer = await startSubagentMcpServer(params.ctx); + const mcpServer = await startSubagentMcpServer({ + ctx: params.ctx, + subagentId: params.subagent.id, + }); + // each subagent gets its own tmpdir so parallel agents don't clobber config files + const subagentTmpdir = join(params.ctx.tmpdir, params.subagent.id); + mkdirSync(subagentTmpdir, { recursive: true }); try { const subagentPayload = { ...params.ctx.payload, effort: params.effort }; - const subagentInstructions = buildSubagentInstructions(params.instructions); + const subagentInstructions = buildSubagentInstructions({ + ctx: params.ctx, + label: params.subagent.label, + instructions: params.instructions, + }); const result = await params.ctx.agent.run({ payload: subagentPayload, mcpServerUrl: mcpServer.url, - tmpdir: params.ctx.tmpdir, + tmpdir: subagentTmpdir, instructions: subagentInstructions, }); params.subagent.usage = result.usage; diff --git a/mcp/toolFiltering.test.ts b/mcp/toolFiltering.test.ts index 10baa36..4285d6c 100644 --- a/mcp/toolFiltering.test.ts +++ b/mcp/toolFiltering.test.ts @@ -4,41 +4,26 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { type } from "arktype"; import { FastMCP } from "fastmcp"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { ORCHESTRATOR_ONLY_TOOLS } from "./server.ts"; import { execute, tool } from "./shared.ts"; import { buildSubagentInstructions } from "./subagent.ts"; -// ─── unit tests for pure exported functions ───────────────────────────── - -describe("ORCHESTRATOR_ONLY_TOOLS", () => { - it("includes delegation tools", () => { - expect(ORCHESTRATOR_ONLY_TOOLS).toContain("select_mode"); - expect(ORCHESTRATOR_ONLY_TOOLS).toContain("delegate"); - expect(ORCHESTRATOR_ONLY_TOOLS).toContain("ask_question"); - }); - - it("includes remote-mutating tools", () => { - expect(ORCHESTRATOR_ONLY_TOOLS).toContain("push_branch"); - expect(ORCHESTRATOR_ONLY_TOOLS).toContain("push_tags"); - expect(ORCHESTRATOR_ONLY_TOOLS).toContain("delete_branch"); - expect(ORCHESTRATOR_ONLY_TOOLS).toContain("create_pull_request"); - expect(ORCHESTRATOR_ONLY_TOOLS).toContain("update_pull_request_body"); - }); -}); - describe("buildSubagentInstructions", () => { - it("returns clean-room instructions with only the orchestrator prompt", () => { + it("includes system preamble, resolved context, and orchestrator prompt", () => { const prompt = "Read file.ts and fix the type error."; - const instructions = buildSubagentInstructions(prompt); - expect(instructions).toEqual({ - full: prompt, - system: "", - user: prompt, - eventInstructions: "", - repo: "", - event: "", - runtime: "", + const ctx = { + repo: { owner: "test-owner", name: "test-repo" }, + } as any; + const instructions = buildSubagentInstructions({ + ctx, + label: "test-task", + instructions: prompt, }); + expect(instructions.user).toBe(prompt); + expect(instructions.full).toContain("[CONTEXT]"); + expect(instructions.full).toContain("test-owner/test-repo"); + expect(instructions.full).toContain("subagent_label: test-task"); + expect(instructions.full).toContain("set_output"); + expect(instructions.full).toContain(prompt); }); }); @@ -97,10 +82,9 @@ describe("per-server tool isolation - integration", () => { orchestratorServer.addTool(mockTool("push_branch", "push branch")); orchestratorServer.addTool(mockTool("create_pull_request", "create PR")); - // subagent gets ONLY common tools (no delegation, no remote mutation) + // subagent gets ONLY file ops, bash, read-only GitHub, upload, set_output subagentServer = new FastMCP({ name: "subagent", version: "0.0.1" }); subagentServer.addTool(mockTool("file_read", "read a file")); - subagentServer.addTool(mockTool("git", "run git commands")); subagentServer.addTool(mockTool("set_output", "set output")); await Promise.all([ @@ -142,7 +126,7 @@ describe("per-server tool isolation - integration", () => { expect(names.length).toBe(8); }); - it("subagent cannot see delegation or mutation tools", async () => { + it("subagent cannot see orchestrator-only tools", async () => { const client = await connectMcpClient(subagentUrl); clients.push(client); const result = await client.listTools(); @@ -152,16 +136,16 @@ describe("per-server tool isolation - integration", () => { expect(names).not.toContain("ask_question"); expect(names).not.toContain("push_branch"); expect(names).not.toContain("create_pull_request"); + expect(names).not.toContain("git"); }); - it("subagent sees only common tools", async () => { + it("subagent sees only file ops, read-only tools, and set_output", async () => { const client = await connectMcpClient(subagentUrl); clients.push(client); const result = await client.listTools(); const names = result.tools.map((t) => t.name); expect(names).toContain("file_read"); - expect(names).toContain("git"); expect(names).toContain("set_output"); - expect(names.length).toBe(3); + expect(names.length).toBe(2); }); }); diff --git a/modes.ts b/modes.ts index 84134d2..22b9e41 100644 --- a/modes.ts +++ b/modes.ts @@ -29,8 +29,8 @@ export function computeModes(): Mode[] { "Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details", prompt: `Follow these steps exactly. -1. **BRANCH** - Determine whether to work on the current branch or create a new one: - - **PR event, modifying the existing PR**: The PR branch is probably already checked out. Continue on this branch. +1. **CHECKOUT** - Determine whether to checkout the existing PR branch or create a new one: + - **PR event, modifying the existing PR**: Call \`${ghPullfrogMcpName}/checkout_pr\` with the PR number to checkout the PR branch. - **PR event, but user wants a NEW branch/PR**: Create a new branch with \`git checkout -b pullfrog/branch-name\` via the \`${ghPullfrogMcpName}/git\` tool. Branch names must be prefixed with "pullfrog/" and be specific enough to avoid collisions. Never commit directly to main/master/production. @@ -214,7 +214,7 @@ ${permalinkTip}`, Your job is to fix issues THIS PR introduced, not to fix all CI failures. If in doubt about causation, abort and explain rather than making speculative changes.`, }, { - name: "Prompt", + name: "Task", description: "General-purpose tasks that don't fit other modes: answering questions, adding comments, labeling, running ad-hoc commands, or any direct request", prompt: `Follow these steps. THINK HARDER. diff --git a/package.json b/package.json index c234779..01be030 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pullfrog/pullfrog", - "version": "0.0.169", + "version": "0.0.170", "type": "module", "files": [ "index.js", diff --git a/play.ts b/play.ts index 7578cfd..200a5ee 100644 --- a/play.ts +++ b/play.ts @@ -21,7 +21,13 @@ import { setupTestRepo } from "./utils/setup.ts"; */ export const playFixture = defineFixture( { - prompt: `What is 2 + 2? Reply with just the number.`, + prompt: `Select Plan mode, then delegate a single task: + +tasks: [ + { label: "tool-audit", instructions: "List every MCP tool you have access to. Call set_output with a JSON array of all tool names you can see.", effort: "mini" } +] + +After it completes, call set_output with the subagent's result verbatim.`, effort: "mini", }, { localOnly: true } diff --git a/post b/post index f6757ed..9b45d9c 100755 --- a/post +++ b/post @@ -41235,7 +41235,7 @@ var Effort = type.enumerated("mini", "auto", "max"); // package.json var package_default = { name: "@pullfrog/pullfrog", - version: "0.0.169", + version: "0.0.170", type: "module", files: [ "index.js", diff --git a/test/adhoc/delegateAskQuestion.ts b/test/adhoc/delegateAskQuestion.ts index b283c33..cb8687e 100644 --- a/test/adhoc/delegateAskQuestion.ts +++ b/test/adhoc/delegateAskQuestion.ts @@ -41,8 +41,8 @@ function validator(result: AgentResult): ValidationCheck[] { const hasFilesFound = setOutputCalled && /FILES_FOUND=true/i.test(output ?? ""); const countMatch = output ? /COUNT=(\d+)/i.exec(output) : null; const hasFileCount = countMatch !== null && parseInt(countMatch[1], 10) > 0; - const askQuestionUsed = /» ask_question subagent=/i.test(agentOutput); - const delegationOccurred = /» delegating subagent=/i.test(agentOutput); + const askQuestionUsed = /» ask_question "/i.test(agentOutput); + const delegationOccurred = /» delegating \d+ task/i.test(agentOutput); return [ { name: "set_output", passed: setOutputCalled }, diff --git a/test/adhoc/delegateContextIsolation.ts b/test/adhoc/delegateContextIsolation.ts index bcf799e..fa1d71d 100644 --- a/test/adhoc/delegateContextIsolation.ts +++ b/test/adhoc/delegateContextIsolation.ts @@ -47,7 +47,7 @@ function validator(result: AgentResult): ValidationCheck[] { // some agents paraphrase or truncate, so matching the first 8 hex chars is sufficient. const secretPrefix = SECRET.slice(0, 8); const secretInOutput = setOutputCalled && output !== null && output.includes(secretPrefix); - const delegationOccurred = /» delegating subagent=/i.test(agentOutput); + const delegationOccurred = /» delegating \d+ task/i.test(agentOutput); // the subagent's context report should NOT contain any part of the secret const subagentMatch = output ? /SUBAGENT_SAID=([\s\S]*)/i.exec(output) : null; diff --git a/test/adhoc/delegateErrorHandling.ts b/test/adhoc/delegateErrorHandling.ts index 297e361..7b1d272 100644 --- a/test/adhoc/delegateErrorHandling.ts +++ b/test/adhoc/delegateErrorHandling.ts @@ -39,7 +39,7 @@ function validator(result: AgentResult): ValidationCheck[] { const setOutputCalled = output !== null; const errorHandled = setOutputCalled && /ERROR_HANDLED=true/i.test(output ?? ""); const hasReason = setOutputCalled && /REASON=\S+/i.test(output ?? ""); - const delegationOccurred = /» delegating subagent=/i.test(agentOutput); + const delegationOccurred = /» delegating \d+ task/i.test(agentOutput); return [ { name: "set_output", passed: setOutputCalled }, diff --git a/test/adhoc/delegateFileRead.ts b/test/adhoc/delegateFileRead.ts index 3536871..e6668d8 100644 --- a/test/adhoc/delegateFileRead.ts +++ b/test/adhoc/delegateFileRead.ts @@ -39,7 +39,7 @@ function validator(result: AgentResult): ValidationCheck[] { const setOutputCalled = output !== null; const linesMatch = output ? /LINES=(\d+)/i.exec(output) : null; const hasLineCount = linesMatch !== null && parseInt(linesMatch[1], 10) > 0; - const delegationOccurred = /» delegating subagent=/i.test(agentOutput); + const delegationOccurred = /» delegating \d+ task/i.test(agentOutput); return [ { name: "set_output", passed: setOutputCalled }, diff --git a/test/adhoc/delegateSynthesis.ts b/test/adhoc/delegateSynthesis.ts index 128dc48..5d9d5ca 100644 --- a/test/adhoc/delegateSynthesis.ts +++ b/test/adhoc/delegateSynthesis.ts @@ -46,7 +46,7 @@ function validator(result: AgentResult): ValidationCheck[] { const setOutputCalled = output !== null; // should have two delegation calls - const delegationMatches = agentOutput.match(/» delegating subagent=/g); + const delegationMatches = agentOutput.match(/» delegating \d+ task/g); const twoDelegations = delegationMatches !== null && delegationMatches.length >= 2; // FIRST_LINE should be a non-empty string (the first line of README.md) diff --git a/test/adhoc/delegateTimeout.ts b/test/adhoc/delegateTimeout.ts index 17b1e50..6709377 100644 --- a/test/adhoc/delegateTimeout.ts +++ b/test/adhoc/delegateTimeout.ts @@ -37,7 +37,7 @@ function validator(result: AgentResult): ValidationCheck[] { const setOutputCalled = output !== null; const correctValue = setOutputCalled && /DELEGATE_TIMEOUT_PASSED/i.test(output); - const delegationOccurred = /» delegating subagent=/i.test(agentOutput); + const delegationOccurred = /» delegating \d+ task/i.test(agentOutput); const noActivityTimeout = !/activity timeout/i.test(agentOutput); return [ diff --git a/test/adhoc/delegateTwoPhase.ts b/test/adhoc/delegateTwoPhase.ts index 24d1e7f..ec6c3fe 100644 --- a/test/adhoc/delegateTwoPhase.ts +++ b/test/adhoc/delegateTwoPhase.ts @@ -51,7 +51,7 @@ function validator(result: AgentResult): ValidationCheck[] { const setOutputCalled = output !== null; // two delegation calls should appear in logs - const delegationMatches = agentOutput.match(/» delegating subagent=/g); + const delegationMatches = agentOutput.match(/» delegating \d+ task/g); const twoDelegations = delegationMatches !== null && delegationMatches.length >= 2; // the marker should appear in both WRITTEN= and READ= sections. diff --git a/test/agnostic/delegate.ts b/test/agnostic/delegate.ts index a6b40a5..3c9070c 100644 --- a/test/agnostic/delegate.ts +++ b/test/agnostic/delegate.ts @@ -25,7 +25,7 @@ function validator(result: AgentResult): ValidationCheck[] { const setOutputCalled = output !== null; const correctValue = setOutputCalled && /DELEGATE_BASIC_PASSED/i.test(output); - const delegationOccurred = /» delegating subagent=/i.test(agentOutput); + const delegationOccurred = /» delegating \d+ task/i.test(agentOutput); return [ { name: "set_output", passed: setOutputCalled }, diff --git a/test/agnostic/delegateMulti.ts b/test/agnostic/delegateMulti.ts index b8ca854..35023de 100644 --- a/test/agnostic/delegateMulti.ts +++ b/test/agnostic/delegateMulti.ts @@ -4,8 +4,8 @@ import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts" /** * delegateMulti test - validates multi-phase delegation with context passing. * - * the orchestrator delegates twice: - * 1. first to Plan mode (subagent calls set_output with PHASE_1_MARKER) + * the orchestrator delegates twice using the tasks array API: + * 1. first to Plan mode with a single-task array (subagent calls set_output with PHASE_1_MARKER) * 2. then to Plan mode again with context from phase 1 (subagent calls set_output with MULTI_DELEGATE_PASSED) * * validates that both delegations executed and the final set_output value is correct. @@ -13,13 +13,11 @@ import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts" const fixture = defineFixture( { - prompt: `This is a multi-delegation test. You must delegate exactly twice: + prompt: `This is a multi-delegation test. You must delegate exactly twice using the tasks array format. -Phase 1: Select Plan mode via select_mode, then delegate with mini effort. Your subagent instructions: -"Your task is to call set_output with the value 'PHASE_1_MARKER'. Do not create plans or PRs." +Phase 1: Select Plan mode via select_mode, then delegate with tasks: [{ label: "phase-1", instructions: "Your task is to call set_output with the value 'PHASE_1_MARKER'. Do not create plans or PRs.", effort: "mini" }] -Phase 2: After Phase 1 completes, select Plan mode again and delegate with mini effort. Include the result from Phase 1. Your subagent instructions: -"Your task is to call set_output with the value 'MULTI_DELEGATE_PASSED'. Do not create plans or PRs." +Phase 2: After Phase 1 completes, select Plan mode again and delegate with tasks: [{ label: "phase-2", instructions: "Your task is to call set_output with the value 'MULTI_DELEGATE_PASSED'. Do not create plans or PRs.", effort: "mini" }]. Include the result from Phase 1 in the instructions if you want. Both delegations must complete successfully.`, effort: "mini", @@ -36,7 +34,7 @@ function validator(result: AgentResult): ValidationCheck[] { // the last set_output call wins — should be from Phase 2 const finalValue = setOutputCalled && /MULTI_DELEGATE_PASSED/i.test(output); - const delegationMatches = agentOutput.match(/» delegating subagent=/g); + const delegationMatches = agentOutput.match(/» delegating \d+ task/g); const twoDelegations = delegationMatches !== null && delegationMatches.length >= 2; return [ diff --git a/utils/activity.ts b/utils/activity.ts index 3e0a276..823fa18 100644 --- a/utils/activity.ts +++ b/utils/activity.ts @@ -110,6 +110,7 @@ export function createProcessOutputActivityTimeout(ctx: ActivityTimeoutContext): if (monitor) { monitor.stop(); } + // matched by delegateTimeout test validator — update tests if changed rejectFn(new Error(`activity timeout: no output for ${idleSec}s`)); }, }); diff --git a/utils/install.ts b/utils/install.ts index d8d53d1..5bc20ae 100644 --- a/utils/install.ts +++ b/utils/install.ts @@ -53,6 +53,17 @@ interface NpmRegistryData { * The temp directory will be cleaned up by the OS automatically */ export async function installFromNpmTarball(params: InstallFromNpmTarballParams): Promise { + const tempDir = process.env.PULLFROG_TEMP_DIR; + if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set"); + + const extractedDir = join(tempDir, "package"); + const cliPath = join(extractedDir, params.executablePath); + + if (existsSync(cliPath)) { + log.debug(`» using cached binary at ${cliPath}`); + return cliPath; + } + // Resolve version if it's a range or "latest" let resolvedVersion = params.version; if ( @@ -80,9 +91,6 @@ export async function installFromNpmTarball(params: InstallFromNpmTarballParams) log.debug(`» installing ${params.packageName}@${resolvedVersion}...`); - const tempDir = process.env.PULLFROG_TEMP_DIR; - if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set"); - const tarballPath = join(tempDir, "package.tgz"); // Download tarball from npm @@ -121,10 +129,6 @@ export async function installFromNpmTarball(params: InstallFromNpmTarballParams) ); } - // Find executable in the extracted package - const extractedDir = join(tempDir, "package"); - const cliPath = join(extractedDir, params.executablePath); - if (!existsSync(cliPath)) { throw new Error(`Executable not found in extracted package at ${cliPath}`); } @@ -189,6 +193,19 @@ async function fetchWithRetry( * The temp directory will be cleaned up by the OS automatically */ export async function installFromGithub(params: InstallFromGithubParams): Promise { + // use a deterministic subdir in PULLFROG_TEMP_DIR so repeated calls are cached + const pullfrogTemp = process.env.PULLFROG_TEMP_DIR; + const installDir = pullfrogTemp + ? join(pullfrogTemp, `github-${params.owner}-${params.repo}`) + : await mkdtemp(join(tmpdir(), `${params.owner}-${params.repo}-github-`)); + + const expectedCliPath = join(installDir, params.executablePath ?? params.assetName ?? "asset"); + + if (existsSync(expectedCliPath)) { + log.debug(`» using cached binary at ${expectedCliPath}`); + return expectedCliPath; + } + log.info(`» installing ${params.owner}/${params.repo} from GitHub releases...`); // fetch release from GitHub API (pinned tag or latest) @@ -222,14 +239,12 @@ export async function installFromGithub(params: InstallFromGithubParams): Promis log.debug(`» downloading asset from ${assetUrl}...`); - // create temp directory - const tempDirPrefix = `${params.owner}-${params.repo}-github-`; - const tempDirPath = await mkdtemp(join(tmpdir(), tempDirPrefix)); + mkdirSync(installDir, { recursive: true }); // determine file extension and download path const urlPath = new URL(assetUrl).pathname; const fileName = urlPath.split("/").pop() || "asset"; - const downloadPath = join(tempDirPath, fileName); + const downloadPath = join(installDir, fileName); // download the asset const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset"); @@ -240,13 +255,7 @@ export async function installFromGithub(params: InstallFromGithubParams): Promis log.debug(`» downloaded asset to ${downloadPath}`); // determine the executable path - let cliPath: string; - if (params.executablePath) { - cliPath = join(tempDirPath, params.executablePath); - } else { - // no executablePath, assume the downloaded file is the executable - cliPath = downloadPath; - } + const cliPath = params.executablePath ? join(installDir, params.executablePath) : downloadPath; if (!existsSync(cliPath)) { throw new Error(`Executable not found at ${cliPath}`); @@ -266,6 +275,16 @@ export async function installFromGithub(params: InstallFromGithubParams): Promis export async function installFromGithubTarball( params: InstallFromGithubTarballParams ): Promise { + const tempDir = process.env.PULLFROG_TEMP_DIR; + if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set"); + + const cliPath = join(tempDir, params.executablePath); + + if (existsSync(cliPath)) { + log.debug(`» using cached binary at ${cliPath}`); + return cliPath; + } + log.info(`» installing ${params.owner}/${params.repo} from GitHub releases...`); // determine platform-specific asset name @@ -304,9 +323,6 @@ export async function installFromGithubTarball( log.debug(`» downloading asset from ${assetUrl}...`); - const tempDir = process.env.PULLFROG_TEMP_DIR; - if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set"); - const tarballPath = join(tempDir, assetName); // download the asset @@ -329,9 +345,6 @@ export async function installFromGithubTarball( ); } - // find executable in the extracted tarball - const cliPath = join(tempDir, params.executablePath); - if (!existsSync(cliPath)) { throw new Error(`Executable not found in extracted tarball at ${cliPath}`); } @@ -351,11 +364,19 @@ export async function installFromGithubTarball( export async function installFromDirectTarball( params: InstallFromDirectTarballParams ): Promise { - log.info(`» downloading tarball from ${params.url}...`); - const tempDir = process.env.PULLFROG_TEMP_DIR; if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set"); + const extractDir = join(tempDir, "direct-package"); + const cliPath = join(extractDir, params.executablePath); + + if (existsSync(cliPath)) { + log.debug(`» using cached binary at ${cliPath}`); + return cliPath; + } + + log.info(`» downloading tarball from ${params.url}...`); + const tarballPath = join(tempDir, "direct-package.tgz"); const response = await fetchWithRetry(params.url, {}, "failed to download tarball"); @@ -365,8 +386,6 @@ export async function installFromDirectTarball( await pipeline(response.body, fileStream); log.debug(`» downloaded tarball to ${tarballPath}`); - // always extract into a dedicated directory - const extractDir = join(tempDir, "direct-package"); mkdirSync(extractDir, { recursive: true }); const tarArgs = ["-xzf", tarballPath, "-C", extractDir]; @@ -385,7 +404,6 @@ export async function installFromDirectTarball( ); } - const cliPath = join(extractDir, params.executablePath); if (!existsSync(cliPath)) { throw new Error(`executable not found in extracted tarball at ${cliPath}`); } @@ -402,11 +420,18 @@ export async function installFromDirectTarball( * The temp directory will be cleaned up by the OS automatically */ export async function installFromCurl(params: InstallFromCurlParams): Promise { - log.info(`» installing ${params.executableName}...`); - const tempDir = process.env.PULLFROG_TEMP_DIR; if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set"); + const cliPath = join(tempDir, ".local", "bin", params.executableName); + + if (existsSync(cliPath)) { + log.debug(`» using cached binary at ${cliPath}`); + return cliPath; + } + + log.info(`» installing ${params.executableName}...`); + const installScriptPath = join(tempDir, "install.sh"); // Download the install script @@ -448,10 +473,6 @@ export async function installFromCurl(params: InstallFromCurlParams): Promise `- "${m.name}": ${m.description}`).join("\n")} -### Step 2: Craft subagent prompts and delegate +### Step 2: Delegate -Based on the guidance from select_mode, craft a focused, self-contained prompt for each subagent, then call \`${ghPullfrogMcpName}/delegate\` with: -- \`instructions\`: Your crafted prompt. **The subagent receives ONLY this text — no other context is added.** Include everything it needs: file paths, constraints, conventions, tool usage instructions, and any relevant context from the codebase or previous phases. -- \`effort\`: \`"mini"\` (simple tasks), \`"auto"\` (typical tasks), or \`"max"\` (complex tasks requiring deep reasoning). +Call \`${ghPullfrogMcpName}/delegate\` to fan out research, local coding tasks, and codebase investigations to subagents. Pass a \`tasks\` array. Each task has: +- \`label\`: Short identifier (e.g. "frontend-review", "schema-check"). Returned in results for matching. +- \`instructions\`: The subagent receives ONLY this text (plus a system preamble with tool documentation and resolved context). Include everything it needs: file paths, constraints, conventions, and any context from the codebase or previous phases. +- \`effort\` (optional): \`"mini"\`, \`"auto"\` (default), or \`"max"\`. -Subagents are designed for research and local work: reading files, exploring the codebase, writing and editing code, running tests, creating reviews, and posting comments. They do NOT have access to remote-mutating operations like pushing branches, creating PRs, or updating PR bodies — those are your responsibility as the orchestrator. +All tasks in a single \`delegate\` call run as **parallel subagents**. For sequential phases (plan → build → review), use separate \`delegate\` calls. -To investigate questions (e.g. web research, codebase investigations), prefer \`${ghPullfrogMcpName}/ask_question\` over \`${ghPullfrogMcpName}/delegate\`. +To investigate questions, prefer \`${ghPullfrogMcpName}/ask_question\` over \`${ghPullfrogMcpName}/delegate\`. -### Step 3: Post-delegation (your responsibility) +### Step 3: Post-delegation -After each delegation, you receive the subagent's summary (via set_output) and a path to its full stdout log (which you can inspect via \`${ghPullfrogMcpName}/file_read\` if needed). Use this to decide whether to delegate again or finalize. +After each \`delegate\` call, you receive a \`results\` array — one entry per task with \`label\`, \`success\`, \`summary\` (from set_output), and \`stdoutFile\` (inspectable via \`${ghPullfrogMcpName}/file_read\`). Follow the post-delegation steps from the select_mode guidance. -**Remote operations are YOUR job.** Subagents do NOT have push, PR creation, or other remote-mutating tools. After a subagent that makes code changes completes, you must: -- Push the branch via \`${ghPullfrogMcpName}/push_branch\` -- Create a PR via \`${ghPullfrogMcpName}/create_pull_request\` (if needed) -- Call \`${ghPullfrogMcpName}/report_progress\` with the final summary including PR links +When all delegations are complete, call \`${ghPullfrogMcpName}/set_output\` with the final result. This makes it available as the GitHub Action output. -When all delegations are complete, call \`${ghPullfrogMcpName}/set_output\` with the final result — the last subagent's summary or a synthesis of all phases. This is required: it makes the result available as the GitHub Action output for downstream steps. +### Subagent capabilities + +Subagents have: file operations, bash (for local git, tests, builds), read-only GitHub queries, and upload_file. They do NOT have: \`git\`, \`checkout_pr\`, \`push_branch\`, \`create_pull_request\`, \`create_pull_request_review\`, \`report_progress\`, \`create_issue_comment\`, \`reply_to_review_comment\`, \`resolve_review_thread\`, \`delegate\`, \`ask_question\`, or any dependency/remote-mutating tools. All GitHub-write and state-mutating operations are your responsibility. ### Prompt-crafting rules -- Your subagent has NO context beyond what you write. No repo instructions, no event instructions, no user prompt — only your crafted instructions. -- Include MCP tool names when the subagent needs them (e.g., "commit via \`${ghPullfrogMcpName}/git\`"). -- Subagents do NOT have \`push_branch\`, \`create_pull_request\`, \`update_pull_request_body\`, \`delete_branch\`, or \`push_tags\`. Never instruct a subagent to push or create PRs — that is your job as the orchestrator. -- Include branch naming conventions, testing expectations, and commit instructions when relevant. -- For multi-phase flows, pass results from earlier phases directly into the next subagent's prompt. -- The subagent should call \`${ghPullfrogMcpName}/set_output\` with a concise summary when done (include the branch name if code changes were made). +- Subagents have NO context beyond what you write. No repo instructions, no event data, no user prompt. +- Specify exactly what information the subagent should return. The subagent's \`set_output\` call is your only way to get results back — be precise about what you need. +- Instruct subagents to use bash for local git (\`git add\`, \`git commit\`, \`git diff\`, \`git status\`). +- Never instruct a subagent to push, create PRs, submit reviews, or post comments. +- For multi-phase flows, pass results from earlier phases into the next delegate call's prompts. +- You do NOT need to instruct subagents to call \`set_output\` — the system preamble handles this. ### No-action cases -If the task clearly requires no work (e.g., irrelevant event, duplicate request), skip delegation entirely. Call \`${ghPullfrogMcpName}/report_progress\` directly to explain why no action is needed.`; +If the task clearly requires no work, skip delegation. Call \`${ghPullfrogMcpName}/report_progress\` directly to explain why no action is needed.`; const system = buildSystemPrompt({ shell: ctx.payload.shell, diff --git a/utils/setup.ts b/utils/setup.ts index 4cd4f2c..2e5ba5e 100644 --- a/utils/setup.ts +++ b/utils/setup.ts @@ -2,8 +2,7 @@ import { execSync } from "node:child_process"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { PayloadEvent, ShellPermission } from "../external.ts"; -import { checkoutPrBranch } from "../mcp/checkout.ts"; +import type { ShellPermission } from "../external.ts"; import type { ToolState } from "../mcp/server.ts"; import { log } from "./cli.ts"; import type { OctokitWithPlugins } from "./github.ts"; @@ -59,9 +58,7 @@ export interface GitContext { postCheckoutScript: string | null; } -export interface SetupGitParams extends GitContext { - event: PayloadEvent; -} +export type SetupGitParams = GitContext; /** * setup git configuration and authentication for the repository. @@ -170,16 +167,5 @@ export async function setupGit(params: SetupGitParams): Promise { // disable credential helpers to prevent prompts and ensure clean auth state $("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir }); - // non-PR events: stay on default branch - if (params.event.is_pr !== true || !params.event.issue_number) { - log.info("» git authentication configured"); - return; - } - - // PR event: checkout PR branch using shared helper - const prNumber = params.event.issue_number; - - // use shared checkout helper (handles fork remotes, push config, post-checkout hook) - // this updates toolState.pushUrl for fork PRs and sets toolState.issueNumber - await checkoutPrBranch(prNumber, params); + log.info("» git authentication configured"); } diff --git a/utils/subprocess.ts b/utils/subprocess.ts index fc0d0a4..beb3ce4 100644 --- a/utils/subprocess.ts +++ b/utils/subprocess.ts @@ -192,6 +192,7 @@ export async function spawn(options: SpawnOptions): Promise { if (isActivityTimedOut) { const idleSec = Math.round((performance.now() - lastActivityTime) / 1000); + // matched by delegateTimeout test validator — update tests if changed reject(new Error(`activity timeout: no output for ${idleSec}s`)); return; }