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,427 @@
import * as childProcess from "node:child_process";
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { checkBackendHealth } from "@sandbox-agent/foundry-client";
import type { AppConfig } from "@sandbox-agent/foundry-shared";
import { CLI_BUILD_ID } from "../build-id.js";
const HEALTH_TIMEOUT_MS = 1_500;
const START_TIMEOUT_MS = 30_000;
const STOP_TIMEOUT_MS = 5_000;
const POLL_INTERVAL_MS = 150;
function sleep(ms: number): Promise<void> {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
function sanitizeHost(host: string): string {
return host
.split("")
.map((ch) => (/[a-zA-Z0-9]/.test(ch) ? ch : "-"))
.join("");
}
function backendStateDir(): string {
const override = process.env.HF_BACKEND_STATE_DIR?.trim();
if (override) {
return override;
}
const xdgDataHome = process.env.XDG_DATA_HOME?.trim();
if (xdgDataHome) {
return join(xdgDataHome, "foundry", "backend");
}
return join(homedir(), ".local", "share", "foundry", "backend");
}
function backendPidPath(host: string, port: number): string {
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.pid`);
}
function backendVersionPath(host: string, port: number): string {
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.version`);
}
function backendLogPath(host: string, port: number): string {
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.log`);
}
function readText(path: string): string | null {
try {
return readFileSync(path, "utf8").trim();
} catch {
return null;
}
}
function readPid(host: string, port: number): number | null {
const raw = readText(backendPidPath(host, port));
if (!raw) {
return null;
}
const pid = Number.parseInt(raw, 10);
if (!Number.isInteger(pid) || pid <= 0) {
return null;
}
return pid;
}
function writePid(host: string, port: number, pid: number): void {
const path = backendPidPath(host, port);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, String(pid), "utf8");
}
function removePid(host: string, port: number): void {
const path = backendPidPath(host, port);
if (existsSync(path)) {
rmSync(path);
}
}
function readBackendVersion(host: string, port: number): string | null {
return readText(backendVersionPath(host, port));
}
function writeBackendVersion(host: string, port: number, buildId: string): void {
const path = backendVersionPath(host, port);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, buildId, "utf8");
}
function removeBackendVersion(host: string, port: number): void {
const path = backendVersionPath(host, port);
if (existsSync(path)) {
rmSync(path);
}
}
function readCliBuildId(): string {
const override = process.env.HF_BUILD_ID?.trim();
if (override) {
return override;
}
return CLI_BUILD_ID;
}
function isVersionCurrent(host: string, port: number): boolean {
return readBackendVersion(host, port) === readCliBuildId();
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code === "EPERM") {
return true;
}
return false;
}
}
function removeStateFiles(host: string, port: number): void {
removePid(host, port);
removeBackendVersion(host, port);
}
async function checkHealth(host: string, port: number): Promise<boolean> {
return await checkBackendHealth({
endpoint: `http://${host}:${port}/api/rivet`,
timeoutMs: HEALTH_TIMEOUT_MS,
});
}
async function waitForHealth(host: string, port: number, timeoutMs: number, pid?: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (pid && !isProcessRunning(pid)) {
throw new Error(`backend process ${pid} exited before becoming healthy`);
}
if (await checkHealth(host, port)) {
return;
}
await sleep(POLL_INTERVAL_MS);
}
throw new Error(`backend did not become healthy within ${timeoutMs}ms`);
}
async function waitForChildPid(child: childProcess.ChildProcess): Promise<number | null> {
if (child.pid && child.pid > 0) {
return child.pid;
}
for (let i = 0; i < 20; i += 1) {
await sleep(50);
if (child.pid && child.pid > 0) {
return child.pid;
}
}
return null;
}
interface LaunchSpec {
command: string;
args: string[];
cwd: string;
}
function resolveBunCommand(): string {
const override = process.env.HF_BUN?.trim();
if (override && (override === "bun" || existsSync(override))) {
return override;
}
const homeBun = join(homedir(), ".bun", "bin", "bun");
if (existsSync(homeBun)) {
return homeBun;
}
return "bun";
}
function resolveLaunchSpec(host: string, port: number): LaunchSpec {
const repoRoot = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const backendEntry = resolve(fileURLToPath(new URL("../../backend/dist/index.js", import.meta.url)));
if (existsSync(backendEntry)) {
return {
command: resolveBunCommand(),
args: [backendEntry, "start", "--host", host, "--port", String(port)],
cwd: repoRoot,
};
}
return {
command: "pnpm",
args: ["--filter", "@sandbox-agent/foundry-backend", "exec", "bun", "src/index.ts", "start", "--host", host, "--port", String(port)],
cwd: repoRoot,
};
}
async function startBackend(host: string, port: number): Promise<void> {
if (await checkHealth(host, port)) {
return;
}
const existingPid = readPid(host, port);
if (existingPid && isProcessRunning(existingPid)) {
await waitForHealth(host, port, START_TIMEOUT_MS, existingPid);
return;
}
if (existingPid) {
removeStateFiles(host, port);
}
const logPath = backendLogPath(host, port);
mkdirSync(dirname(logPath), { recursive: true });
const fd = openSync(logPath, "a");
const launch = resolveLaunchSpec(host, port);
const child = childProcess.spawn(launch.command, launch.args, {
cwd: launch.cwd,
detached: true,
stdio: ["ignore", fd, fd],
env: process.env,
});
child.on("error", (error) => {
console.error(`failed to launch backend: ${String(error)}`);
});
child.unref();
closeSync(fd);
const pid = await waitForChildPid(child);
writeBackendVersion(host, port, readCliBuildId());
if (pid) {
writePid(host, port, pid);
}
try {
await waitForHealth(host, port, START_TIMEOUT_MS, pid ?? undefined);
} catch (error) {
if (pid) {
removeStateFiles(host, port);
} else {
removeBackendVersion(host, port);
}
throw error;
}
}
function trySignal(pid: number, signal: NodeJS.Signals): boolean {
try {
process.kill(pid, signal);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code === "ESRCH") {
return false;
}
throw error;
}
}
function findProcessOnPort(port: number): number | null {
try {
const out = childProcess
.execFileSync("lsof", ["-i", `:${port}`, "-t", "-sTCP:LISTEN"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
})
.trim();
const pidRaw = out.split("\n")[0]?.trim();
if (!pidRaw) {
return null;
}
const pid = Number.parseInt(pidRaw, 10);
if (!Number.isInteger(pid) || pid <= 0) {
return null;
}
return pid;
} catch {
return null;
}
}
export async function stopBackend(host: string, port: number): Promise<void> {
let pid = readPid(host, port);
if (!pid) {
if (!(await checkHealth(host, port))) {
removeStateFiles(host, port);
return;
}
pid = findProcessOnPort(port);
if (!pid) {
throw new Error(`backend is healthy at ${host}:${port} but no PID could be resolved`);
}
}
if (!isProcessRunning(pid)) {
removeStateFiles(host, port);
return;
}
trySignal(pid, "SIGTERM");
const deadline = Date.now() + STOP_TIMEOUT_MS;
while (Date.now() < deadline) {
if (!isProcessRunning(pid)) {
removeStateFiles(host, port);
return;
}
await sleep(100);
}
trySignal(pid, "SIGKILL");
removeStateFiles(host, port);
}
export interface BackendStatus {
running: boolean;
pid: number | null;
version: string | null;
versionCurrent: boolean;
logPath: string;
}
export async function getBackendStatus(host: string, port: number): Promise<BackendStatus> {
const logPath = backendLogPath(host, port);
const pid = readPid(host, port);
if (pid) {
if (isProcessRunning(pid)) {
return {
running: true,
pid,
version: readBackendVersion(host, port),
versionCurrent: isVersionCurrent(host, port),
logPath,
};
}
removeStateFiles(host, port);
}
if (await checkHealth(host, port)) {
return {
running: true,
pid: null,
version: readBackendVersion(host, port),
versionCurrent: isVersionCurrent(host, port),
logPath,
};
}
return {
running: false,
pid: null,
version: readBackendVersion(host, port),
versionCurrent: false,
logPath,
};
}
export async function ensureBackendRunning(config: AppConfig): Promise<void> {
const host = config.backend.host;
const port = config.backend.port;
if (await checkHealth(host, port)) {
if (!isVersionCurrent(host, port)) {
await stopBackend(host, port);
await startBackend(host, port);
}
return;
}
const pid = readPid(host, port);
if (pid && isProcessRunning(pid)) {
try {
await waitForHealth(host, port, START_TIMEOUT_MS, pid);
if (!isVersionCurrent(host, port)) {
await stopBackend(host, port);
await startBackend(host, port);
}
return;
} catch {
await stopBackend(host, port);
await startBackend(host, port);
return;
}
}
if (pid) {
removeStateFiles(host, port);
}
await startBackend(host, port);
}
export function parseBackendPort(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid backend port: ${value}`);
}
return port;
}

View file

@ -0,0 +1,3 @@
declare const __HF_BUILD_ID__: string | undefined;
export const CLI_BUILD_ID = typeof __HF_BUILD_ID__ === "string" && __HF_BUILD_ID__.trim().length > 0 ? __HF_BUILD_ID__.trim() : "dev";

View file

@ -0,0 +1,731 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { AgentTypeSchema, CreateTaskInputSchema, type TaskRecord } from "@sandbox-agent/foundry-shared";
import { readBackendMetadata, createBackendClientFromConfig, formatRelativeAge, groupTaskStatus, summarizeTasks } from "@sandbox-agent/foundry-client";
import { ensureBackendRunning, getBackendStatus, parseBackendPort, stopBackend } from "./backend/manager.js";
import { openEditorForTask } from "./task-editor.js";
import { spawnCreateTmuxWindow } from "./tmux.js";
import { loadConfig, resolveWorkspace, saveConfig } from "./workspace/config.js";
async function ensureBunRuntime(): Promise<void> {
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
return;
}
const preferred = process.env.HF_BUN?.trim();
const candidates = [preferred, `${homedir()}/.bun/bin/bun`, "bun"].filter((item): item is string => Boolean(item && item.length > 0));
for (const candidate of candidates) {
const command = candidate;
const canExec = command === "bun" || existsSync(command);
if (!canExec) {
continue;
}
const child = spawnSync(command, [process.argv[1] ?? "", ...process.argv.slice(2)], {
stdio: "inherit",
env: process.env,
});
if (child.error) {
continue;
}
const code = child.status ?? 1;
process.exit(code);
}
throw new Error("hf requires Bun runtime. Set HF_BUN or install Bun at ~/.bun/bin/bun.");
}
async function runTuiCommand(config: ReturnType<typeof loadConfig>, workspaceId: string): Promise<void> {
const mod = await import("./tui.js");
await mod.runTui(config, workspaceId);
}
function readOption(args: string[], flag: string): string | undefined {
const idx = args.indexOf(flag);
if (idx < 0) return undefined;
return args[idx + 1];
}
function hasFlag(args: string[], flag: string): boolean {
return args.includes(flag);
}
function parseIntOption(value: string | undefined, fallback: number, label: string): number {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid ${label}: ${value}`);
}
return parsed;
}
function positionals(args: string[]): string[] {
const out: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const item = args[i];
if (!item) {
continue;
}
if (item.startsWith("--")) {
const next = args[i + 1];
if (next && !next.startsWith("--")) {
i += 1;
}
continue;
}
out.push(item);
}
return out;
}
function printUsage(): void {
console.log(`
Usage:
hf backend start [--host HOST] [--port PORT]
hf backend stop [--host HOST] [--port PORT]
hf backend status
hf backend inspect
hf status [--workspace WS] [--json]
hf history [--workspace WS] [--limit N] [--branch NAME] [--task ID] [--json]
hf workspace use <name>
hf tui [--workspace WS]
hf create [task] [--workspace WS] --repo <git-remote> [--name NAME|--branch NAME] [--title TITLE] [--agent claude|codex] [--on BRANCH]
hf list [--workspace WS] [--format table|json] [--full]
hf switch [task-id | -] [--workspace WS]
hf attach <task-id> [--workspace WS]
hf merge <task-id> [--workspace WS]
hf archive <task-id> [--workspace WS]
hf push <task-id> [--workspace WS]
hf sync <task-id> [--workspace WS]
hf kill <task-id> [--workspace WS] [--delete-branch] [--abandon]
hf prune [--workspace WS] [--dry-run] [--yes]
hf statusline [--workspace WS] [--format table|claude-code]
hf db path
hf db nuke
Tips:
hf status --help Show status output format and examples
hf history --help Show history output format and examples
hf switch - Switch to most recently updated task
`);
}
function printStatusUsage(): void {
console.log(`
Usage:
hf status [--workspace WS] [--json]
Text Output:
workspace=<workspace-id>
backend running=<true|false> pid=<pid|unknown> version=<version|unknown>
tasks total=<number>
status queued=<n> running=<n> idle=<n> archived=<n> killed=<n> error=<n>
providers <provider-id>=<count> ...
providers -
JSON Output:
{
"workspaceId": "default",
"backend": { ...backend status object... },
"tasks": {
"total": 4,
"byStatus": { "queued": 0, "running": 1, "idle": 2, "archived": 1, "killed": 0, "error": 0 },
"byProvider": { "daytona": 4 }
}
}
`);
}
function printHistoryUsage(): void {
console.log(`
Usage:
hf history [--workspace WS] [--limit N] [--branch NAME] [--task ID] [--json]
Text Output:
<iso8601>\t<event-kind>\t<branch|task|repo|->\t<payload-json>
<iso8601>\t<event-kind>\t<branch|task|repo|->\t<payload-json...>
no events
Notes:
- payload is truncated to 120 characters in text mode.
- --limit defaults to 20.
JSON Output:
[
{
"id": "...",
"workspaceId": "default",
"kind": "task.created",
"taskId": "...",
"repoId": "...",
"branchName": "feature/foo",
"payloadJson": "{\\"providerId\\":\\"daytona\\"}",
"createdAt": 1770607522229
}
]
`);
}
async function handleBackend(args: string[]): Promise<void> {
const sub = args[0] ?? "start";
const config = loadConfig();
const host = readOption(args, "--host") ?? config.backend.host;
const port = parseBackendPort(readOption(args, "--port"), config.backend.port);
const backendConfig = {
...config,
backend: {
...config.backend,
host,
port,
},
};
if (sub === "start") {
await ensureBackendRunning(backendConfig);
const status = await getBackendStatus(host, port);
const pid = status.pid ?? "unknown";
const version = status.version ?? "unknown";
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
console.log(`running=true pid=${pid} version=${version}${stale} log=${status.logPath}`);
return;
}
if (sub === "stop") {
await stopBackend(host, port);
console.log(`running=false host=${host} port=${port}`);
return;
}
if (sub === "status") {
const status = await getBackendStatus(host, port);
const pid = status.pid ?? "unknown";
const version = status.version ?? "unknown";
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
console.log(`running=${status.running} pid=${pid} version=${version}${stale} host=${host} port=${port} log=${status.logPath}`);
return;
}
if (sub === "inspect") {
await ensureBackendRunning(backendConfig);
const metadata = await readBackendMetadata({
endpoint: `http://${host}:${port}/api/rivet`,
timeoutMs: 4_000,
});
const managerEndpoint = metadata.clientEndpoint ?? `http://${host}:${port}`;
const inspectorUrl = `https://inspect.rivet.dev?u=${encodeURIComponent(managerEndpoint)}`;
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
spawnSync(openCmd, [inspectorUrl], { stdio: "ignore" });
console.log(inspectorUrl);
return;
}
throw new Error(`Unknown backend subcommand: ${sub}`);
}
async function handleWorkspace(args: string[]): Promise<void> {
const sub = args[0];
if (sub !== "use") {
throw new Error("Usage: hf workspace use <name>");
}
const name = args[1];
if (!name) {
throw new Error("Missing workspace name");
}
const config = loadConfig();
config.workspace.default = name;
saveConfig(config);
const client = createBackendClientFromConfig(config);
try {
await client.useWorkspace(name);
} catch {
// Backend may not be running yet. Config is already updated.
}
console.log(`workspace=${name}`);
}
async function handleList(args: string[]): Promise<void> {
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const format = readOption(args, "--format") ?? "table";
const full = hasFlag(args, "--full");
const client = createBackendClientFromConfig(config);
const rows = await client.listTasks(workspaceId);
if (format === "json") {
console.log(JSON.stringify(rows, null, 2));
return;
}
if (rows.length === 0) {
console.log("no tasks");
return;
}
for (const row of rows) {
const age = formatRelativeAge(row.updatedAt);
let line = `${row.taskId}\t${row.branchName}\t${row.status}\t${row.providerId}\t${age}`;
if (full) {
const task = row.task.length > 60 ? `${row.task.slice(0, 57)}...` : row.task;
line += `\t${row.title}\t${task}\t${row.activeSessionId ?? "-"}\t${row.activeSandboxId ?? "-"}`;
}
console.log(line);
}
}
async function handlePush(args: string[]): Promise<void> {
const taskId = positionals(args)[0];
if (!taskId) {
throw new Error("Missing task id for push");
}
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const client = createBackendClientFromConfig(config);
await client.runAction(workspaceId, taskId, "push");
console.log("ok");
}
async function handleSync(args: string[]): Promise<void> {
const taskId = positionals(args)[0];
if (!taskId) {
throw new Error("Missing task id for sync");
}
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const client = createBackendClientFromConfig(config);
await client.runAction(workspaceId, taskId, "sync");
console.log("ok");
}
async function handleKill(args: string[]): Promise<void> {
const taskId = positionals(args)[0];
if (!taskId) {
throw new Error("Missing task id for kill");
}
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const deleteBranch = hasFlag(args, "--delete-branch");
const abandon = hasFlag(args, "--abandon");
if (deleteBranch) {
console.log("info: --delete-branch flag set, branch will be deleted after kill");
}
if (abandon) {
console.log("info: --abandon flag set, Graphite abandon will be attempted");
}
const client = createBackendClientFromConfig(config);
await client.runAction(workspaceId, taskId, "kill");
console.log("ok");
}
async function handlePrune(args: string[]): Promise<void> {
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const dryRun = hasFlag(args, "--dry-run");
const yes = hasFlag(args, "--yes");
const client = createBackendClientFromConfig(config);
const rows = await client.listTasks(workspaceId);
const prunable = rows.filter((r) => r.status === "archived" || r.status === "killed");
if (prunable.length === 0) {
console.log("nothing to prune");
return;
}
for (const row of prunable) {
const age = formatRelativeAge(row.updatedAt);
console.log(`${dryRun ? "[dry-run] " : ""}${row.taskId}\t${row.branchName}\t${row.status}\t${age}`);
}
if (dryRun) {
console.log(`\n${prunable.length} task(s) would be pruned`);
return;
}
if (!yes) {
console.log("\nnot yet implemented: auto-pruning requires confirmation");
return;
}
console.log(`\n${prunable.length} task(s) would be pruned (pruning not yet implemented)`);
}
async function handleStatusline(args: string[]): Promise<void> {
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const format = readOption(args, "--format") ?? "table";
const client = createBackendClientFromConfig(config);
const rows = await client.listTasks(workspaceId);
const summary = summarizeTasks(rows);
const running = summary.byStatus.running;
const idle = summary.byStatus.idle;
const errorCount = summary.byStatus.error;
if (format === "claude-code") {
console.log(`hf:${running}R/${idle}I/${errorCount}E`);
return;
}
console.log(`running=${running} idle=${idle} error=${errorCount}`);
}
async function handleDb(args: string[]): Promise<void> {
const sub = args[0];
if (sub === "path") {
const config = loadConfig();
const dbPath = config.backend.dbPath.replace(/^~/, homedir());
console.log(dbPath);
return;
}
if (sub === "nuke") {
console.log("WARNING: hf db nuke would delete the entire database. This is a placeholder and does not delete anything.");
return;
}
throw new Error("Usage: hf db path | hf db nuke");
}
async function waitForTaskReady(
client: ReturnType<typeof createBackendClientFromConfig>,
workspaceId: string,
taskId: string,
timeoutMs: number,
): Promise<TaskRecord> {
const start = Date.now();
let delayMs = 250;
for (;;) {
const record = await client.getTask(workspaceId, taskId);
const hasName = Boolean(record.branchName && record.title);
const hasSandbox = Boolean(record.activeSandboxId);
if (record.status === "error") {
throw new Error(`task entered error state while provisioning: ${taskId}`);
}
if (hasName && hasSandbox) {
return record;
}
if (Date.now() - start > timeoutMs) {
throw new Error(`timed out waiting for task provisioning: ${taskId}`);
}
await new Promise((r) => setTimeout(r, delayMs));
delayMs = Math.min(Math.round(delayMs * 1.5), 2_000);
}
}
async function handleCreate(args: string[]): Promise<void> {
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const repoRemote = readOption(args, "--repo");
if (!repoRemote) {
throw new Error("Missing required --repo <git-remote>");
}
const explicitBranchName = readOption(args, "--name") ?? readOption(args, "--branch");
const explicitTitle = readOption(args, "--title");
const agentRaw = readOption(args, "--agent");
const agentType = agentRaw ? AgentTypeSchema.parse(agentRaw) : undefined;
const onBranch = readOption(args, "--on");
const taskFromArgs = positionals(args).join(" ").trim();
const task = taskFromArgs || openEditorForTask();
const client = createBackendClientFromConfig(config);
const repo = await client.addRepo(workspaceId, repoRemote);
const payload = CreateTaskInputSchema.parse({
workspaceId,
repoId: repo.repoId,
task,
explicitTitle: explicitTitle || undefined,
explicitBranchName: explicitBranchName || undefined,
agentType,
onBranch,
});
const created = await client.createTask(payload);
const task = await waitForTaskReady(client, workspaceId, created.taskId, 180_000);
const switched = await client.switchTask(workspaceId, task.taskId);
const attached = await client.attachTask(workspaceId, task.taskId);
console.log(`Branch: ${task.branchName ?? "-"}`);
console.log(`Task: ${task.taskId}`);
console.log(`Provider: ${task.providerId}`);
console.log(`Session: ${attached.sessionId ?? "none"}`);
console.log(`Target: ${switched.switchTarget || attached.target}`);
console.log(`Title: ${task.title ?? "-"}`);
const tmuxResult = spawnCreateTmuxWindow({
branchName: task.branchName ?? task.taskId,
targetPath: switched.switchTarget || attached.target,
sessionId: attached.sessionId,
});
if (tmuxResult.created) {
console.log(`Window: created (${task.branchName})`);
return;
}
console.log("");
console.log(`Run: hf switch ${task.taskId}`);
if ((switched.switchTarget || attached.target).startsWith("/")) {
console.log(`cd ${switched.switchTarget || attached.target}`);
}
}
async function handleTui(args: string[]): Promise<void> {
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
await runTuiCommand(config, workspaceId);
}
async function handleStatus(args: string[]): Promise<void> {
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
printStatusUsage();
return;
}
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const client = createBackendClientFromConfig(config);
const backendStatus = await getBackendStatus(config.backend.host, config.backend.port);
const rows = await client.listTasks(workspaceId);
const summary = summarizeTasks(rows);
if (hasFlag(args, "--json")) {
console.log(
JSON.stringify(
{
workspaceId,
backend: backendStatus,
tasks: {
total: summary.total,
byStatus: summary.byStatus,
byProvider: summary.byProvider,
},
},
null,
2,
),
);
return;
}
console.log(`workspace=${workspaceId}`);
console.log(`backend running=${backendStatus.running} pid=${backendStatus.pid ?? "unknown"} version=${backendStatus.version ?? "unknown"}`);
console.log(`tasks total=${summary.total}`);
console.log(
`status queued=${summary.byStatus.queued} running=${summary.byStatus.running} idle=${summary.byStatus.idle} archived=${summary.byStatus.archived} killed=${summary.byStatus.killed} error=${summary.byStatus.error}`,
);
const providerSummary = Object.entries(summary.byProvider)
.map(([provider, count]) => `${provider}=${count}`)
.join(" ");
console.log(`providers ${providerSummary || "-"}`);
}
async function handleHistory(args: string[]): Promise<void> {
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
printHistoryUsage();
return;
}
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const limit = parseIntOption(readOption(args, "--limit"), 20, "limit");
const branch = readOption(args, "--branch");
const taskId = readOption(args, "--task");
const client = createBackendClientFromConfig(config);
const rows = await client.listHistory({
workspaceId,
limit,
branch: branch || undefined,
taskId: taskId || undefined,
});
if (hasFlag(args, "--json")) {
console.log(JSON.stringify(rows, null, 2));
return;
}
if (rows.length === 0) {
console.log("no events");
return;
}
for (const row of rows) {
const ts = new Date(row.createdAt).toISOString();
const target = row.branchName || row.taskId || row.repoId || "-";
let payload = row.payloadJson;
if (payload.length > 120) {
payload = `${payload.slice(0, 117)}...`;
}
console.log(`${ts}\t${row.kind}\t${target}\t${payload}`);
}
}
async function handleSwitchLike(cmd: string, args: string[]): Promise<void> {
let taskId = positionals(args)[0];
if (!taskId && cmd === "switch") {
await handleTui(args);
return;
}
if (!taskId) {
throw new Error(`Missing task id for ${cmd}`);
}
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const client = createBackendClientFromConfig(config);
if (cmd === "switch" && taskId === "-") {
const rows = await client.listTasks(workspaceId);
const active = rows.filter((r) => {
const group = groupTaskStatus(r.status);
return group === "running" || group === "idle" || group === "queued";
});
const sorted = active.sort((a, b) => b.updatedAt - a.updatedAt);
const target = sorted[0];
if (!target) {
throw new Error("No active tasks to switch to");
}
taskId = target.taskId;
}
if (cmd === "switch") {
const result = await client.switchTask(workspaceId, taskId);
console.log(`cd ${result.switchTarget}`);
return;
}
if (cmd === "attach") {
const result = await client.attachTask(workspaceId, taskId);
console.log(`target=${result.target} session=${result.sessionId ?? "none"}`);
return;
}
if (cmd === "merge" || cmd === "archive") {
await client.runAction(workspaceId, taskId, cmd);
console.log("ok");
return;
}
throw new Error(`Unsupported action: ${cmd}`);
}
async function main(): Promise<void> {
await ensureBunRuntime();
const args = process.argv.slice(2);
const cmd = args[0];
const rest = args.slice(1);
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
printUsage();
return;
}
if (cmd === "backend") {
await handleBackend(rest);
return;
}
const config = loadConfig();
await ensureBackendRunning(config);
if (!cmd || cmd.startsWith("--")) {
await handleTui(args);
return;
}
if (cmd === "workspace") {
await handleWorkspace(rest);
return;
}
if (cmd === "create") {
await handleCreate(rest);
return;
}
if (cmd === "list") {
await handleList(rest);
return;
}
if (cmd === "tui") {
await handleTui(rest);
return;
}
if (cmd === "status") {
await handleStatus(rest);
return;
}
if (cmd === "history") {
await handleHistory(rest);
return;
}
if (cmd === "push") {
await handlePush(rest);
return;
}
if (cmd === "sync") {
await handleSync(rest);
return;
}
if (cmd === "kill") {
await handleKill(rest);
return;
}
if (cmd === "prune") {
await handlePrune(rest);
return;
}
if (cmd === "statusline") {
await handleStatusline(rest);
return;
}
if (cmd === "db") {
await handleDb(rest);
return;
}
if (["switch", "attach", "merge", "archive"].includes(cmd)) {
await handleSwitchLike(cmd, rest);
return;
}
printUsage();
throw new Error(`Unknown command: ${cmd}`);
}
main().catch((err: unknown) => {
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
console.error(msg);
process.exit(1);
});

