Rename Foundry handoffs to tasks (#239)

* Restore foundry onboarding stack

* Consolidate foundry rename

* Create foundry tasks without prompts

* Rename Foundry handoffs to tasks
This commit is contained in:
Nathan Flurry 2026-03-11 13:23:54 -07:00 committed by GitHub
parent d30cc0bcc8
commit d75e8c31d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
281 changed files with 9242 additions and 4356 deletions

View file

@ -0,0 +1,113 @@
import { Daytona, type Image } from "@daytonaio/sdk";
export interface DaytonaSandbox {
id: string;
state?: string;
snapshot?: string;
labels?: Record<string, string>;
}
export interface DaytonaCreateSandboxOptions {
image: string | Image;
envVars?: Record<string, string>;
labels?: Record<string, string>;
autoStopInterval?: number;
}
export interface DaytonaPreviewEndpoint {
url: string;
token?: string;
}
export interface DaytonaClientOptions {
apiUrl?: string;
apiKey?: string;
target?: string;
}
function normalizeApiUrl(input?: string): string | undefined {
if (!input) return undefined;
const trimmed = input.replace(/\/+$/, "");
if (trimmed.endsWith("/api")) {
return trimmed;
}
return `${trimmed}/api`;
}
export class DaytonaClient {
private readonly daytona: Daytona;
constructor(options: DaytonaClientOptions) {
const apiUrl = normalizeApiUrl(options.apiUrl);
this.daytona = new Daytona({
_experimental: {},
...(apiUrl ? { apiUrl } : {}),
...(options.apiKey ? { apiKey: options.apiKey } : {}),
...(options.target ? { target: options.target } : {}),
});
}
async createSandbox(options: DaytonaCreateSandboxOptions): Promise<DaytonaSandbox> {
const sandbox = await this.daytona.create({
image: options.image,
envVars: options.envVars,
labels: options.labels,
...(options.autoStopInterval !== undefined ? { autoStopInterval: options.autoStopInterval } : {}),
});
return {
id: sandbox.id,
state: sandbox.state,
snapshot: sandbox.snapshot,
labels: (sandbox as any).labels,
};
}
async getSandbox(sandboxId: string): Promise<DaytonaSandbox> {
const sandbox = await this.daytona.get(sandboxId);
return {
id: sandbox.id,
state: sandbox.state,
snapshot: sandbox.snapshot,
labels: (sandbox as any).labels,
};
}
async startSandbox(sandboxId: string, timeoutSeconds?: number): Promise<void> {
const sandbox = await this.daytona.get(sandboxId);
await sandbox.start(timeoutSeconds);
}
async stopSandbox(sandboxId: string, timeoutSeconds?: number): Promise<void> {
const sandbox = await this.daytona.get(sandboxId);
await sandbox.stop(timeoutSeconds);
}
async deleteSandbox(sandboxId: string): Promise<void> {
const sandbox = await this.daytona.get(sandboxId);
await this.daytona.delete(sandbox);
}
async executeCommand(sandboxId: string, command: string): Promise<{ exitCode: number; result: string }> {
const sandbox = await this.daytona.get(sandboxId);
const response = await sandbox.process.executeCommand(command);
return {
exitCode: response.exitCode,
result: response.result,
};
}
async getPreviewEndpoint(sandboxId: string, port: number): Promise<DaytonaPreviewEndpoint> {
const sandbox = await this.daytona.get(sandboxId);
// Use signed preview URLs for server-to-sandbox communication.
// The standard preview link may redirect to an interactive Auth0 flow from non-browser clients.
// Signed preview URLs work for direct HTTP access.
//
// Request a longer-lived URL so sessions can run for several minutes without refresh.
const preview = await sandbox.getSignedPreviewUrl(port, 6 * 60 * 60);
return {
url: preview.url,
token: preview.token,
};
}
}

View file

@ -0,0 +1,223 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const DEFAULT_TIMEOUT_MS = 2 * 60_000;
interface SpiceCommand {
command: string;
prefix: string[];
}
export interface SpiceStackEntry {
branchName: string;
parentBranch: string | null;
}
function spiceCommands(): SpiceCommand[] {
const explicit = process.env.HF_GIT_SPICE_BIN?.trim();
const list: SpiceCommand[] = [];
if (explicit) {
list.push({ command: explicit, prefix: [] });
}
list.push({ command: "git-spice", prefix: [] });
list.push({ command: "git", prefix: ["spice"] });
return list;
}
function commandLabel(cmd: SpiceCommand): string {
return [cmd.command, ...cmd.prefix].join(" ");
}
function looksMissing(error: unknown): boolean {
const detail = error instanceof Error ? error.message : String(error);
return detail.includes("ENOENT") || detail.includes("not a git command") || detail.includes("command not found");
}
async function tryRun(repoPath: string, cmd: SpiceCommand, args: string[]): Promise<{ stdout: string; stderr: string }> {
return await execFileAsync(cmd.command, [...cmd.prefix, ...args], {
cwd: repoPath,
timeout: DEFAULT_TIMEOUT_MS,
maxBuffer: 1024 * 1024 * 8,
env: {
...process.env,
NO_COLOR: "1",
FORCE_COLOR: "0",
},
});
}
async function pickCommand(repoPath: string): Promise<SpiceCommand | null> {
for (const candidate of spiceCommands()) {
try {
await tryRun(repoPath, candidate, ["--help"]);
return candidate;
} catch (error) {
if (looksMissing(error)) {
continue;
}
}
}
return null;
}
async function runSpice(repoPath: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
const cmd = await pickCommand(repoPath);
if (!cmd) {
throw new Error("git-spice is not available (set HF_GIT_SPICE_BIN or install git-spice)");
}
return await tryRun(repoPath, cmd, args);
}
function parseLogJson(stdout: string): SpiceStackEntry[] {
const trimmed = stdout.trim();
if (!trimmed) {
return [];
}
const entries: SpiceStackEntry[] = [];
// `git-spice log ... --json` prints one JSON object per line.
for (const line of trimmed.split("\n")) {
const raw = line.trim();
if (!raw.startsWith("{")) {
continue;
}
try {
const value = JSON.parse(raw) as {
name?: string;
branch?: string;
parent?: string | null;
parentBranch?: string | null;
};
const branchName = (value.name ?? value.branch ?? "").trim();
if (!branchName) {
continue;
}
const parentRaw = value.parent ?? value.parentBranch ?? null;
const parentBranch = parentRaw ? parentRaw.trim() || null : null;
entries.push({ branchName, parentBranch });
} catch {
continue;
}
}
const seen = new Set<string>();
return entries.filter((entry) => {
if (seen.has(entry.branchName)) {
return false;
}
seen.add(entry.branchName);
return true;
});
}
async function runFallbacks(repoPath: string, commands: string[][], errorContext: string): Promise<void> {
const failures: string[] = [];
for (const args of commands) {
try {
await runSpice(repoPath, args);
return;
} catch (error) {
failures.push(`${args.join(" ")} :: ${error instanceof Error ? error.message : String(error)}`);
}
}
throw new Error(`${errorContext}. attempts=${failures.join(" | ")}`);
}
export async function gitSpiceAvailable(repoPath: string): Promise<boolean> {
return (await pickCommand(repoPath)) !== null;
}
export async function gitSpiceListStack(repoPath: string): Promise<SpiceStackEntry[]> {
try {
const { stdout } = await runSpice(repoPath, ["log", "short", "--all", "--json", "--no-cr-status", "--no-prompt"]);
return parseLogJson(stdout);
} catch {
return [];
}
}
export async function gitSpiceSyncRepo(repoPath: string): Promise<void> {
await runFallbacks(
repoPath,
[
["repo", "sync", "--restack", "--no-prompt"],
["repo", "sync", "--restack"],
["repo", "sync"],
],
"git-spice repo sync failed",
);
}
export async function gitSpiceRestackRepo(repoPath: string): Promise<void> {
await runFallbacks(
repoPath,
[
["repo", "restack", "--no-prompt"],
["repo", "restack"],
],
"git-spice repo restack failed",
);
}
export async function gitSpiceRestackSubtree(repoPath: string, branchName: string): Promise<void> {
await runFallbacks(
repoPath,
[
["upstack", "restack", "--branch", branchName, "--no-prompt"],
["upstack", "restack", "--branch", branchName],
["branch", "restack", "--branch", branchName, "--no-prompt"],
["branch", "restack", "--branch", branchName],
],
`git-spice restack subtree failed for ${branchName}`,
);
}
export async function gitSpiceRebaseBranch(repoPath: string, branchName: string): Promise<void> {
await runFallbacks(
repoPath,
[
["branch", "restack", "--branch", branchName, "--no-prompt"],
["branch", "restack", "--branch", branchName],
],
`git-spice branch restack failed for ${branchName}`,
);
}
export async function gitSpiceReparentBranch(repoPath: string, branchName: string, parentBranch: string): Promise<void> {
await runFallbacks(
repoPath,
[
["upstack", "onto", "--branch", branchName, parentBranch, "--no-prompt"],
["upstack", "onto", "--branch", branchName, parentBranch],
["branch", "onto", "--branch", branchName, parentBranch, "--no-prompt"],
["branch", "onto", "--branch", branchName, parentBranch],
],
`git-spice reparent failed for ${branchName} -> ${parentBranch}`,
);
}
export async function gitSpiceTrackBranch(repoPath: string, branchName: string, parentBranch: string): Promise<void> {
await runFallbacks(
repoPath,
[
["branch", "track", branchName, "--base", parentBranch, "--no-prompt"],
["branch", "track", branchName, "--base", parentBranch],
],
`git-spice track failed for ${branchName}`,
);
}
export function normalizeBaseBranchName(ref: string): string {
const trimmed = ref.trim();
if (!trimmed) {
return "main";
}
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}
export function describeSpiceCommandForLogs(repoPath: string): Promise<string | null> {
return pickCommand(repoPath).then((cmd) => (cmd ? commandLabel(cmd) : null));
}

View file

@ -0,0 +1,260 @@
import { execFile } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, resolve } from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS = 15_000;
const DEFAULT_GIT_FETCH_TIMEOUT_MS = 2 * 60_000;
const DEFAULT_GIT_CLONE_TIMEOUT_MS = 5 * 60_000;
function resolveGithubToken(): string | null {
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.HF_GITHUB_TOKEN ?? process.env.HF_GH_TOKEN ?? null;
if (!token) return null;
const trimmed = token.trim();
return trimmed.length > 0 ? trimmed : null;
}
let cachedAskpassPath: string | null = null;
function ensureAskpassScript(): string {
if (cachedAskpassPath) {
return cachedAskpassPath;
}
const dir = mkdtempSync(resolve(tmpdir(), "foundry-git-askpass-"));
const path = resolve(dir, "askpass.sh");
// Git invokes $GIT_ASKPASS with the prompt string as argv[1]. Provide both username and password.
// We avoid embedding the token in this file; it is read from env at runtime.
const content = [
"#!/bin/sh",
'prompt="$1"',
// Prefer GH_TOKEN/GITHUB_TOKEN but support HF_* aliases too.
'token="${GH_TOKEN:-${GITHUB_TOKEN:-${HF_GITHUB_TOKEN:-${HF_GH_TOKEN:-}}}}"',
'case "$prompt" in',
' *Username*) echo "x-access-token" ;;',
' *Password*) echo "$token" ;;',
' *) echo "" ;;',
"esac",
"",
].join("\n");
writeFileSync(path, content, "utf8");
chmodSync(path, 0o700);
cachedAskpassPath = path;
return path;
}
function gitEnv(): Record<string, string> {
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
env.GIT_TERMINAL_PROMPT = "0";
const token = resolveGithubToken();
if (token) {
env.GIT_ASKPASS = ensureAskpassScript();
// Some tooling expects these vars; keep them aligned.
env.GITHUB_TOKEN = env.GITHUB_TOKEN || token;
env.GH_TOKEN = env.GH_TOKEN || token;
}
return env;
}
export interface BranchSnapshot {
branchName: string;
commitSha: string;
}
export async function fetch(repoPath: string): Promise<void> {
await execFileAsync("git", ["-C", repoPath, "fetch", "--prune"], {
timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS,
env: gitEnv(),
});
}
export async function revParse(repoPath: string, ref: string): Promise<string> {
const { stdout } = await execFileAsync("git", ["-C", repoPath, "rev-parse", ref], { env: gitEnv() });
return stdout.trim();
}
export async function validateRemote(remoteUrl: string): Promise<void> {
const remote = remoteUrl.trim();
if (!remote) {
throw new Error("remoteUrl is required");
}
try {
await execFileAsync("git", ["ls-remote", "--exit-code", remote, "HEAD"], {
// This command does not need repo context. Running from a neutral directory
// avoids inheriting broken worktree .git indirection inside dev containers.
cwd: tmpdir(),
maxBuffer: 1024 * 1024,
timeout: DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS,
env: gitEnv(),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`git remote validation failed: ${detail}`);
}
}
function isGitRepo(path: string): boolean {
return existsSync(resolve(path, ".git"));
}
export async function ensureCloned(remoteUrl: string, targetPath: string): Promise<void> {
const remote = remoteUrl.trim();
if (!remote) {
throw new Error("remoteUrl is required");
}
if (existsSync(targetPath)) {
if (!isGitRepo(targetPath)) {
throw new Error(`targetPath exists but is not a git repo: ${targetPath}`);
}
// Keep origin aligned with the configured remote URL.
await execFileAsync("git", ["-C", targetPath, "remote", "set-url", "origin", remote], {
maxBuffer: 1024 * 1024,
timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS,
env: gitEnv(),
});
await fetch(targetPath);
return;
}
mkdirSync(dirname(targetPath), { recursive: true });
await execFileAsync("git", ["clone", remote, targetPath], {
maxBuffer: 1024 * 1024 * 8,
timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS,
env: gitEnv(),
});
await fetch(targetPath);
}
export async function remoteDefaultBaseRef(repoPath: string): Promise<string> {
try {
const { stdout } = await execFileAsync("git", ["-C", repoPath, "symbolic-ref", "refs/remotes/origin/HEAD"], { env: gitEnv() });
const ref = stdout.trim(); // refs/remotes/origin/main
const match = ref.match(/^refs\/remotes\/(.+)$/);
if (match?.[1]) {
return match[1];
}
} catch {
// fall through
}
const candidates = ["origin/main", "origin/master", "main", "master"];
for (const ref of candidates) {
try {
await execFileAsync("git", ["-C", repoPath, "rev-parse", "--verify", ref], { env: gitEnv() });
return ref;
} catch {
continue;
}
}
return "origin/main";
}
export async function listRemoteBranches(repoPath: string): Promise<BranchSnapshot[]> {
const { stdout } = await execFileAsync("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/remotes/origin"], {
maxBuffer: 1024 * 1024,
env: gitEnv(),
});
return stdout
.trim()
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => {
const [refName, commitSha] = line.trim().split(/\s+/, 2);
const short = (refName ?? "").trim();
const branchName = short.replace(/^origin\//, "");
return { branchName, commitSha: commitSha ?? "" };
})
.filter((row) => row.branchName.length > 0 && row.branchName !== "HEAD" && row.branchName !== "origin" && row.commitSha.length > 0);
}
async function remoteBranchExists(repoPath: string, branchName: string): Promise<boolean> {
try {
await execFileAsync("git", ["-C", repoPath, "show-ref", "--verify", `refs/remotes/origin/${branchName}`], { env: gitEnv() });
return true;
} catch {
return false;
}
}
export async function ensureRemoteBranch(repoPath: string, branchName: string): Promise<void> {
await fetch(repoPath);
if (await remoteBranchExists(repoPath, branchName)) {
return;
}
const baseRef = await remoteDefaultBaseRef(repoPath);
await execFileAsync("git", ["-C", repoPath, "push", "origin", `${baseRef}:refs/heads/${branchName}`], {
maxBuffer: 1024 * 1024 * 2,
env: gitEnv(),
});
await fetch(repoPath);
}
export async function diffStatForBranch(repoPath: string, branchName: string): Promise<string> {
try {
const baseRef = await remoteDefaultBaseRef(repoPath);
const headRef = `origin/${branchName}`;
const { stdout } = await execFileAsync("git", ["-C", repoPath, "diff", "--shortstat", `${baseRef}...${headRef}`], {
maxBuffer: 1024 * 1024,
env: gitEnv(),
});
const trimmed = stdout.trim();
if (!trimmed) {
return "+0/-0";
}
const insertMatch = trimmed.match(/(\d+)\s+insertion/);
const deleteMatch = trimmed.match(/(\d+)\s+deletion/);
const insertions = insertMatch ? insertMatch[1] : "0";
const deletions = deleteMatch ? deleteMatch[1] : "0";
return `+${insertions}/-${deletions}`;
} catch {
return "+0/-0";
}
}
export async function conflictsWithMain(repoPath: string, branchName: string): Promise<boolean> {
try {
const baseRef = await remoteDefaultBaseRef(repoPath);
const headRef = `origin/${branchName}`;
// Use merge-tree (git 2.38+) for a clean conflict check.
try {
await execFileAsync("git", ["-C", repoPath, "merge-tree", "--write-tree", "--no-messages", baseRef, headRef], { env: gitEnv() });
// If merge-tree exits 0, no conflicts. Non-zero exit means conflicts.
return false;
} catch {
// merge-tree exits non-zero when there are conflicts
return true;
}
} catch {
return false;
}
}
export async function getOriginOwner(repoPath: string): Promise<string> {
try {
const { stdout } = await execFileAsync("git", ["-C", repoPath, "remote", "get-url", "origin"], { env: gitEnv() });
const url = stdout.trim();
// Handle SSH: git@github.com:owner/repo.git
const sshMatch = url.match(/[:\/]([^\/]+)\/[^\/]+(?:\.git)?$/);
if (sshMatch) {
return sshMatch[1] ?? "";
}
// Handle HTTPS: https://github.com/owner/repo.git
const httpsMatch = url.match(/\/\/[^\/]+\/([^\/]+)\//);
if (httpsMatch) {
return httpsMatch[1] ?? "";
}
return "";
} catch {
return "";
}
}

View file

@ -0,0 +1,231 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface PullRequestSnapshot {
number: number;
headRefName: string;
state: string;
title: string;
url: string;
author: string;
isDraft: boolean;
ciStatus: string | null;
reviewStatus: string | null;
reviewer: string | null;
}
interface GhPrListItem {
number: number;
headRefName: string;
state: string;
title: string;
url?: string;
author?: { login?: string };
isDraft?: boolean;
statusCheckRollup?: Array<{
state?: string;
status?: string;
conclusion?: string;
__typename?: string;
}>;
reviews?: Array<{
state?: string;
author?: { login?: string };
}>;
}
function parseCiStatus(checks: GhPrListItem["statusCheckRollup"]): string | null {
if (!checks || checks.length === 0) return null;
let total = 0;
let successes = 0;
let hasRunning = false;
for (const check of checks) {
total++;
const conclusion = check.conclusion?.toUpperCase();
const state = check.state?.toUpperCase();
const status = check.status?.toUpperCase();
if (conclusion === "SUCCESS" || state === "SUCCESS") {
successes++;
} else if (status === "IN_PROGRESS" || status === "QUEUED" || status === "PENDING" || state === "PENDING") {
hasRunning = true;
}
}
if (hasRunning && successes < total) {
return "running";
}
return `${successes}/${total}`;
}
function parseReviewStatus(reviews: GhPrListItem["reviews"]): { status: string | null; reviewer: string | null } {
if (!reviews || reviews.length === 0) {
return { status: null, reviewer: null };
}
// Build a map of latest review per author
const latestByAuthor = new Map<string, { state: string; login: string }>();
for (const review of reviews) {
const login = review.author?.login ?? "unknown";
const state = review.state?.toUpperCase() ?? "";
if (state === "COMMENTED") continue; // Skip comments, only track actionable reviews
latestByAuthor.set(login, { state, login });
}
// Check for CHANGES_REQUESTED first (takes priority), then APPROVED
for (const [, entry] of latestByAuthor) {
if (entry.state === "CHANGES_REQUESTED") {
return { status: "CHANGES_REQUESTED", reviewer: entry.login };
}
}
for (const [, entry] of latestByAuthor) {
if (entry.state === "APPROVED") {
return { status: "APPROVED", reviewer: entry.login };
}
}
// If there are reviews but none are APPROVED or CHANGES_REQUESTED
if (latestByAuthor.size > 0) {
const first = latestByAuthor.values().next().value;
return { status: "PENDING", reviewer: first?.login ?? null };
}
return { status: null, reviewer: null };
}
function snapshotFromGhItem(item: GhPrListItem): PullRequestSnapshot {
const { status: reviewStatus, reviewer } = parseReviewStatus(item.reviews);
return {
number: item.number,
headRefName: item.headRefName,
state: item.state,
title: item.title,
url: item.url ?? "",
author: item.author?.login ?? "",
isDraft: item.isDraft ?? false,
ciStatus: parseCiStatus(item.statusCheckRollup),
reviewStatus,
reviewer,
};
}
const PR_JSON_FIELDS = "number,headRefName,state,title,url,author,isDraft,statusCheckRollup,reviews";
export async function listPullRequests(repoPath: string): Promise<PullRequestSnapshot[]> {
try {
const { stdout } = await execFileAsync("gh", ["pr", "list", "--json", PR_JSON_FIELDS, "--limit", "200"], { maxBuffer: 1024 * 1024 * 4, cwd: repoPath });
const parsed = JSON.parse(stdout) as GhPrListItem[];
return parsed.map((item) => {
// Handle fork PRs where headRefName may contain "owner:branch"
const headRefName = item.headRefName.includes(":") ? (item.headRefName.split(":").pop() ?? item.headRefName) : item.headRefName;
return snapshotFromGhItem({ ...item, headRefName });
});
} catch {
return [];
}
}
export async function getPrInfo(repoPath: string, branchName: string): Promise<PullRequestSnapshot | null> {
try {
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", PR_JSON_FIELDS], { maxBuffer: 1024 * 1024 * 4, cwd: repoPath });
const item = JSON.parse(stdout) as GhPrListItem;
return snapshotFromGhItem(item);
} catch {
return null;
}
}
export async function createPr(repoPath: string, headBranch: string, title: string, body?: string): Promise<{ number: number; url: string }> {
const args = ["pr", "create", "--title", title, "--head", headBranch];
if (body) {
args.push("--body", body);
} else {
args.push("--body", "");
}
const { stdout } = await execFileAsync("gh", args, {
maxBuffer: 1024 * 1024,
cwd: repoPath,
});
// gh pr create outputs the PR URL on success
const url = stdout.trim();
// Extract PR number from URL: https://github.com/owner/repo/pull/123
const numberMatch = url.match(/\/pull\/(\d+)/);
const number = numberMatch ? parseInt(numberMatch[1]!, 10) : 0;
return { number, url };
}
export async function starRepository(repoFullName: string): Promise<void> {
try {
await execFileAsync("gh", ["api", "--method", "PUT", `user/starred/${repoFullName}`], {
maxBuffer: 1024 * 1024,
});
} catch (error) {
const message =
error instanceof Error ? error.message : `Failed to star GitHub repository ${repoFullName}. Ensure GitHub auth is configured for the backend.`;
throw new Error(message);
}
}
export async function getAllowedMergeMethod(repoPath: string): Promise<"squash" | "rebase" | "merge"> {
try {
// Get the repo owner/name from gh
const { stdout: repoJson } = await execFileAsync("gh", ["repo", "view", "--json", "owner,name"], { cwd: repoPath });
const repo = JSON.parse(repoJson) as { owner: { login: string }; name: string };
const repoFullName = `${repo.owner.login}/${repo.name}`;
const { stdout } = await execFileAsync("gh", ["api", `repos/${repoFullName}`, "--jq", ".allow_squash_merge, .allow_rebase_merge, .allow_merge_commit"], {
maxBuffer: 1024 * 1024,
cwd: repoPath,
});
const lines = stdout.trim().split("\n");
const allowSquash = lines[0]?.trim() === "true";
const allowRebase = lines[1]?.trim() === "true";
const allowMerge = lines[2]?.trim() === "true";
if (allowSquash) return "squash";
if (allowRebase) return "rebase";
if (allowMerge) return "merge";
return "squash";
} catch {
return "squash";
}
}
export async function mergePr(repoPath: string, prNumber: number): Promise<void> {
const method = await getAllowedMergeMethod(repoPath);
await execFileAsync("gh", ["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"], { cwd: repoPath });
}
export async function isPrMerged(repoPath: string, branchName: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "state"], { cwd: repoPath });
const parsed = JSON.parse(stdout) as { state: string };
return parsed.state.toUpperCase() === "MERGED";
} catch {
return false;
}
}
export async function getPrTitle(repoPath: string, branchName: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "title"], { cwd: repoPath });
const parsed = JSON.parse(stdout) as { title: string };
return parsed.title;
} catch {
return null;
}
}

