5bcfae990a
* add mode instructions and restructure dashboard sidebar
- add modeInstructions JSONB field to Repo model for per-mode user instructions
- thread modeInstructions through settings API, run-context API, RepoSettings, ToolContext, and selectMode runtime
- merge user-defined mode instructions with hardcoded orchestrator guidance, with IncrementalReview inheriting from Review
- reduce visible built-in modes from 7 to 4 (Build, Review, Plan, Fix) with editable Instructions textareas
- add TRIGGERS group header to sidebar above Mentions, Pull requests, Issues
- add wiki/modes.md documenting triggers and modes conceptual model
Made-with: Cursor
* fix leaping comment deletion and address review feedback
- wrap post-createReview operations in try/finally so deleteProgressComment
runs even when updateReview or reportReviewNodeId throws
- add parseModeInstructions runtime guard to filter non-string values
from the JSONB field before passing to buildOrchestratorGuidance
- add useEffect sync for localInstructions when props change
- guard onBlur to skip save when instructions haven't changed
- update wiki/modes.md to reflect V2 is implemented (no longer "proposed")
Made-with: Cursor
* harden review cleanup, fix type cast, stabilize mode instructions state
- wrap deleteProgressComment in try/catch inside finally to prevent masking original errors
- replace `as Record<string,string>` cast with runtime parseModeInstructions + useMemo
- fix wiki dual-prompt table to reflect mode.prompt fallback status
Made-with: Cursor
* fix wiki tense and heading ambiguity from PR review
Made-with: Cursor
* fix review "edited" badge by using pending review + submit flow
create review as PENDING first (no event/body), build the footer with
the now-known review ID, then submitReview with the full body. single
atomic publish — no updateReview edit needed.
Made-with: Cursor
* add post-agent follow-up re-review dispatch
After the agent exits, check if PR HEAD moved past the reviewed commit
and dispatch a follow-up re-review. This closes the gap where push
webhooks are suppressed during in-flight reviews.
Made-with: Cursor
* add silent flag to follow-up re-review dispatch
Made-with: Cursor
* restructure dashboard for consistency and clarity
- consolidate tools into single grouped card (was 4 separate cards)
- merge coding + autofix CI into one section
- remove redundant trigger section descriptions
- add bidirectional crosslinks between modes and triggers
- inline instruction links (review/plan/build) into descriptions
- add save status indicators to all sections
- restructure flags with grouped built-in/custom cards
- flatten sidebar (remove dividers and group headers)
- tighten all descriptions
Made-with: Cursor
* update PR screenshots for new dashboard layout
Made-with: Cursor
* extend review context inline instead of dispatching new workflow
when commits are pushed during a review, the agent now handles them
inline: create_pull_request_review detects HEAD movement, returns
instructions to pull and review the incremental diff, and the agent
submits a second review covering only the new changes. this avoids
the cost of spinning up a full new workflow run.
also fixes a bug where reviewedSha was set to the submission HEAD
(current) rather than the checkout HEAD (what was actually reviewed),
which caused commits pushed between checkout and submission to be
silently missed by postReviewCleanup.
the workflow dispatch is kept as a safety net for agent timeout/error.
Made-with: Cursor
* polish dashboard UI: fix debug markers, crosslinks, title consistency, descriptions
- remove all red debug borders/labels and CM component
- remove all inline style={{}} debug outlines from crosslinks
- fix ambiguous crosslinks: Build→"Coding ↓", Plan→"Enrich issues ↓"
- add missing "Edit build instructions ↑" backlink on Auto-address reviews
- normalize card title weight to text-sm font-semibold across all cards
- rename "Default" subcard to "Setup" with broader description
- fix Mentions description to imperative tone
- broaden Flags section description to cover built-in and custom
- remove useless fragments in ModesSection and ToolsSettings
- restructure Agent section: remove ConsoleSection wrappers, add sidebar indent support
Made-with: Cursor
* extract PR quick links as standalone card, consistent with issues
- PR quick links is now its own card under Reviews (was a sub-toggle inside Review PRs disabled state)
- Review PRs OFF sets prCreated="none" instead of auto-falling back to "links"
- Review PRs card hides sub-toggles when disabled (re-review/approve don't apply)
- Both PRs and Issues now have identical Quick links card structure
Made-with: Cursor
* update reviews screenshot with standalone quick links card
Made-with: Cursor
* polish dashboard UI: revert quick links to inline toggles, fix fonts and spacing
- revert standalone PR/issue Quick Links cards back to inline toggles inside
Review PRs and Enrich Issues cards (fixes prCreated state coupling bug)
- restore original font-medium card titles across all trigger/settings cards
- fix sidebar: add CONSOLE heading, remove nested indentation, remove truncation
- right-justify Enrich Issues mode dropdown, group description with label
- move instructions links inline with behavior descriptions
- replace text save indicators with icon spinner/checkmark
- standardize section title spacing, move footer below danger zone
Made-with: Cursor
* fix formatting for biome lint
Made-with: Cursor
* address PR review feedback: cleanup guard, shared util, wiki update
- clear ctx.toolState.review after read to prevent double-execution of postReviewCleanup
- forward authorPermission in safety-net re-review dispatch
- extract parseModeInstructions to utils/schemas/modeInstructions.ts
- update wiki/modes.md: remove stale v1/v2 language, fix dashboard layout
- add typecheck to pre-push hook
Made-with: Cursor
* add action typecheck to pre-push, fix exactOptionalPropertyTypes errors
Made-with: Cursor
* fix duplicate actuallyReviewedSha from rebase
Made-with: Cursor
* remove PR screenshots
Made-with: Cursor
* add label/textarea association for mode instruction accessibility
Made-with: Cursor
---------
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
426 lines
13 KiB
TypeScript
426 lines
13 KiB
TypeScript
// this must be imported first
|
|
import "./arkConfig.ts";
|
|
import { createServer } from "node:net";
|
|
import { FastMCP, type Tool } from "fastmcp";
|
|
import type { Agent, AgentUsage } from "../agents/index.ts";
|
|
import { ghPullfrogMcpName } from "../external.ts";
|
|
import type { Mode } from "../modes.ts";
|
|
import type { PrepResult } from "../prep/index.ts";
|
|
import type { OctokitWithPlugins } from "../utils/github.ts";
|
|
import type { ResolvedPayload } from "../utils/payload.ts";
|
|
|
|
export type BackgroundProcess = {
|
|
pid: number;
|
|
outputPath: string;
|
|
pidPath: string;
|
|
};
|
|
|
|
export type StoredPushDest = {
|
|
remoteName: string;
|
|
remoteBranch: string;
|
|
localBranch: string;
|
|
};
|
|
|
|
export type SubagentStatus = "running" | "completed" | "failed";
|
|
|
|
export type SubagentState = {
|
|
id: string;
|
|
label: string;
|
|
status: SubagentStatus;
|
|
mode: string;
|
|
stdoutFilePath: string;
|
|
output: string | undefined;
|
|
usage: AgentUsage | undefined;
|
|
startedAt: number;
|
|
keepAliveInterval: ReturnType<typeof setInterval> | undefined;
|
|
};
|
|
|
|
export interface ToolState {
|
|
// where we're allowed to push - base repo initially, fork URL for fork PRs
|
|
// set by setupGit, updated by checkout_pr. always set before push validation.
|
|
pushUrl?: string;
|
|
// push destination set by checkout_pr - used as primary source in push_branch
|
|
// because git config reads can fail in certain environments
|
|
pushDest?: StoredPushDest;
|
|
// issue or PR number (same number space in GitHub)
|
|
issueNumber?: number;
|
|
// PR HEAD sha at checkout time — used to detect new commits pushed during a review
|
|
checkoutSha?: string;
|
|
selectedMode?: string;
|
|
// per-subagent lifecycle tracking (keyed by subagent uuid)
|
|
subagents: Map<string, SubagentState>;
|
|
// 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<string, BackgroundProcess>;
|
|
review?: {
|
|
id: number;
|
|
nodeId: string;
|
|
reviewedSha: string | undefined;
|
|
};
|
|
dependencyInstallation?: {
|
|
status: "not_started" | "in_progress" | "completed" | "failed";
|
|
promise: Promise<PrepResult[]> | undefined;
|
|
results: PrepResult[] | undefined;
|
|
};
|
|
// undefined = no comment yet, number = active comment, null = deliberately deleted
|
|
progressCommentId: number | null | undefined;
|
|
lastProgressBody?: string;
|
|
wasUpdated?: boolean;
|
|
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
|
|
existingPlanCommentId?: number;
|
|
previousPlanBody?: string;
|
|
output?: string;
|
|
usageEntries: AgentUsage[];
|
|
}
|
|
|
|
interface InitToolStateParams {
|
|
progressCommentId: string | undefined;
|
|
}
|
|
|
|
export function initToolState(params: InitToolStateParams): ToolState {
|
|
const parsed = params.progressCommentId ? parseInt(params.progressCommentId, 10) : NaN;
|
|
const resolvedId = Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed;
|
|
|
|
if (resolvedId) {
|
|
log.info(`» using pre-created progress comment: ${resolvedId}`);
|
|
}
|
|
|
|
return {
|
|
progressCommentId: resolvedId,
|
|
subagents: new Map(),
|
|
selfSubagentId: undefined,
|
|
backgroundProcesses: new Map(),
|
|
usageEntries: [],
|
|
};
|
|
}
|
|
|
|
export interface ToolContext {
|
|
repo: RunContextData["repo"];
|
|
payload: ResolvedPayload;
|
|
octokit: OctokitWithPlugins;
|
|
githubInstallationToken: string;
|
|
gitToken: string;
|
|
apiToken: string;
|
|
agent: Agent;
|
|
modes: Mode[];
|
|
postCheckoutScript: string | null;
|
|
prApproveEnabled: boolean;
|
|
modeInstructions: Record<string, string>;
|
|
toolState: ToolState;
|
|
runId: number | undefined;
|
|
jobId: string | undefined;
|
|
// set after MCP server starts — used by delegate tool to pass URL to subagents
|
|
mcpServerUrl: string;
|
|
tmpdir: string;
|
|
}
|
|
|
|
import { log } from "../utils/cli.ts";
|
|
import type { RunContextData } from "../utils/runContextData.ts";
|
|
import { AskQuestionTool } from "./askQuestion.ts";
|
|
import { CheckoutPrTool } from "./checkout.ts";
|
|
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
|
import {
|
|
CreateCommentTool,
|
|
EditCommentTool,
|
|
ReplyToReviewCommentTool,
|
|
ReportProgressTool,
|
|
} from "./comment.ts";
|
|
import { CommitInfoTool } from "./commitInfo.ts";
|
|
import { DelegateTool } from "./delegate.ts";
|
|
import {
|
|
AwaitDependencyInstallationTool,
|
|
StartDependencyInstallationTool,
|
|
} from "./dependencies.ts";
|
|
import {
|
|
FileDeleteTool,
|
|
FileEditTool,
|
|
FileReadTool,
|
|
FileWriteTool,
|
|
ListDirectoryTool,
|
|
} from "./file.ts";
|
|
import { DeleteBranchTool, GitFetchTool, GitTool, PushBranchTool, PushTagsTool } from "./git.ts";
|
|
import { IssueTool } from "./issue.ts";
|
|
import { GetIssueCommentsTool } from "./issueComments.ts";
|
|
import { GetIssueEventsTool } from "./issueEvents.ts";
|
|
import { IssueInfoTool } from "./issueInfo.ts";
|
|
import { AddLabelsTool } from "./labels.ts";
|
|
import { SetOutputTool } from "./output.ts";
|
|
import { CreatePullRequestTool, UpdatePullRequestBodyTool } from "./pr.ts";
|
|
import { PullRequestInfoTool } from "./prInfo.ts";
|
|
import { CreatePullRequestReviewTool } from "./review.ts";
|
|
import {
|
|
GetReviewCommentsTool,
|
|
ListPullRequestReviewsTool,
|
|
ResolveReviewThreadTool,
|
|
} from "./reviewComments.ts";
|
|
import { SelectModeTool } from "./selectMode.ts";
|
|
import { addTools } from "./shared.ts";
|
|
import { KillBackgroundTool, ShellTool } from "./shell.ts";
|
|
import { UploadFileTool } from "./upload.ts";
|
|
|
|
const mcpPortStart = 3764;
|
|
const mcpPortAttempts = 100;
|
|
const mcpHost = "127.0.0.1";
|
|
const mcpEndpoint = "/mcp";
|
|
|
|
function readEnvPort(): number | null {
|
|
const rawPort = process.env.PULLFROG_MCP_PORT;
|
|
if (!rawPort) return null;
|
|
const parsed = Number.parseInt(rawPort, 10);
|
|
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
|
throw new Error(`invalid PULLFROG_MCP_PORT: ${rawPort}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function isPortAvailable(port: number): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const server = createServer();
|
|
server.unref();
|
|
server.once("error", () => resolve(false));
|
|
server.once("listening", () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
server.listen(port, mcpHost);
|
|
});
|
|
}
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
return String(error);
|
|
}
|
|
|
|
function isAddressInUse(error: unknown): boolean {
|
|
const message = getErrorMessage(error).toLowerCase();
|
|
return message.includes("eaddrinuse") || message.includes("address already in use");
|
|
}
|
|
|
|
type JsonSchema = Record<string, unknown>;
|
|
|
|
// tools shared by both orchestrator and subagent servers
|
|
function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any, any>[] {
|
|
const tools: Tool<any, any>[] = [
|
|
StartDependencyInstallationTool(ctx),
|
|
AwaitDependencyInstallationTool(ctx),
|
|
CreateCommentTool(ctx),
|
|
EditCommentTool(ctx),
|
|
ReplyToReviewCommentTool(ctx),
|
|
IssueTool(ctx),
|
|
IssueInfoTool(ctx),
|
|
GetIssueCommentsTool(ctx),
|
|
GetIssueEventsTool(ctx),
|
|
CreatePullRequestReviewTool(ctx),
|
|
PullRequestInfoTool(ctx),
|
|
CommitInfoTool(ctx),
|
|
CheckoutPrTool(ctx),
|
|
GetReviewCommentsTool(ctx),
|
|
ListPullRequestReviewsTool(ctx),
|
|
ResolveReviewThreadTool(ctx),
|
|
GetCheckSuiteLogsTool(ctx),
|
|
AddLabelsTool(ctx),
|
|
GitTool(ctx),
|
|
GitFetchTool(ctx),
|
|
UploadFileTool(ctx),
|
|
SetOutputTool(ctx, outputSchema),
|
|
FileReadTool(ctx),
|
|
FileWriteTool(ctx),
|
|
FileEditTool(ctx),
|
|
FileDeleteTool(ctx),
|
|
ListDirectoryTool(ctx),
|
|
];
|
|
|
|
// only add ShellTool when shell is "restricted"
|
|
// - "enabled": native shell only (no MCP shell needed)
|
|
// - "restricted": MCP shell only (native blocked, env filtered)
|
|
// - "disabled": no shell at all
|
|
if (ctx.payload.shell === "restricted") {
|
|
tools.push(ShellTool(ctx));
|
|
tools.push(KillBackgroundTool(ctx));
|
|
}
|
|
|
|
return tools;
|
|
}
|
|
|
|
// orchestrator gets common tools + delegation + remote-mutating tools
|
|
function buildOrchestratorTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any, any>[] {
|
|
return [
|
|
...buildCommonTools(ctx, outputSchema),
|
|
ReportProgressTool(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<any, any>[] {
|
|
return buildCommonTools(ctx);
|
|
}
|
|
|
|
type McpStartResult = {
|
|
server: FastMCP;
|
|
url: string;
|
|
port: number;
|
|
};
|
|
|
|
async function tryStartMcpServer(
|
|
ctx: ToolContext,
|
|
tools: Tool<any, any>[],
|
|
port: number
|
|
): Promise<McpStartResult | null> {
|
|
const server = new FastMCP({ name: ghPullfrogMcpName, version: "0.0.1" });
|
|
addTools(ctx, server, tools);
|
|
|
|
try {
|
|
await server.start({
|
|
transportType: "httpStream",
|
|
httpStream: {
|
|
port,
|
|
host: mcpHost,
|
|
endpoint: mcpEndpoint,
|
|
},
|
|
});
|
|
const url = `http://${mcpHost}:${port}${mcpEndpoint}`;
|
|
return { server, url, port };
|
|
} catch (error) {
|
|
if (!isAddressInUse(error)) {
|
|
throw error;
|
|
}
|
|
try {
|
|
await server.stop();
|
|
} catch {
|
|
// ignore cleanup errors on failed start
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function selectMcpPort(ctx: ToolContext, tools: Tool<any, any>[]): Promise<McpStartResult> {
|
|
let lastError: unknown = null;
|
|
|
|
const requestedPort = readEnvPort();
|
|
if (requestedPort !== null) {
|
|
if (await isPortAvailable(requestedPort)) {
|
|
const requestedResult = await tryStartMcpServer(ctx, tools, requestedPort);
|
|
if (requestedResult) {
|
|
return requestedResult;
|
|
}
|
|
}
|
|
}
|
|
|
|
// randomize start offset to reduce collision chance in parallel runs
|
|
const randomOffset = Math.floor(Math.random() * 50);
|
|
|
|
for (let offset = 0; offset < mcpPortAttempts; offset++) {
|
|
const port = mcpPortStart + randomOffset + offset;
|
|
try {
|
|
if (!(await isPortAvailable(port))) {
|
|
continue;
|
|
}
|
|
const result = await tryStartMcpServer(ctx, tools, port);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (!isAddressInUse(error)) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
const message = getErrorMessage(lastError);
|
|
throw new Error(
|
|
`could not find available mcp port starting at ${mcpPortStart} (last error: ${message})`
|
|
);
|
|
}
|
|
|
|
async function killBackgroundProcesses(toolState: ToolState): Promise<void> {
|
|
const backgroundProcesses = toolState.backgroundProcesses;
|
|
if (backgroundProcesses.size === 0) return;
|
|
for (const proc of backgroundProcesses.values()) {
|
|
try {
|
|
process.kill(-proc.pid, "SIGTERM");
|
|
} catch {
|
|
// already dead
|
|
}
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
for (const proc of backgroundProcesses.values()) {
|
|
try {
|
|
process.kill(-proc.pid, "SIGKILL");
|
|
} catch {
|
|
// already dead
|
|
}
|
|
}
|
|
backgroundProcesses.clear();
|
|
}
|
|
|
|
type McpHttpServerOptions = {
|
|
outputSchema?: JsonSchema | undefined;
|
|
};
|
|
|
|
/**
|
|
* Start the orchestrator MCP HTTP server (has all tools including push/PR/delegation).
|
|
*/
|
|
export async function startMcpHttpServer(
|
|
ctx: ToolContext,
|
|
options?: McpHttpServerOptions
|
|
): Promise<{ url: string; [Symbol.asyncDispose]: () => Promise<void> }> {
|
|
const tools = buildOrchestratorTools(ctx, options?.outputSchema);
|
|
const startResult = await selectMcpPort(ctx, tools);
|
|
|
|
return {
|
|
url: startResult.url,
|
|
[Symbol.asyncDispose]: async () => {
|
|
await killBackgroundProcesses(ctx.toolState);
|
|
await startResult.server.stop();
|
|
},
|
|
};
|
|
}
|
|
|
|
export type ManagedMcpServer = {
|
|
url: string;
|
|
stop: () => Promise<void>;
|
|
toolState: ToolState;
|
|
};
|
|
|
|
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(
|
|
params: StartSubagentMcpServerParams
|
|
): Promise<ManagedMcpServer> {
|
|
const subagentToolState: ToolState = {
|
|
...params.ctx.toolState,
|
|
selfSubagentId: params.subagentId,
|
|
backgroundProcesses: new Map(),
|
|
};
|
|
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(),
|
|
toolState: subagentToolState,
|
|
};
|
|
}
|