View file

@ -0,0 +1,41 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const DEFAULT_EDITOR_TEMPLATE = ["# Enter task task details below.", "# Lines starting with # are ignored.", ""].join("\n");
export function sanitizeEditorTask(input: string): string {
return input
.split(/\r?\n/)
.filter((line) => !line.trim().startsWith("#"))
.join("\n")
.trim();
}
export function openEditorForTask(): string {
const editor = process.env.VISUAL?.trim() || process.env.EDITOR?.trim() || "vi";
const tempDir = mkdtempSync(join(tmpdir(), "hf-task-"));
const taskPath = join(tempDir, "task.md");
try {
writeFileSync(taskPath, DEFAULT_EDITOR_TEMPLATE, "utf8");
const result = spawnSync(editor, [taskPath], { stdio: "inherit" });
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
throw new Error(`Editor exited with status ${result.status ?? "unknown"}`);
}
const raw = readFileSync(taskPath, "utf8");
const task = sanitizeEditorTask(raw);
if (!task) {
throw new Error("Missing task task text");
}
return task;
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}

View file

@ -0,0 +1,792 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { cwd } from "node:process";
import * as toml from "@iarna/toml";
import type { AppConfig } from "@sandbox-agent/foundry-shared";
import opencodeThemePackJson from "./themes/opencode-pack.json" with { type: "json" };
export type ThemeMode = "dark" | "light";
export interface TuiTheme {
background: string;
text: string;
muted: string;
header: string;
status: string;
highlightBg: string;
highlightFg: string;
selectionBorder: string;
success: string;
warning: string;
error: string;
info: string;
diffAdd: string;
diffDel: string;
diffSep: string;
agentRunning: string;
agentIdle: string;
agentNone: string;
agentError: string;
prUnpushed: string;
author: string;
ciRunning: string;
ciPass: string;
ciFail: string;
ciNone: string;
reviewApproved: string;
reviewChanges: string;
reviewPending: string;
reviewNone: string;
}
export interface TuiThemeResolution {
theme: TuiTheme;
name: string;
source: string;
mode: ThemeMode;
}
interface ThemeCandidate {
theme: TuiTheme;
name: string;
}
type JsonObject = Record<string, unknown>;
type ConfigLike = AppConfig & { theme?: string };
const DEFAULT_THEME: TuiTheme = {
background: "#282828",
text: "#ffffff",
muted: "#6b7280",
header: "#6b7280",
status: "#6b7280",
highlightBg: "#282828",
highlightFg: "#ffffff",
selectionBorder: "#d946ef",
success: "#22c55e",
warning: "#eab308",
error: "#ef4444",
info: "#22d3ee",
diffAdd: "#22c55e",
diffDel: "#ef4444",
diffSep: "#6b7280",
agentRunning: "#22c55e",
agentIdle: "#eab308",
agentNone: "#6b7280",
agentError: "#ef4444",
prUnpushed: "#eab308",
author: "#22d3ee",
ciRunning: "#eab308",
ciPass: "#22c55e",
ciFail: "#ef4444",
ciNone: "#6b7280",
reviewApproved: "#22c55e",
reviewChanges: "#ef4444",
reviewPending: "#eab308",
reviewNone: "#6b7280",
};
const OPENCODE_THEME_PACK = opencodeThemePackJson as Record<string, unknown>;
export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeResolution {
const mode = opencodeStateThemeMode() ?? "dark";
const configWithTheme = config as ConfigLike;
const override = typeof configWithTheme.theme === "string" ? configWithTheme.theme.trim() : "";
if (override) {
const candidate = loadFromSpec(override, [], mode, baseDir);
if (candidate) {
return {
theme: candidate.theme,
name: candidate.name,
source: "foundry config",
mode,
};
}
}
const fromConfig = loadOpencodeThemeFromConfig(mode, baseDir);
if (fromConfig) {
return fromConfig;
}
const fromState = loadOpencodeThemeFromState(mode, baseDir);
if (fromState) {
return fromState;
}
return {
theme: DEFAULT_THEME,
name: "opencode-default",
source: "default",
mode,
};
}
function loadOpencodeThemeFromConfig(mode: ThemeMode, baseDir: string): TuiThemeResolution | null {
for (const path of opencodeConfigPaths(baseDir)) {
if (!existsSync(path)) {
continue;
}
const value = readJsonWithComments(path);
if (!value) {
continue;
}
const themeValue = findOpencodeThemeValue(value);
if (themeValue === undefined) {
continue;
}
const candidate = themeFromOpencodeValue(themeValue, opencodeThemeDirs(dirname(path), baseDir), mode, baseDir);
if (!candidate) {
continue;
}
return {
theme: candidate.theme,
name: candidate.name,
source: `opencode config (${path})`,
mode,
};
}
return null;
}
function loadOpencodeThemeFromState(mode: ThemeMode, baseDir: string): TuiThemeResolution | null {
const path = opencodeStatePath();
if (!path || !existsSync(path)) {
return null;
}
const value = readJsonWithComments(path);
if (!isObject(value)) {
return null;
}
const spec = value.theme;
if (typeof spec !== "string" || !spec.trim()) {
return null;
}
const candidate = loadFromSpec(spec.trim(), opencodeThemeDirs(undefined, baseDir), mode, baseDir);
if (!candidate) {
return null;
}
return {
theme: candidate.theme,
name: candidate.name,
source: `opencode state (${path})`,
mode,
};
}
function loadFromSpec(spec: string, searchDirs: string[], mode: ThemeMode, baseDir: string): ThemeCandidate | null {
if (isDefaultThemeName(spec)) {
return {
theme: DEFAULT_THEME,
name: "opencode-default",
};
}
if (isPathLike(spec)) {
const resolved = resolvePath(spec, baseDir);
if (existsSync(resolved)) {
const candidate = loadThemeFromPath(resolved, mode);
if (candidate) {
return candidate;
}
}
}
for (const dir of searchDirs) {
for (const ext of ["json", "toml"]) {
const path = join(dir, `${spec}.${ext}`);
if (!existsSync(path)) {
continue;
}
const candidate = loadThemeFromPath(path, mode);
if (candidate) {
return candidate;
}
}
}
const builtIn = OPENCODE_THEME_PACK[spec];
if (builtIn !== undefined) {
const theme = themeFromOpencodeJson(builtIn, mode);
if (theme) {
return {
theme,
name: spec,
};
}
}
return null;
}
function loadThemeFromPath(path: string, mode: ThemeMode): ThemeCandidate | null {
const content = safeReadText(path);
if (!content) {
return null;
}
const lower = path.toLowerCase();
if (lower.endsWith(".toml")) {
try {
const parsed = toml.parse(content);
const theme = themeFromAny(parsed);
if (!theme) {
return null;
}
return {
theme,
name: themeNameFromPath(path),
};
} catch {
return null;
}
}
const value = parseJsonWithComments(content);
if (!value) {
return null;
}
const opencodeTheme = themeFromOpencodeJson(value, mode);
if (opencodeTheme) {
return {
theme: opencodeTheme,
name: themeNameFromPath(path),
};
}
const paletteTheme = themeFromAny(value);
if (!paletteTheme) {
return null;
}
return {
theme: paletteTheme,
name: themeNameFromPath(path),
};
}
function themeNameFromPath(path: string): string {
const base = path.split(/[\\/]/).pop() ?? path;
if (base.endsWith(".json") || base.endsWith(".toml")) {
return base.replace(/\.(json|toml)$/i, "");
}
return base;
}
function themeFromOpencodeValue(value: unknown, searchDirs: string[], mode: ThemeMode, baseDir: string): ThemeCandidate | null {
if (typeof value === "string") {
return loadFromSpec(value, searchDirs, mode, baseDir);
}
if (!isObject(value)) {
return null;
}
if (value.theme !== undefined) {
const theme = themeFromOpencodeJson(value, mode);
if (theme) {
return {
theme,
name: typeof value.name === "string" ? value.name : "inline",
};
}
}
const paletteTheme = themeFromAny(value.colors ?? value.palette ?? value);
if (paletteTheme) {
return {
theme: paletteTheme,
name: typeof value.name === "string" ? value.name : "inline",
};
}
if (typeof value.name === "string") {
const named = loadFromSpec(value.name, searchDirs, mode, baseDir);
if (named) {
return named;
}
}
const pathLike = value.path ?? value.file;
if (typeof pathLike === "string") {
const resolved = resolvePath(pathLike, baseDir);
const candidate = loadThemeFromPath(resolved, mode);
if (candidate) {
return candidate;
}
}
return null;
}
function themeFromOpencodeJson(value: unknown, mode: ThemeMode): TuiTheme | null {
if (!isObject(value)) {
return null;
}
const themeMap = value.theme;
if (!isObject(themeMap)) {
return null;
}
const defs = isObject(value.defs) ? value.defs : {};
const background =
opencodeColor(themeMap, defs, mode, "background") ??
opencodeColor(themeMap, defs, mode, "backgroundPanel") ??
opencodeColor(themeMap, defs, mode, "backgroundElement") ??
DEFAULT_THEME.background;
const text = opencodeColor(themeMap, defs, mode, "text") ?? DEFAULT_THEME.text;
const muted = opencodeColor(themeMap, defs, mode, "textMuted") ?? DEFAULT_THEME.muted;
const highlightBg = opencodeColor(themeMap, defs, mode, "text") ?? text;
const highlightFg =
opencodeColor(themeMap, defs, mode, "backgroundElement") ??
opencodeColor(themeMap, defs, mode, "backgroundPanel") ??
opencodeColor(themeMap, defs, mode, "background") ??
DEFAULT_THEME.highlightFg;
const selectionBorder =
opencodeColor(themeMap, defs, mode, "secondary") ??
opencodeColor(themeMap, defs, mode, "accent") ??
opencodeColor(themeMap, defs, mode, "primary") ??
DEFAULT_THEME.selectionBorder;
const success = opencodeColor(themeMap, defs, mode, "success") ?? DEFAULT_THEME.success;
const warning = opencodeColor(themeMap, defs, mode, "warning") ?? DEFAULT_THEME.warning;
const error = opencodeColor(themeMap, defs, mode, "error") ?? DEFAULT_THEME.error;
const info = opencodeColor(themeMap, defs, mode, "info") ?? DEFAULT_THEME.info;
const diffAdd = opencodeColor(themeMap, defs, mode, "diffAdded") ?? success;
const diffDel = opencodeColor(themeMap, defs, mode, "diffRemoved") ?? error;
const diffSep = opencodeColor(themeMap, defs, mode, "diffContext") ?? opencodeColor(themeMap, defs, mode, "diffHunkHeader") ?? muted;
return {
background,
text,
muted,
header: muted,
status: muted,
highlightBg,
highlightFg,
selectionBorder,
success,
warning,
error,
info,
diffAdd,
diffDel,
diffSep,
agentRunning: success,
agentIdle: warning,
agentNone: muted,
agentError: error,
prUnpushed: warning,
author: info,
ciRunning: warning,
ciPass: success,
ciFail: error,
ciNone: muted,
reviewApproved: success,
reviewChanges: error,
reviewPending: warning,
reviewNone: muted,
};
}
function opencodeColor(themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, key: string): string | null {
const raw = themeMap[key];
if (raw === undefined) {
return null;
}
return resolveOpencodeColor(raw, themeMap, defs, mode, 0);
}
function resolveOpencodeColor(value: unknown, themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, depth: number): string | null {
if (depth > 12) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed || trimmed.toLowerCase() === "transparent" || trimmed.toLowerCase() === "none") {
return null;
}
const fromDefs = defs[trimmed];
if (fromDefs !== undefined) {
return resolveOpencodeColor(fromDefs, themeMap, defs, mode, depth + 1);
}
const fromTheme = themeMap[trimmed];
if (fromTheme !== undefined) {
return resolveOpencodeColor(fromTheme, themeMap, defs, mode, depth + 1);
}
if (isColorLike(trimmed)) {
return trimmed;
}
return null;
}
if (isObject(value)) {
const nested = value[mode];
if (nested !== undefined) {
return resolveOpencodeColor(nested, themeMap, defs, mode, depth + 1);
}
}
return null;
}
function themeFromAny(value: unknown): TuiTheme | null {
const palette = extractPalette(value);
if (!palette) {
return null;
}
const pick = (keys: string[], fallback: string): string => {
for (const key of keys) {
const v = palette[normalizeKey(key)];
if (v && isColorLike(v)) {
return v;
}
}
return fallback;
};
const background = pick(["background", "bg", "base", "background_color"], DEFAULT_THEME.background);
const text = pick(["text", "foreground", "fg", "primary"], DEFAULT_THEME.text);
const muted = pick(["muted", "subtle", "secondary", "dim"], DEFAULT_THEME.muted);
const header = pick(["header", "header_text"], muted);
const status = pick(["status", "status_text"], muted);
const highlightBg = pick(["highlight_bg", "selection", "highlight", "accent_bg"], DEFAULT_THEME.highlightBg);
const highlightFg = pick(["highlight_fg", "selection_fg", "accent_fg"], text);
const selectionBorder = pick(["selection_border", "highlight_border", "accent", "secondary"], DEFAULT_THEME.selectionBorder);
const success = pick(["success", "green"], DEFAULT_THEME.success);
const warning = pick(["warning", "yellow"], DEFAULT_THEME.warning);
const error = pick(["error", "red"], DEFAULT_THEME.error);
const info = pick(["info", "cyan", "blue"], DEFAULT_THEME.info);
const diffAdd = pick(["diff_add", "diff_addition", "add"], success);
const diffDel = pick(["diff_del", "diff_deletion", "delete"], error);
const diffSep = pick(["diff_sep", "diff_separator", "separator"], muted);
return {
background,
text,
muted,
header,
status,
highlightBg,
highlightFg,
selectionBorder,
success,
warning,
error,
info,
diffAdd,
diffDel,
diffSep,
agentRunning: pick(["agent_running", "running"], success),
agentIdle: pick(["agent_idle", "idle"], warning),
agentNone: pick(["agent_none", "none"], muted),
agentError: pick(["agent_error", "agent_failed"], error),
prUnpushed: pick(["pr_unpushed", "unpushed"], warning),
author: pick(["author"], info),
ciRunning: pick(["ci_running"], warning),
ciPass: pick(["ci_pass", "ci_success"], success),
ciFail: pick(["ci_fail", "ci_error"], error),
ciNone: pick(["ci_none", "ci_unknown"], muted),
reviewApproved: pick(["review_approved", "approved"], success),
reviewChanges: pick(["review_changes", "changes"], error),
reviewPending: pick(["review_pending", "pending"], warning),
reviewNone: pick(["review_none", "review_unknown"], muted),
};
}
function extractPalette(value: unknown): Record<string, string> | null {
if (!isObject(value)) {
return null;
}
const colors = isObject(value.colors) ? value.colors : undefined;
const palette = isObject(value.palette) ? value.palette : undefined;
const source = colors ?? palette ?? value;
if (!isObject(source)) {
return null;
}
const out: Record<string, string> = {};
for (const [key, raw] of Object.entries(source)) {
if (typeof raw !== "string") {
continue;
}
out[normalizeKey(key)] = raw;
}
return Object.keys(out).length > 0 ? out : null;
}
function normalizeKey(key: string): string {
return key.toLowerCase().replace(/[\-\s.]/g, "_");
}
function isColorLike(value: string): boolean {
const lower = value.trim().toLowerCase();
if (!lower) {
return false;
}
if (/^#[0-9a-f]{3}$/.test(lower) || /^#[0-9a-f]{6}$/.test(lower) || /^#[0-9a-f]{8}$/.test(lower)) {
return true;
}
if (/^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(\s*,\s*[\d.]+)?\s*\)$/.test(lower)) {
return true;
}
return /^[a-z_\-]+$/.test(lower);
}
function findOpencodeThemeValue(value: unknown): unknown {
if (!isObject(value)) {
return undefined;
}
if (value.theme !== undefined) {
return value.theme;
}
return pointer(value, ["ui", "theme"]) ?? pointer(value, ["tui", "theme"]) ?? pointer(value, ["options", "theme"]);
}
function pointer(obj: JsonObject, parts: string[]): unknown {
let current: unknown = obj;
for (const part of parts) {
if (!isObject(current)) {
return undefined;
}
current = current[part];
}
return current;
}
function opencodeConfigPaths(baseDir: string): string[] {
const paths: string[] = [];
const rootish = opencodeProjectConfigPaths(baseDir);
paths.push(...rootish);
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
const opencodeDir = join(configDir, "opencode");
paths.push(join(opencodeDir, "opencode.json"));
paths.push(join(opencodeDir, "opencode.jsonc"));
paths.push(join(opencodeDir, "config.json"));
return paths;
}
function opencodeThemeDirs(configDir: string | undefined, baseDir: string): string[] {
const dirs: string[] = [];
if (configDir) {
dirs.push(join(configDir, "themes"));
}
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
dirs.push(join(xdgConfig, "opencode", "themes"));
dirs.push(join(homedir(), ".opencode", "themes"));
dirs.push(...opencodeProjectThemeDirs(baseDir));
return dirs;
}
function opencodeProjectConfigPaths(baseDir: string): string[] {
const dirs = ancestorDirs(baseDir);
const out: string[] = [];
for (const dir of dirs) {
out.push(join(dir, "opencode.json"));
out.push(join(dir, "opencode.jsonc"));
out.push(join(dir, ".opencode", "opencode.json"));
out.push(join(dir, ".opencode", "opencode.jsonc"));
}
return out;
}
function opencodeProjectThemeDirs(baseDir: string): string[] {
const dirs = ancestorDirs(baseDir);
const out: string[] = [];
for (const dir of dirs) {
out.push(join(dir, ".opencode", "themes"));
}
return out;
}
function ancestorDirs(start: string): string[] {
const out: string[] = [];
let current = resolve(start);
while (true) {
out.push(current);
const parent = dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return out;
}
function opencodeStatePath(): string | null {
const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
return join(stateHome, "opencode", "kv.json");
}
function opencodeStateThemeMode(): ThemeMode | null {
const path = opencodeStatePath();
if (!path || !existsSync(path)) {
return null;
}
const value = readJsonWithComments(path);
if (!isObject(value)) {
return null;
}
const mode = value.theme_mode;
if (typeof mode !== "string") {
return null;
}
const lower = mode.toLowerCase();
if (lower === "dark" || lower === "light") {
return lower;
}
return null;
}
function parseJsonWithComments(content: string): unknown {
try {
return JSON.parse(content);
} catch {
// Fall through.
}
try {
return JSON.parse(stripJsoncComments(content));
} catch {
return null;
}
}
function readJsonWithComments(path: string): unknown {
const content = safeReadText(path);
if (!content) {
return null;
}
return parseJsonWithComments(content);
}
function stripJsoncComments(input: string): string {
let output = "";
let i = 0;
let inString = false;
let escaped = false;
while (i < input.length) {
const ch = input[i];
if (inString) {
output += ch;
if (escaped) {
escaped = false;
} else if (ch === "\\") {
escaped = true;
} else if (ch === '"') {
inString = false;
}
i += 1;
continue;
}
if (ch === '"') {
inString = true;
output += ch;
i += 1;
continue;
}
if (ch === "/" && input[i + 1] === "/") {
i += 2;
while (i < input.length && input[i] !== "\n") {
i += 1;
}
continue;
}
if (ch === "/" && input[i + 1] === "*") {
i += 2;
while (i < input.length) {
if (input[i] === "*" && input[i + 1] === "/") {
i += 2;
break;
}
i += 1;
}
continue;
}
output += ch;
i += 1;
}
return output;
}
function safeReadText(path: string): string | null {
try {
return readFileSync(path, "utf8");
} catch {
return null;
}
}
function resolvePath(path: string, baseDir: string): string {
if (path.startsWith("~/")) {
return join(homedir(), path.slice(2));
}
if (isAbsolute(path)) {
return path;
}
return resolve(baseDir, path);
}
function isPathLike(spec: string): boolean {
return spec.includes("/") || spec.includes("\\") || spec.endsWith(".json") || spec.endsWith(".toml");
}
function isDefaultThemeName(spec: string): boolean {
const lower = spec.toLowerCase();
return lower === "default" || lower === "opencode" || lower === "opencode-default" || lower === "system";
}
function isObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,180 @@
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
const SYMBOL_RUNNING = "▶";
const SYMBOL_IDLE = "✓";
const DEFAULT_OPENCODE_ENDPOINT = "http://127.0.0.1:4097/opencode";
export interface TmuxWindowMatch {
target: string;
windowName: string;
}
export interface SpawnCreateTmuxWindowInput {
branchName: string;
targetPath: string;
sessionId?: string | null;
opencodeEndpoint?: string;
}
export interface SpawnCreateTmuxWindowResult {
created: boolean;
reason: "created" | "not-in-tmux" | "not-local-path" | "window-exists" | "tmux-new-window-failed";
}
function isTmuxSession(): boolean {
return Boolean(process.env.TMUX);
}
function isAbsoluteLocalPath(path: string): boolean {
return path.startsWith("/");
}
function runTmux(args: string[]): boolean {
const result = spawnSync("tmux", args, { stdio: "ignore" });
return !result.error && result.status === 0;
}
function shellEscape(value: string): string {
if (value.length === 0) {
return "''";
}
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function opencodeExistsOnPath(): boolean {
const probe = spawnSync("which", ["opencode"], { stdio: "ignore" });
return !probe.error && probe.status === 0;
}
function resolveOpencodeBinary(): string {
const envOverride = process.env.HF_OPENCODE_BIN?.trim();
if (envOverride) {
return envOverride;
}
if (opencodeExistsOnPath()) {
return "opencode";
}
const bundledCandidates = [`${homedir()}/.local/share/sandbox-agent/bin/opencode`, `${homedir()}/.opencode/bin/opencode`];
for (const candidate of bundledCandidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return "opencode";
}
function attachCommand(sessionId: string, targetPath: string, endpoint: string): string {
const opencode = resolveOpencodeBinary();
return [shellEscape(opencode), "attach", shellEscape(endpoint), "--session", shellEscape(sessionId), "--dir", shellEscape(targetPath)].join(" ");
}
export function stripStatusPrefix(windowName: string): string {
return windowName
.trimStart()
.replace(new RegExp(`^${SYMBOL_RUNNING}\\s+`), "")
.replace(new RegExp(`^${SYMBOL_IDLE}\\s+`), "")
.trim();
}
export function findTmuxWindowsByBranch(branchName: string): TmuxWindowMatch[] {
const output = spawnSync("tmux", ["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"], { encoding: "utf8" });
if (output.error || output.status !== 0 || !output.stdout) {
return [];
}
const lines = output.stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
const matches: TmuxWindowMatch[] = [];
for (const line of lines) {
const parts = line.split(":", 3);
if (parts.length !== 3) {
continue;
}
const sessionName = parts[0] ?? "";
const windowId = parts[1] ?? "";
const windowName = parts[2] ?? "";
const clean = stripStatusPrefix(windowName);
if (clean !== branchName) {
continue;
}
matches.push({
target: `${sessionName}:${windowId}`,
windowName,
});
}
return matches;
}
export function spawnCreateTmuxWindow(input: SpawnCreateTmuxWindowInput): SpawnCreateTmuxWindowResult {
if (!isTmuxSession()) {
return { created: false, reason: "not-in-tmux" };
}
if (!isAbsoluteLocalPath(input.targetPath)) {
return { created: false, reason: "not-local-path" };
}
if (findTmuxWindowsByBranch(input.branchName).length > 0) {
return { created: false, reason: "window-exists" };
}
const windowName = input.sessionId ? `${SYMBOL_RUNNING} ${input.branchName}` : input.branchName;
const endpoint = input.opencodeEndpoint ?? DEFAULT_OPENCODE_ENDPOINT;
let output = "";
try {
output = execFileSync("tmux", ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", windowName, "-c", input.targetPath], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch {
return { created: false, reason: "tmux-new-window-failed" };
}
const windowId = output.trim();
if (!windowId) {
return { created: false, reason: "tmux-new-window-failed" };
}
if (input.sessionId) {
const leftPane = `${windowId}.0`;
// Split left pane horizontally → creates right pane; capture its pane ID
let rightPane: string;
try {
rightPane = execFileSync("tmux", ["split-window", "-h", "-P", "-F", "#{pane_id}", "-t", leftPane, "-c", input.targetPath], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch {
return { created: true, reason: "created" };
}
if (!rightPane) {
return { created: true, reason: "created" };
}
// Split right pane vertically → top-right (rightPane) + bottom-right (new)
runTmux(["split-window", "-v", "-t", rightPane, "-c", input.targetPath]);
// Left pane 60% width, top-right pane 70% height
runTmux(["resize-pane", "-t", leftPane, "-x", "60%"]);
runTmux(["resize-pane", "-t", rightPane, "-y", "70%"]);
// Editor in left pane, agent attach in top-right pane
runTmux(["send-keys", "-t", leftPane, "nvim .", "Enter"]);
runTmux(["send-keys", "-t", rightPane, attachCommand(input.sessionId, input.targetPath, endpoint), "Enter"]);
runTmux(["select-pane", "-t", rightPane]);
}
return { created: true, reason: "created" };
}

View file

@ -0,0 +1,608 @@
import type { AppConfig, TaskRecord } from "@sandbox-agent/foundry-shared";
import { spawnSync } from "node:child_process";
import { createBackendClientFromConfig, filterTasks, formatRelativeAge, groupTaskStatus } from "@sandbox-agent/foundry-client";
import { CLI_BUILD_ID } from "./build-id.js";
import { resolveTuiTheme, type TuiTheme } from "./theme.js";
interface KeyEventLike {
name?: string;
ctrl?: boolean;
meta?: boolean;
}
const HELP_LINES = [
"Shortcuts",
"Ctrl-H toggle cheatsheet",
"Enter switch to branch",
"Ctrl-A attach to session",
"Ctrl-O open PR in browser",
"Ctrl-X archive branch / close PR",
"Ctrl-Y merge highlighted PR",
"Ctrl-S sync task with remote",
"Ctrl-N / Down next row",
"Ctrl-P / Up previous row",
"Backspace delete filter",
"Type filter by branch/PR/author",
"Esc / Ctrl-C cancel",
"",
"Legend",
"Agent: \u{1F916} running \u{1F4AC} idle \u25CC queued",
];
const COLUMN_WIDTHS = {
diff: 10,
agent: 5,
pr: 6,
author: 10,
ci: 7,
review: 8,
age: 5,
} as const;
interface DisplayRow {
name: string;
diff: string;
agent: string;
pr: string;
author: string;
ci: string;
review: string;
age: string;
}
interface RenderOptions {
width?: number;
height?: number;
}
function pad(input: string, width: number): string {
if (width <= 0) {
return "";
}
const chars = Array.from(input);
const text = chars.length > width ? `${chars.slice(0, Math.max(1, width - 1)).join("")}` : input;
return text.padEnd(width, " ");
}
function truncateToLen(input: string, maxLen: number): string {
if (maxLen <= 0) {
return "";
}
return Array.from(input).slice(0, maxLen).join("");
}
function fitLine(input: string, width: number): string {
if (width <= 0) {
return "";
}
const clipped = truncateToLen(input, width);
const len = Array.from(clipped).length;
if (len >= width) {
return clipped;
}
return `${clipped}${" ".repeat(width - len)}`;
}
function overlayLine(base: string, overlay: string, startCol: number, width: number): string {
const out = Array.from(fitLine(base, width));
const src = Array.from(truncateToLen(overlay, Math.max(0, width - startCol)));
for (let i = 0; i < src.length; i += 1) {
const col = startCol + i;
if (col >= 0 && col < out.length) {
out[col] = src[i] ?? " ";
}
}
return out.join("");
}
function buildFooterLine(width: number, segments: string[], right: string): string {
if (width <= 0) {
return "";
}
const rightLen = Array.from(right).length;
if (width <= rightLen + 1) {
return truncateToLen(right, width);
}
const leftMax = width - rightLen - 1;
let used = 0;
let left = "";
let first = true;
for (const segment of segments) {
const chunk = first ? segment : ` | ${segment}`;
const clipped = truncateToLen(chunk, leftMax - used);
if (!clipped) {
break;
}
left += clipped;
used += Array.from(clipped).length;
first = false;
if (used >= leftMax) {
break;
}
}
const padding = " ".repeat(Math.max(0, leftMax - used) + 1);
return `${left}${padding}${right}`;
}
function agentSymbol(status: TaskRecord["status"]): string {
const group = groupTaskStatus(status);
if (group === "running") return "🤖";
if (group === "idle") return "💬";
if (group === "error") return "⚠";
if (group === "queued") return "◌";
return "-";
}
function toDisplayRow(row: TaskRecord): DisplayRow {
const conflictPrefix = row.conflictsWithMain === "true" ? "\u26A0 " : "";
const prLabel = row.prUrl ? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}` : row.prSubmitted ? "sub" : "-";
const ciLabel = row.ciStatus ?? "-";
const reviewLabel = row.reviewStatus
? row.reviewStatus === "approved"
? "ok"
: row.reviewStatus === "changes_requested"
? "chg"
: row.reviewStatus === "pending"
? "..."
: row.reviewStatus
: "-";
return {
name: `${conflictPrefix}${row.title || row.branchName}`,
diff: row.diffStat ?? "-",
agent: agentSymbol(row.status),
pr: prLabel,
author: row.prAuthor ?? "-",
ci: ciLabel,
review: reviewLabel,
age: formatRelativeAge(row.updatedAt),
};
}
function helpLines(width: number): string[] {
const popupWidth = Math.max(40, Math.min(width - 2, 100));
const innerWidth = Math.max(2, popupWidth - 2);
const borderTop = `${"─".repeat(innerWidth)}`;
const borderBottom = `${"─".repeat(innerWidth)}`;
const lines = [borderTop];
for (const line of HELP_LINES) {
lines.push(`${pad(line, innerWidth)}`);
}
lines.push(borderBottom);
return lines;
}
export function formatRows(
rows: TaskRecord[],
selected: number,
workspaceId: string,
status: string,
searchQuery = "",
showHelp = false,
options: RenderOptions = {},
): string {
const totalWidth = options.width ?? process.stdout.columns ?? 120;
const totalHeight = Math.max(6, options.height ?? process.stdout.rows ?? 24);
const fixedWidth =
COLUMN_WIDTHS.diff + COLUMN_WIDTHS.agent + COLUMN_WIDTHS.pr + COLUMN_WIDTHS.author + COLUMN_WIDTHS.ci + COLUMN_WIDTHS.review + COLUMN_WIDTHS.age;
const separators = 7;
const prefixWidth = 2;
const branchWidth = Math.max(20, totalWidth - (fixedWidth + separators + prefixWidth));
const branchHeader = searchQuery ? `Branch/PR: ${searchQuery}_` : "Branch/PR (type to filter)";
const header = [
` ${pad(branchHeader, branchWidth)} ${pad("Diff", COLUMN_WIDTHS.diff)} ${pad("Agent", COLUMN_WIDTHS.agent)} ${pad("PR", COLUMN_WIDTHS.pr)} ${pad("Author", COLUMN_WIDTHS.author)} ${pad("CI", COLUMN_WIDTHS.ci)} ${pad("Review", COLUMN_WIDTHS.review)} ${pad("Age", COLUMN_WIDTHS.age)}`,
"-".repeat(Math.max(24, Math.min(totalWidth, 180))),
];
const body =
rows.length === 0
? ["No branches found."]
: rows.map((row, index) => {
const marker = index === selected ? "┃ " : " ";
const display = toDisplayRow(row);
return `${marker}${pad(display.name, branchWidth)} ${pad(display.diff, COLUMN_WIDTHS.diff)} ${pad(display.agent, COLUMN_WIDTHS.agent)} ${pad(display.pr, COLUMN_WIDTHS.pr)} ${pad(display.author, COLUMN_WIDTHS.author)} ${pad(display.ci, COLUMN_WIDTHS.ci)} ${pad(display.review, COLUMN_WIDTHS.review)} ${pad(display.age, COLUMN_WIDTHS.age)}`;
});
const footer = fitLine(buildFooterLine(totalWidth, ["Ctrl-H:cheatsheet", `workspace:${workspaceId}`, status], `v${CLI_BUILD_ID}`), totalWidth);
const contentHeight = totalHeight - 1;
const lines = [...header, ...body].map((line) => fitLine(line, totalWidth));
const page = lines.slice(0, contentHeight);
while (page.length < contentHeight) {
page.push(" ".repeat(totalWidth));
}
if (showHelp) {
const popup = helpLines(totalWidth);
const startRow = Math.max(0, Math.floor((contentHeight - popup.length) / 2));
for (let i = 0; i < popup.length; i += 1) {
const target = startRow + i;
if (target >= page.length) {
break;
}
const popupLine = popup[i] ?? "";
const popupLen = Array.from(popupLine).length;
const startCol = Math.max(0, Math.floor((totalWidth - popupLen) / 2));
page[target] = overlayLine(page[target] ?? "", popupLine, startCol, totalWidth);
}
}
return [...page, footer].join("\n");
}
interface OpenTuiLike {
createCliRenderer?: (options?: Record<string, unknown>) => Promise<any>;
TextRenderable?: new (
ctx: any,
options: { id: string; content: string },
) => {
content: unknown;
fg?: string;
bg?: string;
};
fg?: (color: string) => (input: unknown) => unknown;
bg?: (color: string) => (input: unknown) => unknown;
StyledText?: new (chunks: unknown[]) => unknown;
}
interface StyledTextApi {
fg: (color: string) => (input: unknown) => unknown;
bg: (color: string) => (input: unknown) => unknown;
StyledText: new (chunks: unknown[]) => unknown;
}
function buildStyledContent(content: string, theme: TuiTheme, api: StyledTextApi): unknown {
const lines = content.split("\n");
const chunks: unknown[] = [];
const footerIndex = Math.max(0, lines.length - 1);
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? "";
let fgColor = theme.text;
let bgColor: string | undefined;
if (line.startsWith("┃ ")) {
const marker = "┃ ";
const rest = line.slice(marker.length);
bgColor = theme.highlightBg;
const markerChunk = api.bg(bgColor)(api.fg(theme.selectionBorder)(marker));
const restChunk = api.bg(bgColor)(api.fg(theme.highlightFg)(rest));
chunks.push(markerChunk);
chunks.push(restChunk);
if (i < lines.length - 1) {
chunks.push(api.fg(theme.text)("\n"));
}
continue;
}
if (i === 0) {
fgColor = theme.header;
} else if (i === 1) {
fgColor = theme.muted;
} else if (i === footerIndex) {
fgColor = theme.status;
} else if (line.startsWith("┌") || line.startsWith("│") || line.startsWith("└")) {
fgColor = theme.info;
}
let chunk: unknown = api.fg(fgColor)(line);
if (bgColor) {
chunk = api.bg(bgColor)(chunk);
}
chunks.push(chunk);
if (i < lines.length - 1) {
chunks.push(api.fg(theme.text)("\n"));
}
}
return new api.StyledText(chunks);
}
export async function runTui(config: AppConfig, workspaceId: string): Promise<void> {
const core = (await import("@opentui/core")) as OpenTuiLike;
const createCliRenderer = core.createCliRenderer;
const TextRenderable = core.TextRenderable;
const styleApi = core.fg && core.bg && core.StyledText ? { fg: core.fg, bg: core.bg, StyledText: core.StyledText } : null;
if (!createCliRenderer || !TextRenderable) {
throw new Error("OpenTUI runtime missing createCliRenderer/TextRenderable exports");
}
const themeResolution = resolveTuiTheme(config);
const client = createBackendClientFromConfig(config);
const renderer = await createCliRenderer({ exitOnCtrlC: false });
const text = new TextRenderable(renderer, {
id: "foundry-switch",
content: "Loading...",
});
text.fg = themeResolution.theme.text;
text.bg = themeResolution.theme.background;
renderer.root.add(text);
renderer.start();
let allRows: TaskRecord[] = [];
let filteredRows: TaskRecord[] = [];
let selected = 0;
let searchQuery = "";
let showHelp = false;
let status = "loading...";
let busy = false;
let closed = false;
let timer: ReturnType<typeof setInterval> | null = null;
const clampSelected = (): void => {
if (filteredRows.length === 0) {
selected = 0;
return;
}
if (selected < 0) {
selected = 0;
return;
}
if (selected >= filteredRows.length) {
selected = filteredRows.length - 1;
}
};
const render = (): void => {
if (closed) {
return;
}
const output = formatRows(filteredRows, selected, workspaceId, status, searchQuery, showHelp, {
width: renderer.width ?? process.stdout.columns,
height: renderer.height ?? process.stdout.rows,
});
text.content = styleApi ? buildStyledContent(output, themeResolution.theme, styleApi) : output;
renderer.requestRender();
};
const refresh = async (): Promise<void> => {
if (closed) {
return;
}
try {
allRows = await client.listTasks(workspaceId);
if (closed) {
return;
}
filteredRows = filterTasks(allRows, searchQuery);
clampSelected();
status = `tasks=${allRows.length} filtered=${filteredRows.length}`;
} catch (err) {
if (closed) {
return;
}
status = err instanceof Error ? err.message : String(err);
}
render();
};
const selectedRow = (): TaskRecord | null => {
if (filteredRows.length === 0) {
return null;
}
return filteredRows[selected] ?? null;
};
let resolveDone: () => void = () => {};
const done = new Promise<void>((resolve) => {
resolveDone = () => resolve();
});
const close = (output?: string): void => {
if (closed) {
return;
}
closed = true;
if (timer) {
clearInterval(timer);
timer = null;
}
process.off("SIGINT", handleSignal);
process.off("SIGTERM", handleSignal);
renderer.destroy();
if (output) {
console.log(output);
}
resolveDone();
};
const handleSignal = (): void => {
close();
};
const runActionWithRefresh = async (label: string, fn: () => Promise<void>, success: string): Promise<void> => {
if (busy) {
return;
}
busy = true;
status = `${label}...`;
render();
try {
await fn();
status = success;
await refresh();
} catch (err) {
status = err instanceof Error ? err.message : String(err);
render();
} finally {
busy = false;
}
};
await refresh();
timer = setInterval(() => {
void refresh();
}, 10_000);
process.once("SIGINT", handleSignal);
process.once("SIGTERM", handleSignal);
const keyInput = (renderer.keyInput ?? renderer.keyHandler) as { on: (name: string, cb: (event: KeyEventLike) => void) => void } | undefined;
if (!keyInput) {
clearInterval(timer);
renderer.destroy();
throw new Error("OpenTUI key input handler is unavailable");
}
keyInput.on("keypress", (event: KeyEventLike) => {
if (closed) {
return;
}
const name = event.name ?? "";
const ctrl = Boolean(event.ctrl);
if (ctrl && name === "h") {
showHelp = !showHelp;
render();
return;
}
if (showHelp) {
if (name === "escape") {
showHelp = false;
render();
}
return;
}
if (name === "q" || name === "escape" || (ctrl && name === "c")) {
close();
return;
}
if ((ctrl && name === "n") || name === "down") {
if (filteredRows.length > 0) {
selected = selected >= filteredRows.length - 1 ? 0 : selected + 1;
render();
}
return;
}
if ((ctrl && name === "p") || name === "up") {
if (filteredRows.length > 0) {
selected = selected <= 0 ? filteredRows.length - 1 : selected - 1;
render();
}
return;
}
if (name === "backspace") {
searchQuery = searchQuery.slice(0, -1);
filteredRows = filterTasks(allRows, searchQuery);
selected = 0;
render();
return;
}
if (name === "return" || name === "enter") {
const row = selectedRow();
if (!row || busy) {
return;
}
busy = true;
status = `switching ${row.taskId}...`;
render();
void (async () => {
try {
const result = await client.switchTask(workspaceId, row.taskId);
close(`cd ${result.switchTarget}`);
} catch (err) {
busy = false;
status = err instanceof Error ? err.message : String(err);
render();
}
})();
return;
}
if (ctrl && name === "a") {
const row = selectedRow();
if (!row || busy) {
return;
}
busy = true;
status = `attaching ${row.taskId}...`;
render();
void (async () => {
try {
const result = await client.attachTask(workspaceId, row.taskId);
close(`target=${result.target} session=${result.sessionId ?? "none"}`);
} catch (err) {
busy = false;
status = err instanceof Error ? err.message : String(err);
render();
}
})();
return;
}
if (ctrl && name === "x") {
const row = selectedRow();
if (!row) {
return;
}
void runActionWithRefresh(`archiving ${row.taskId}`, async () => client.runAction(workspaceId, row.taskId, "archive"), `archived ${row.taskId}`);
return;
}
if (ctrl && name === "s") {
const row = selectedRow();
if (!row) {
return;
}
void runActionWithRefresh(`syncing ${row.taskId}`, async () => client.runAction(workspaceId, row.taskId, "sync"), `synced ${row.taskId}`);
return;
}
if (ctrl && name === "y") {
const row = selectedRow();
if (!row) {
return;
}
void runActionWithRefresh(
`merging ${row.taskId}`,
async () => {
await client.runAction(workspaceId, row.taskId, "merge");
await client.runAction(workspaceId, row.taskId, "archive");
},
`merged+archived ${row.taskId}`,
);
return;
}
if (ctrl && name === "o") {
const row = selectedRow();
if (!row?.prUrl) {
status = "no PR URL available for this task";
render();
return;
}
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
spawnSync(openCmd, [row.prUrl], { stdio: "ignore" });
status = `opened ${row.prUrl}`;
render();
return;
}
if (!ctrl && !event.meta && name.length === 1) {
searchQuery += name;
filteredRows = filterTasks(allRows, searchQuery);
selected = 0;
render();
}
});
await done;
}

View file

@ -0,0 +1,25 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { homedir } from "node:os";
import * as toml from "@iarna/toml";
import { ConfigSchema, resolveWorkspaceId, type AppConfig } from "@sandbox-agent/foundry-shared";
export const CONFIG_PATH = `${homedir()}/.config/foundry/config.toml`;
export function loadConfig(path = CONFIG_PATH): AppConfig {
if (!existsSync(path)) {
return ConfigSchema.parse({});
}
const raw = readFileSync(path, "utf8");
return ConfigSchema.parse(toml.parse(raw));
}
export function saveConfig(config: AppConfig, path = CONFIG_PATH): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, toml.stringify(config), "utf8");
}
export function resolveWorkspace(flagWorkspace: string | undefined, config: AppConfig): string {
return resolveWorkspaceId(flagWorkspace, config);
}