View file

@ -0,0 +1,140 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export async function graphiteAvailable(repoPath: string): Promise<boolean> {
try {
await execFileAsync("gt", ["trunk"], { cwd: repoPath });
return true;
} catch {
return false;
}
}
export async function graphiteGet(repoPath: string, branchName: string): Promise<boolean> {
try {
await execFileAsync("gt", ["get", branchName], { cwd: repoPath });
return true;
} catch {
return false;
}
}
export async function graphiteCreateBranch(repoPath: string, branchName: string): Promise<void> {
await execFileAsync("gt", ["create", branchName], { cwd: repoPath });
}
export async function graphiteCheckout(repoPath: string, branchName: string): Promise<void> {
await execFileAsync("gt", ["checkout", branchName], { cwd: repoPath });
}
export async function graphiteSubmit(repoPath: string): Promise<void> {
await execFileAsync("gt", ["submit", "--no-edit"], { cwd: repoPath });
}
export async function graphiteMergeBranch(repoPath: string, branchName: string): Promise<void> {
await execFileAsync("gt", ["merge", branchName], { cwd: repoPath });
}
export async function graphiteAbandon(repoPath: string, branchName: string): Promise<void> {
await execFileAsync("gt", ["abandon", branchName], { cwd: repoPath });
}
export interface GraphiteStackEntry {
branchName: string;
parentBranch: string | null;
}
export async function graphiteGetStack(repoPath: string): Promise<GraphiteStackEntry[]> {
try {
// Try JSON output first
const { stdout } = await execFileAsync("gt", ["log", "--json"], {
cwd: repoPath,
maxBuffer: 1024 * 1024,
});
const parsed = JSON.parse(stdout) as Array<{
branch?: string;
name?: string;
parent?: string;
parentBranch?: string;
}>;
return parsed.map((entry) => ({
branchName: entry.branch ?? entry.name ?? "",
parentBranch: entry.parent ?? entry.parentBranch ?? null,
}));
} catch {
// Fall back to text parsing of `gt log`
try {
const { stdout } = await execFileAsync("gt", ["log"], {
cwd: repoPath,
maxBuffer: 1024 * 1024,
});
const entries: GraphiteStackEntry[] = [];
const lines = stdout.split("\n").filter((l) => l.trim().length > 0);
// Parse indented tree output: each line has tree chars (|, /, \, -, etc.)
// followed by branch names. Build parent-child from indentation level.
const branchStack: string[] = [];
for (const line of lines) {
// Strip ANSI color codes
const clean = line.replace(/\x1b\[[0-9;]*m/g, "");
// Extract branch name: skip tree characters and whitespace
const branchMatch = clean.match(/[│├└─|/\\*\s]*(?:◉|○|●)?\s*(.+)/);
if (!branchMatch) continue;
const branchName = branchMatch[1]!.trim();
if (!branchName || branchName.startsWith("(") || branchName === "") continue;
// Determine indentation level by counting leading whitespace/tree chars
const indent = clean.search(/[a-zA-Z0-9]/);
const level = Math.max(0, Math.floor(indent / 2));
// Trim stack to current level
while (branchStack.length > level) {
branchStack.pop();
}
const parentBranch = branchStack.length > 0 ? (branchStack[branchStack.length - 1] ?? null) : null;
entries.push({ branchName, parentBranch });
branchStack.push(branchName);
}
return entries;
} catch {
return [];
}
}
}
export async function graphiteGetParent(repoPath: string, branchName: string): Promise<string | null> {
try {
// Try `gt get <branchName>` to see parent info
const { stdout } = await execFileAsync("gt", ["get", branchName], {
cwd: repoPath,
maxBuffer: 1024 * 1024,
});
// Parse output for parent branch reference
const parentMatch = stdout.match(/parent:\s*(\S+)/i);
if (parentMatch) {
return parentMatch[1] ?? null;
}
} catch {
// Fall through to stack-based lookup
}
// Fall back to stack info
try {
const stack = await graphiteGetStack(repoPath);
const entry = stack.find((e) => e.branchName === branchName);
return entry?.parentBranch ?? null;
} catch {
return null;
}
}

View file

@ -0,0 +1,418 @@
import type { AgentType } from "@sandbox-agent/foundry-shared";
import type {
ListEventsRequest,
ListPage,
ListPageRequest,
ProcessCreateRequest,
ProcessInfo,
ProcessLogFollowQuery,
ProcessLogsResponse,
ProcessSignalQuery,
SessionEvent,
SessionPersistDriver,
SessionRecord,
} from "sandbox-agent";
import { SandboxAgent } from "sandbox-agent";
export type AgentId = AgentType | "opencode";
export interface SandboxSession {
id: string;
status: "running" | "idle" | "error";
}
export interface SandboxSessionCreateRequest {
prompt?: string;
cwd?: string;
agent?: AgentId;
}
export interface SandboxSessionPromptRequest {
sessionId: string;
prompt: string;
notification?: boolean;
}
export interface SandboxAgentClientOptions {
endpoint: string;
token?: string;
agent?: AgentId;
persist?: SessionPersistDriver;
}
const DEFAULT_AGENT: AgentId = "codex";
function modeIdForAgent(agent: AgentId): string | null {
switch (agent) {
case "codex":
return "full-access";
case "claude":
return "acceptEdits";
default:
return null;
}
}
function normalizeStatusFromMessage(payload: unknown): SandboxSession["status"] | null {
if (payload && typeof payload === "object") {
const envelope = payload as {
error?: unknown;
method?: unknown;
result?: unknown;
};
const maybeError = envelope.error;
if (maybeError) {
return "error";
}
if (envelope.result && typeof envelope.result === "object") {
const stopReason = (envelope.result as { stopReason?: unknown }).stopReason;
if (typeof stopReason === "string" && stopReason.length > 0) {
return "idle";
}
}
const method = envelope.method;
if (typeof method === "string") {
const lowered = method.toLowerCase();
if (lowered.includes("error") || lowered.includes("failed")) {
return "error";
}
if (lowered.includes("ended") || lowered.includes("complete") || lowered.includes("stopped")) {
return "idle";
}
}
}
return null;
}
export class SandboxAgentClient {
readonly endpoint: string;
readonly token?: string;
readonly agent: AgentId;
readonly persist?: SessionPersistDriver;
private sdkPromise?: Promise<SandboxAgent>;
private readonly statusBySessionId = new Map<string, SandboxSession["status"]>();
constructor(options: SandboxAgentClientOptions) {
this.endpoint = options.endpoint.replace(/\/$/, "");
this.token = options.token;
this.agent = options.agent ?? DEFAULT_AGENT;
this.persist = options.persist;
}
private async sdk(): Promise<SandboxAgent> {
if (!this.sdkPromise) {
this.sdkPromise = SandboxAgent.connect({
baseUrl: this.endpoint,
token: this.token,
persist: this.persist,
});
}
return this.sdkPromise;
}
private setStatus(sessionId: string, status: SandboxSession["status"]): void {
this.statusBySessionId.set(sessionId, status);
}
private isLikelyPromptTimeout(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
const lowered = message.toLowerCase();
// sandbox-agent server times out long-running ACP prompts and returns a 504-like error.
return lowered.includes("timeout waiting for agent response") || lowered.includes("timed out waiting for agent response") || lowered.includes("504");
}
async createSession(request: string | SandboxSessionCreateRequest): Promise<SandboxSession> {
const normalized: SandboxSessionCreateRequest = typeof request === "string" ? { prompt: request } : request;
const sdk = await this.sdk();
// Do not wrap createSession in a local Promise.race timeout. The underlying SDK
// call is not abortable, so local timeout races create overlapping ACP requests and
// can produce duplicate/orphaned sessions while the original request is still running.
const session = await sdk.createSession({
agent: normalized.agent ?? this.agent,
sessionInit: {
cwd: normalized.cwd ?? "/",
mcpServers: [],
},
});
const modeId = modeIdForAgent(normalized.agent ?? this.agent);
// Codex defaults to a restrictive "read-only" preset in some environments.
// Foundry automation needs edits, command execution, and network access.
// access (git push / PR creation). Use full-access where supported.
//
// If the agent doesn't support session modes, ignore.
//
// Do this in the background: ACP mode updates can occasionally time out (504),
// and waiting here can stall session creation long enough to trip task init
// step timeouts even though the session itself was created.
if (modeId) {
void session.rawSend("session/set_mode", { modeId }).catch(() => {
// ignore
});
}
const prompt = normalized.prompt?.trim();
if (!prompt) {
this.setStatus(session.id, "idle");
return {
id: session.id,
status: "idle",
};
}
// Fire the first turn in the background. We intentionally do not await this:
// session creation must remain fast, and we observe completion via events/stopReason.
//
// Note: sandbox-agent's ACP adapter for Codex may take >2 minutes to respond.
// sandbox-agent can return a timeout error (504) even though the agent continues
// running. Treat that timeout as non-fatal and keep polling events.
void session
.prompt([{ type: "text", text: prompt }])
.then(() => {
this.setStatus(session.id, "idle");
})
.catch((err) => {
if (this.isLikelyPromptTimeout(err)) {
this.setStatus(session.id, "running");
return;
}
this.setStatus(session.id, "error");
});
this.setStatus(session.id, "running");
return {
id: session.id,
status: "running",
};
}
async createSessionNoTask(dir: string): Promise<SandboxSession> {
return this.createSession({
cwd: dir,
});
}
async listSessions(request: ListPageRequest = {}): Promise<ListPage<SessionRecord>> {
const sdk = await this.sdk();
const page = await sdk.listSessions(request);
return {
items: page.items.map((session) => session.toRecord()),
nextCursor: page.nextCursor,
};
}
async listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>> {
const sdk = await this.sdk();
return sdk.getEvents(request);
}
async createProcess(request: ProcessCreateRequest): Promise<ProcessInfo> {
const sdk = await this.sdk();
return await sdk.createProcess(request);
}
async listProcesses(): Promise<{ processes: ProcessInfo[] }> {
const sdk = await this.sdk();
return await sdk.listProcesses();
}
async getProcessLogs(processId: string, query: ProcessLogFollowQuery = {}): Promise<ProcessLogsResponse> {
const sdk = await this.sdk();
return await sdk.getProcessLogs(processId, query);
}
async stopProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo> {
const sdk = await this.sdk();
return await sdk.stopProcess(processId, query);
}
async killProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo> {
const sdk = await this.sdk();
return await sdk.killProcess(processId, query);
}
async deleteProcess(processId: string): Promise<void> {
const sdk = await this.sdk();
await sdk.deleteProcess(processId);
}
async sendPrompt(request: SandboxSessionPromptRequest): Promise<void> {
const sdk = await this.sdk();
const existing = await sdk.getSession(request.sessionId);
if (!existing) {
throw new Error(`session '${request.sessionId}' not found`);
}
const session = await sdk.resumeSession(request.sessionId);
const modeId = modeIdForAgent(this.agent);
// Keep mode update best-effort and non-blocking for the same reason as createSession.
if (modeId) {
void session.rawSend("session/set_mode", { modeId }).catch(() => {
// ignore
});
}
const text = request.prompt.trim();
if (!text) return;
// sandbox-agent's Session.send(notification=true) forwards an extNotification with
// method "session/prompt", which some agents (e.g. codex-acp) do not implement.
// Use Session.prompt and treat notification=true as "fire-and-forget".
const fireAndForget = request.notification ?? true;
if (fireAndForget) {
void session
.prompt([{ type: "text", text }])
.then(() => {
this.setStatus(request.sessionId, "idle");
})
.catch((err) => {
if (this.isLikelyPromptTimeout(err)) {
this.setStatus(request.sessionId, "running");
return;
}
this.setStatus(request.sessionId, "error");
});
} else {
try {
await session.prompt([{ type: "text", text }]);
this.setStatus(request.sessionId, "idle");
} catch (err) {
if (this.isLikelyPromptTimeout(err)) {
this.setStatus(request.sessionId, "running");
return;
}
throw err;
}
}
this.setStatus(request.sessionId, "running");
}
async cancelSession(sessionId: string): Promise<void> {
const sdk = await this.sdk();
const existing = await sdk.getSession(sessionId);
if (!existing) {
throw new Error(`session '${sessionId}' not found`);
}
const session = await sdk.resumeSession(sessionId);
await session.rawSend("session/cancel", {});
this.setStatus(sessionId, "idle");
}
async destroySession(sessionId: string): Promise<void> {
const sdk = await this.sdk();
await sdk.destroySession(sessionId);
this.setStatus(sessionId, "idle");
}
async sessionStatus(sessionId: string): Promise<SandboxSession> {
const cached = this.statusBySessionId.get(sessionId);
if (cached && cached !== "running") {
return { id: sessionId, status: cached };
}
const sdk = await this.sdk();
const session = await sdk.getSession(sessionId);
if (!session) {
this.setStatus(sessionId, "error");
return { id: sessionId, status: "error" };
}
const record = session.toRecord();
if (record.destroyedAt) {
this.setStatus(sessionId, "idle");
return { id: sessionId, status: "idle" };
}
const events = await sdk.getEvents({
sessionId,
limit: 25,
});
for (let i = events.items.length - 1; i >= 0; i--) {
const item = events.items[i];
if (!item) continue;
const status = normalizeStatusFromMessage(item.payload);
if (status) {
this.setStatus(sessionId, status);
return { id: sessionId, status };
}
}
this.setStatus(sessionId, "running");
return { id: sessionId, status: "running" };
}
async killSessionsInDirectory(dir: string): Promise<void> {
const sdk = await this.sdk();
let cursor: string | undefined;
do {
const page = await sdk.listSessions({
cursor,
limit: 100,
});
for (const session of page.items) {
const initCwd = session.toRecord().sessionInit?.cwd;
if (initCwd !== dir) {
continue;
}
await sdk.destroySession(session.id);
this.statusBySessionId.delete(session.id);
}
cursor = page.nextCursor;
} while (cursor);
}
async generateCommitMessage(dir: string, spec: string, task: string): Promise<string> {
const prompt = [
"Generate a conventional commit message for the following changes.",
"Return ONLY the commit message, no explanation or markdown formatting.",
"",
`Task: ${task}`,
"",
`Spec/diff:\n${spec}`,
].join("\n");
const sdk = await this.sdk();
const session = await sdk.createSession({
agent: this.agent,
sessionInit: {
cwd: dir,
mcpServers: [],
},
});
await session.prompt([{ type: "text", text: prompt }]);
this.setStatus(session.id, "idle");
const events = await sdk.getEvents({
sessionId: session.id,
limit: 100,
});
for (let i = events.items.length - 1; i >= 0; i--) {
const event = events.items[i];
if (!event) continue;
if (event.sender !== "agent") continue;
const payload = event.payload as Record<string, unknown>;
const params = payload.params;
if (!params || typeof params !== "object") continue;
const text = (params as { text?: unknown }).text;
if (typeof text === "string" && text.trim().length > 0) {
return text.trim();
}
}
throw new Error("sandbox-agent commit message response was empty");
}
}