diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 69e5e9f..ad18bbb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,33 +9,20 @@ permissions:
contents: read
jobs:
- core:
- strategy:
- matrix:
- os: [ubuntu-latest, windows-latest]
- runs-on: ${{ matrix.os }}
+ verify:
+ runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
+ cache-dependency-path: |
+ package-lock.json
+ desktop/package-lock.json
- run: npm ci
- run: npm run check
- - run: npm audit --omit=dev
-
- desktop:
- runs-on: ubuntu-latest
- defaults:
- run:
- working-directory: desktop
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: npm
- cache-dependency-path: desktop/package-lock.json
- run: npm ci
+ working-directory: desktop
- run: npm run typecheck
- - run: npm audit --omit=dev
+ working-directory: desktop
diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml
new file mode 100644
index 0000000..f149c9b
--- /dev/null
+++ b/.github/workflows/dependency-audit.yml
@@ -0,0 +1,28 @@
+name: Dependency Audit
+
+on:
+ schedule:
+ - cron: "17 9 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ audit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: |
+ package-lock.json
+ desktop/package-lock.json
+ - run: npm ci
+ - run: npm audit --omit=dev
+ - run: npm ci
+ working-directory: desktop
+ - run: npm audit --omit=dev
+ working-directory: desktop
diff --git a/README.md b/README.md
index fb84a7f..13ebaea 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# codePilot
-codePilot is a local control plane for coordinating Codex and Claude Code across Git repositories. It gives each coding agent an isolated checkout, persists its evidence, integrates eligible commits on a review branch, validates the combined result, and produces a pull-request draft—without silently changing your primary branch.
+codePilot is a local control plane for coordinating Codex and Claude Code across Git repositories. It gives each coding agent an isolated checkout, persists its handoff in Git commits, integrates eligible commits on a review branch, validates the combined result, and produces a pull-request description—without silently changing your primary branch.
> **Public preview (v0.1):** the local execution path works end to end and is covered by integration tests. This is still developer tooling for trusted repositories, not a hardened sandbox for hostile code or unattended production use.
@@ -17,19 +17,20 @@ Coding agents are effective inside one task and one checkout. The harder problem
- bounded JSONL event ingestion and SQLite-backed run history
- manager-owned commits retained under `refs/codepilot/runs/...`
- collision detection, integration branches, repository validation, and deterministic review
-- local PR drafts under `.codepilot/runs//pull-request.md`
-- an Electron desktop control plane for projects, task queues, knowledge, and live agent events
+- Git-native task provenance and generated GitHub pull-request descriptions
+- an Electron desktop control plane with local/remote Git onboarding, project-scoped PM threads, task queues, knowledge, and live agent events
## Trust boundary
Workers can run commands from a repository inside their temporary checkout. Validation commands are also user-configured shell commands. Use codePilot only with repositories and objectives you trust.
-codePilot filters parent conversation identifiers and common unrelated secrets from worker environments, bounds stored events and validation output, and never pushes or opens a remote pull request. These are guardrails, not a security sandbox. Read the [threat model](docs/threat-model.md) before live use.
+codePilot filters parent conversation identifiers and common unrelated secrets from worker environments and bounds stored events and validation output. Remote delivery is disabled unless a run explicitly requests `--create-pr`. These are guardrails, not a security sandbox. Read the [threat model](docs/threat-model.md) before live use.
## Requirements
- Node.js 22+
- Git
+- GitHub CLI for repository cloning and pull-request publication
- Codex CLI and/or Claude Code, already installed and authenticated
- Windows, macOS, or Linux for the CLI; the packaged desktop flow is currently Windows-first
@@ -61,7 +62,9 @@ npm run dev -- run "implement the requested change" `
Repeat `--validate` to run multiple commands. Without explicit validation, codePilot discovers `check`, or `test`, `build`, and `lint` package scripts. Use `--no-integrate` to retain task commits and evidence without assembling a review branch.
-Successful changed runs create `codepilot/`. Review that branch and its generated PR draft, then push or merge it yourself. codePilot does not mutate the checked-out primary branch or contact GitHub.
+Successful changed runs create `codepilot/`. Each task becomes a manager-owned commit containing its agent outcome, claimed checks, concerns, follow-ups, and codePilot provenance. The combined run handoff is returned as `pullRequestBody`; no standalone Markdown draft is written to the project.
+
+Add `--create-pr` to explicitly push the integration branch and create a GitHub pull request using that generated description. Runs needing review are opened as drafts. Without this flag, codePilot does not contact GitHub.
## Desktop app
@@ -72,6 +75,10 @@ npm run desktop:dev
Build the Windows package with `npm run desktop:build`; output is written under `desktop/out/`. Automatic task deployment is off by default.
+The Projects screen can register an existing local Git checkout or clone an HTTPS/SSH remote into the configured projects directory. Credentials are never accepted in repository URLs; Git's configured credential helper or SSH agent owns authentication. After registration, onboarding asks the operator to review the App Core start command, repository-relative working directory, and local URL before Project Manager begins; this step saves configuration but never launches the app. Each registered repository has its own Project Manager thread, conversation, backlog, knowledge, and run database. Turns are serialized within one project while different projects can think and execute concurrently, even as the desktop switches between them. Removing a project only unregisters it from the workspace; its repository and `.codepilot` data remain on disk so it can be onboarded again later.
+
+Settings includes a GitHub connection panel backed by the official `gh` browser login. GitHub CLI stores the credential in the operating-system credential store; codePilot receives only redacted account/status metadata. GitHub writes require both the global **Allow GitHub write operations** switch and an explicit **Create GitHub pull request** selection for that deployment. Today those writes are limited to pushing the generated integration branch and opening its pull request—codePilot does not merge, delete, change repository settings, or grant access.
+
The older local Next.js dashboard is deliberately absent from the public tree because it contains a separately licensed UI kit. Electron is the supported public UI.
## Worker configuration
diff --git a/desktop/src/active-agents.tsx b/desktop/src/active-agents.tsx
index eac6672..2616ade 100644
--- a/desktop/src/active-agents.tsx
+++ b/desktop/src/active-agents.tsx
@@ -3,7 +3,7 @@ import { ChevronDownIcon, FolderOpenIcon, StopCircleIcon } from "@heroicons/reac
import type { ActiveAgent } from "./types";
import { collapseLifecycleEvents, EventView } from "./event-view";
-export function ActiveAgents() {
+export function ActiveAgents({ projectId }: { projectId: string }) {
const [agents, setAgents] = useState([]),
[expanded, setExpanded] = useState>(new Set()),
[messages, setMessages] = useState>({}),
@@ -15,7 +15,7 @@ export function ActiveAgents() {
logs = useRef(new Map());
async function refresh() {
try {
- const next = await window.codepilot.getActiveAgents();
+ const next = await window.codepilot.getActiveAgents(projectId);
setAgents(next);
setExpanded((current) => {
const copy = new Set(current);
@@ -34,7 +34,7 @@ export function ActiveAgents() {
void refresh();
const timer = setInterval(() => void refresh(), 1200);
return () => clearInterval(timer);
- }, []);
+ }, [projectId]);
useEffect(() => {
const frame = requestAnimationFrame(() => {
for (const [taskId, element] of logs.current)
@@ -55,7 +55,7 @@ export function ActiveAgents() {
setSending(agent.taskId);
setError("");
try {
- setAgents(await window.codepilot.guideAgent(agent.taskId, message));
+ setAgents(await window.codepilot.guideAgent(projectId, agent.taskId, message));
setMessages((current) => ({ ...current, [agent.taskId]: "" }));
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
@@ -67,7 +67,7 @@ export function ActiveAgents() {
setInterrupting(agent.taskId);
setError("");
try {
- const result = await window.codepilot.interruptAgent(agent.taskId);
+ const result = await window.codepilot.interruptAgent(projectId, agent.taskId);
setFeedback(result.feedback);
setAgents(result.agents);
} catch (cause) {
@@ -90,7 +90,7 @@ export function ActiveAgents() {
{ setCreatePullRequest(value); if (value) { setIntegrate(true); setDryRun(false); } }}
+ />
Agents never edit your current checkout. Eligible commits are
@@ -147,16 +156,19 @@ function Check({
label,
checked,
set,
+ disabled = false,
}: {
label: string;
checked: boolean;
set(value: boolean): void;
+ disabled?: boolean;
}) {
return (
-
+
{label}
set(e.target.checked)}
className="size-4 accent-indigo-500"
diff --git a/desktop/src/app-control.tsx b/desktop/src/app-control.tsx
index 3939267..f5f5666 100644
--- a/desktop/src/app-control.tsx
+++ b/desktop/src/app-control.tsx
@@ -1,4 +1,4 @@
-import { ArrowTopRightOnSquareIcon, PlayIcon, StopIcon, XMarkIcon } from "@heroicons/react/24/outline";
+import { ArrowPathIcon, ArrowTopRightOnSquareIcon, PlayIcon, StopIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { useEffect, useRef, useState } from "react";
import type { AppConfig, AppStatus } from "./types";
@@ -6,6 +6,7 @@ export function AppControl({ projectId, status, onStatus }: { projectId: string;
const [config, setConfig] = useState({ command: "", workingDirectory: ".", url: "http://localhost:3000" });
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
+ const [previewKey, setPreviewKey] = useState(0);
const loaded = useRef(false);
useEffect(() => {
loaded.current = false;
@@ -21,17 +22,24 @@ export function AppControl({ projectId, status, onStatus }: { projectId: string;
Project runtime
APP CORE // {status?.running ? "ON" : "OFF"} {status?.running ? "PROCESS LINK ACTIVE" : "PROCESS LINK DORMANT"}
Configure how codePilot launches this project. The PM can update these values when you ask it to configure the app.
+ {config.url &&
+
+
+ setPreviewKey(value => value + 1)} className="flex shrink-0 items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-xs text-zinc-300 hover:bg-white/5"> Refresh
+
+
+ }
Launch configuration {status?.running ? `Running · ${status.pid}` : "Stopped"}
setConfig({ ...config, command: event.target.value })} placeholder="npm start" className="field" />
setConfig({ ...config, workingDirectory: event.target.value })} placeholder="." className="field" />
-
+ setConfig({ ...config, url: event.target.value })} placeholder="http://localhost:3000" className="field min-w-0 flex-1" /> void act("clear-port", () => window.codepilot.clearProjectPort(projectId))} className="flex shrink-0 items-center gap-1 rounded-lg border border-amber-400/20 px-3 text-xs text-amber-300 hover:bg-amber-400/10 disabled:opacity-40"> Clear port
{message && {message}
}
-
void act("save", () => window.codepilot.saveAppConfig(config))} className="rounded-lg border border-white/10 px-4 py-2.5 text-sm text-zinc-300 hover:bg-white/5 disabled:opacity-50">Save configuration
-
void (status?.running ? act("stop", () => window.codepilot.stopProjectApp()) : act("start", () => window.codepilot.startProjectApp()))} className={`analog-control flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold transition disabled:opacity-40 ${status?.running ? "analog-red" : "analog-green"}`}>{status?.running ? : }{busy ? "Working…" : status?.running ? "Stop app" : "Start app"}
-
void window.codepilot.openProjectApp()} className="flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2.5 text-sm text-zinc-300 disabled:opacity-40"> Open app
+
void act("save", () => window.codepilot.saveAppConfig(projectId, config))} className="rounded-lg border border-white/10 px-4 py-2.5 text-sm text-zinc-300 hover:bg-white/5 disabled:opacity-50">Save configuration
+
void (status?.running ? act("stop", () => window.codepilot.stopProjectApp(projectId)) : act("start", () => window.codepilot.startProjectApp(projectId)))} className={`analog-control flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold transition disabled:opacity-40 ${status?.running ? "analog-red" : "analog-green"}`}>{status?.running ? : }{busy ? "Working…" : status?.running ? "Stop app" : "Start app"}
+
void window.codepilot.openProjectApp(projectId)} className="flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2.5 text-sm text-zinc-300 disabled:opacity-40"> Open app
{status?.logs.length ? status.logs.join("") : "Start the app to see its output here."}
diff --git a/desktop/src/knowledge.tsx b/desktop/src/knowledge.tsx
index 5cb06df..b65bc28 100644
--- a/desktop/src/knowledge.tsx
+++ b/desktop/src/knowledge.tsx
@@ -1,15 +1,15 @@
import { PaperAirplaneIcon, SparklesIcon } from "@heroicons/react/20/solid";
import { useEffect, useRef, useState } from "react";
import type { KnowledgeState } from "./types";
-export function Knowledge() {
+export function Knowledge({ projectId }: { projectId: string }) {
const [state, setState] = useState
(),
[input, setInput] = useState(""),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
const chat = useRef(null);
useEffect(() => {
- void window.codepilot.getKnowledge().then(setState);
- }, []);
+ void window.codepilot.getKnowledge(projectId).then(setState);
+ }, [projectId]);
useEffect(() => {
const frame = requestAnimationFrame(() => {
if (chat.current) chat.current.scrollTop = chat.current.scrollHeight;
@@ -23,7 +23,7 @@ export function Knowledge() {
setError("");
setInput("");
try {
- setState(await window.codepilot.chatKnowledge(message));
+ setState(await window.codepilot.chatKnowledge(projectId, message));
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
diff --git a/desktop/src/main.ts b/desktop/src/main.ts
index c89da0b..29bc081 100644
--- a/desktop/src/main.ts
+++ b/desktop/src/main.ts
@@ -24,6 +24,7 @@ import type {
NewPMTask,
TaskBin,
Project,
+ ProjectOnboardingInput,
} from "./types";
import { Manager } from "../../src/manager";
import type { ProviderName } from "../../src/domain";
@@ -45,15 +46,26 @@ import {
savePMTasks,
} from "../../src/pm-store";
import { CodexAppServer } from "./codex-app-server";
+import { KeyedSerialQueue } from "../../src/keyed-queue";
+import {
+ canonicalGitRepository,
+ cloneGitRepository,
+ repositoryNameFromRemote,
+ suggestedProjectName,
+} from "../../src/project-onboarding";
+import { removeProjectRegistration } from "../../src/project-registry";
+import { assertGitHubPublicationAllowed, parseGitHubAuthStatus, type GitHubStatus } from "../../src/github-auth";
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
declare const MAIN_WINDOW_VITE_NAME: string;
if (started) app.quit();
let window: BrowserWindow | null = null;
const pmServer = new CodexAppServer();
const pmThreads = new Map();
+const pmTurns = new KeyedSerialQueue();
+const pmActivity = new Map();
const appProcesses = new Map>();
const appProcessState = new Map();
-const defaultGlobalSettings: GlobalSettings = { defaultProvider: "codex", scaffold: { mode: "guided", autoDeploy: false, onboardingPrompt: "You are onboarding me into a new project. Begin by asking focused questions about the users, problem, core workflow, constraints, technology preferences, and definition of success. Summarize decisions as we go. Create small implementation tasks only after requirements are agreed, then continue helping me shape and build the project incrementally." } };
+const defaultGlobalSettings: GlobalSettings = { defaultProvider: "codex", scaffold: { mode: "guided", autoDeploy: false, onboardingPrompt: "You are onboarding me into a new project. Begin by asking focused questions about the users, problem, core workflow, constraints, technology preferences, and definition of success. Summarize decisions as we go. Create small implementation tasks only after requirements are agreed, then continue helping me shape and build the project incrementally." }, github: { allowWrites: false } };
const file = () => join(app.getPath("userData"), "state.json");
function load(): DesktopState {
try {
@@ -81,7 +93,7 @@ function save(state: DesktopState) {
}
function hydrate() {
const state = load();
- state.globalSettings = { ...defaultGlobalSettings, ...state.globalSettings, scaffold: { ...defaultGlobalSettings.scaffold, ...state.globalSettings?.scaffold } };
+ state.globalSettings = { ...defaultGlobalSettings, ...state.globalSettings, scaffold: { ...defaultGlobalSettings.scaffold, ...state.globalSettings?.scaffold }, github: { ...defaultGlobalSettings.github, ...state.globalSettings?.github } };
const
project =
state.projects.find((item) => item.id === state.activeProjectId) ??
@@ -107,6 +119,48 @@ function hydrate() {
}
return state;
}
+function githubStatus(): GitHubStatus {
+ try {
+ execFileSync("gh", ["--version"], { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
+ } catch {
+ return { installed: false, authenticated: false, host: "github.com", scopes: [], error: "GitHub CLI is not installed or is not available on PATH" };
+ }
+ try {
+ const output = execFileSync("gh", ["auth", "status", "--hostname", "github.com"], { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
+ return { installed: true, ...parseGitHubAuthStatus(output) };
+ } catch {
+ return { installed: true, authenticated: false, host: "github.com", scopes: [], error: "Connect a GitHub account to publish branches and pull requests" };
+ }
+}
+async function loginGitHub(): Promise {
+ if (!githubStatus().installed) throw new Error("Install GitHub CLI before connecting a GitHub account");
+ await new Promise((resolveLogin, rejectLogin) => {
+ const child = spawn("gh", ["auth", "login", "--hostname", "github.com", "--git-protocol", "https", "--web", "--clipboard", "--scopes", "workflow"], {
+ windowsHide: true,
+ stdio: ["ignore", "ignore", "ignore"],
+ });
+ const timeout = setTimeout(() => {
+ child.kill();
+ rejectLogin(new Error("GitHub sign-in timed out. Start it again when you are ready to finish in the browser."));
+ }, 10 * 60_000);
+ child.once("error", () => {
+ clearTimeout(timeout);
+ rejectLogin(new Error("GitHub sign-in could not start. Confirm that GitHub CLI is installed."));
+ });
+ child.once("close", (code) => {
+ clearTimeout(timeout);
+ if (code === 0) resolveLogin();
+ else rejectLogin(new Error("GitHub sign-in did not complete. No credentials were stored by CodePilot."));
+ });
+ });
+ execFileSync("gh", ["auth", "setup-git", "--hostname", "github.com"], { windowsHide: true, stdio: "ignore" });
+ const status = githubStatus();
+ if (!status.authenticated) throw new Error("GitHub CLI did not report a connected account after sign-in");
+ return status;
+}
+function assertGitHubWriteAccess() {
+ assertGitHubPublicationAllowed(Boolean(load().globalSettings?.github?.allowWrites), githubStatus());
+}
function recoverInterruptedRuns() {
for (const project of load().projects) {
const db = join(project.repo, ".codepilot", "codepilot.db");
@@ -129,8 +183,8 @@ function recoverInterruptedRuns() {
}
}
}
-function runDetail(id: string) {
- const db = join(active().repo, ".codepilot", "codepilot.db");
+function runDetail(project: Project, id: string) {
+ const db = join(project.repo, ".codepilot", "codepilot.db");
if (!existsSync(db)) throw new Error("Run database not found");
using database = new DatabaseSync(db, { readOnly: true });
const run = database.prepare("SELECT * FROM runs WHERE id = ?").get(id) as
@@ -171,26 +225,29 @@ function runDetail(id: string) {
}
function registerIPC() {
ipcMain.handle("state:get", () => hydrate());
- ipcMain.handle("project:brief-get",()=>projectBrief());
- ipcMain.handle("project:brief-save",(_event,content:string)=>saveProjectBrief(String(content).slice(0,100000)));
+ ipcMain.handle("project:brief-get",(_event,projectId:string)=>projectBrief(projectById(projectId)));
+ ipcMain.handle("project:brief-save",(_event,projectId:string,content:string)=>saveProjectBrief(projectById(projectId),String(content).slice(0,100000)));
ipcMain.handle("projects:activity",()=>projectActivity());
- ipcMain.handle("settings:get", () => ({ ...defaultGlobalSettings, ...load().globalSettings, scaffold: { ...defaultGlobalSettings.scaffold, ...load().globalSettings?.scaffold } }));
+ ipcMain.handle("settings:get", () => ({ ...defaultGlobalSettings, ...load().globalSettings, scaffold: { ...defaultGlobalSettings.scaffold, ...load().globalSettings?.scaffold }, github: { ...defaultGlobalSettings.github, ...load().globalSettings?.github } }));
ipcMain.handle("settings:save", (_event, settings: GlobalSettings) => {
const state = load();
- state.globalSettings = { defaultProvider: settings.defaultProvider === "claude" ? "claude" : "codex", scaffold: { mode: settings.scaffold?.mode === "blank" ? "blank" : "guided", autoDeploy: Boolean(settings.scaffold?.autoDeploy), onboardingPrompt: String(settings.scaffold?.onboardingPrompt || defaultGlobalSettings.scaffold.onboardingPrompt).trim().slice(0, 12000) } };
+ state.globalSettings = { defaultProvider: settings.defaultProvider === "claude" ? "claude" : "codex", scaffold: { mode: settings.scaffold?.mode === "blank" ? "blank" : "guided", autoDeploy: Boolean(settings.scaffold?.autoDeploy), onboardingPrompt: String(settings.scaffold?.onboardingPrompt || defaultGlobalSettings.scaffold.onboardingPrompt).trim().slice(0, 12000) }, github: { allowWrites: Boolean(settings.github?.allowWrites) } };
save(state); return state.globalSettings;
});
- ipcMain.handle("run:detail", (_event, id: string) => runDetail(id));
- ipcMain.handle("app:status", () => projectAppStatus());
- ipcMain.handle("app:save-config", (_event, config: AppConfig) => {
- saveAppConfig(config);
- return projectAppStatus();
+ ipcMain.handle("github:status", () => githubStatus());
+ ipcMain.handle("github:login", () => loginGitHub());
+ ipcMain.handle("run:detail", (_event, projectId: string, id: string) => runDetail(projectById(projectId), id));
+ ipcMain.handle("app:status", (_event, projectId: string) => projectAppStatus(projectById(projectId)));
+ ipcMain.handle("app:save-config", (_event, projectId: string, config: AppConfig) => {
+ const project = projectById(projectId);
+ saveAppConfig(config, project);
+ return projectAppStatus(project);
});
- ipcMain.handle("app:start", () => startProjectApp());
- ipcMain.handle("app:stop", () => stopProjectApp());
- ipcMain.handle("app:clear-port", () => clearProjectPort());
- ipcMain.handle("app:open", async () => {
- const { config } = projectAppStatus();
+ ipcMain.handle("app:start", (_event, projectId: string) => startProjectApp(projectById(projectId)));
+ ipcMain.handle("app:stop", (_event, projectId: string) => stopProjectApp(projectById(projectId)));
+ ipcMain.handle("app:clear-port", (_event, projectId: string) => clearProjectPort(projectById(projectId)));
+ ipcMain.handle("app:open", async (_event, projectId: string) => {
+ const { config } = projectAppStatus(projectById(projectId));
if (!/^https?:\/\//i.test(config.url)) throw new Error("Configure an http:// or https:// app URL first");
await shell.openExternal(config.url);
});
@@ -212,56 +269,67 @@ function registerIPC() {
});
ipcMain.handle(
"project:add",
- (_event, input: { name: string; repo?: string }) => {
+ (_event, input: { name: string }) => {
const state = load(),
name = input.name.trim();
if (!name) throw new Error("Project name is required");
- let repo = input.repo
- ? resolve(input.repo)
- : resolve(
- state.projectsDirectory,
- name.replace(/[^a-zA-Z0-9._-]+/g, "-"),
- );
- if (!input.repo) {
- const rel = relative(resolve(state.projectsDirectory), repo);
- if (!rel || rel.startsWith(".."))
- throw new Error("Invalid generated project path");
- if (existsSync(repo))
- throw new Error("Project directory already exists");
- ensureProjectGit(repo, true);
- execFileSync("git", [
- "-C",
- repo,
- "-c",
- "user.name=codePilot",
- "-c",
- "user.email=codepilot@local",
- "commit",
- "--allow-empty",
- "-m",
- "Initialize project",
- ]);
- } else execFileSync("git", ["-C", repo, "rev-parse", "--show-toplevel"]);
- if (input.repo) ensureProjectGit(repo);
- const existing = state.projects.find(
- (project) => project.repo.toLowerCase() === repo.toLowerCase(),
- );
- const project: Project = existing ?? {
+ const repo = resolve(state.projectsDirectory, name.replace(/[^a-zA-Z0-9._-]+/g, "-"));
+ const rel = relative(resolve(state.projectsDirectory), repo);
+ if (!rel || rel.startsWith("..")) throw new Error("Invalid generated project path");
+ if (existsSync(repo)) throw new Error("Project directory already exists");
+ ensureProjectGit(repo, true);
+ execFileSync("git", ["-C", repo, "-c", "user.name=codePilot", "-c", "user.email=codepilot@local", "commit", "--allow-empty", "-m", "Initialize project"]);
+ const project: Project = {
id: randomUUID(),
name,
repo,
createdAt: new Date().toISOString(),
};
- if (!existing) state.projects.push(project);
+ state.projects.push(project);
state.activeProjectId = project.id;
save(state);
return hydrate();
},
);
+ ipcMain.handle("project:onboard", async (_event, input: ProjectOnboardingInput) => {
+ const state = load();
+ const repo = input.source === "local"
+ ? canonicalGitRepository(input.repo)
+ : await cloneGitRepository({
+ url: input.url,
+ projectsDirectory: state.projectsDirectory,
+ ...(input.directoryName?.trim() ? { directoryName: input.directoryName } : {}),
+ });
+ ensureProjectGit(repo);
+ const existing = state.projects.find((project) => samePath(project.repo, repo));
+ const fallbackName = input.source === "local" ? suggestedProjectName(repo) : repositoryNameFromRemote(input.url);
+ const name = input.name?.trim() || fallbackName;
+ const project: Project = existing ?? { id: randomUUID(), name, repo, createdAt: new Date().toISOString() };
+ if (!existing) state.projects.push(project);
+ state.activeProjectId = project.id;
+ save(state);
+ return hydrate();
+ });
+ ipcMain.handle("project:remove", (_event, projectId: string) => {
+ const state = load();
+ const project = state.projects.find((candidate) => candidate.id === projectId);
+ if (!project) throw new Error("Project not found. Refresh the workspace and try again.");
+ const activity = projectActivity()[project.id] ?? 0;
+ const appProcess = appProcesses.get(project.id);
+ if (activity > 0 || appProcess?.exitCode === null)
+ throw new Error("Stop this project's PM, agents, and app process before removing it.");
+ const next = removeProjectRegistration(state.projects, state.activeProjectId, project.id);
+ state.projects = next.projects;
+ state.activeProjectId = next.activeProjectId;
+ state.runs = [];
+ pmThreads.delete(project.id);
+ appProcessState.delete(project.id);
+ save(state);
+ return hydrate();
+ });
}
-function activeAgents() {
- const project = active(),
- db = join(project.repo, ".codepilot", "codepilot.db");
+function activeAgents(project: Project) {
+ const db = join(project.repo, ".codepilot", "codepilot.db");
if (!existsSync(db)) return [];
using database = new DatabaseSync(db, { readOnly: true });
const tasks = database
@@ -276,6 +344,7 @@ function activeAgents() {
)
.all(String(task.id)) as Array>;
return {
+ projectId: project.id,
taskId: String(task.id),
runId: String(task.run_id),
provider: String(task.provider),
@@ -292,27 +361,32 @@ function activeAgents() {
});
}
function registerAgentIPC() {
- ipcMain.handle("agents:active", () => activeAgents());
- ipcMain.handle("agents:guide", (_event, taskId: string, message: string) => {
- if (!activeAgents().some((agent) => agent.taskId === taskId))
+ ipcMain.handle("agents:active", (_event, projectId: string) => activeAgents(projectById(projectId)));
+ ipcMain.handle("agents:guide", (_event, projectId: string, taskId: string, message: string) => {
+ const project = projectById(projectId);
+ if (!activeAgents(project).some((agent) => agent.taskId === taskId))
throw new Error("That agent is no longer active");
queueAgentGuidance(taskId, message.slice(0, 4000));
- return activeAgents();
+ return activeAgents(project);
});
- ipcMain.handle("agents:interrupt", (_event, taskId: string) => {
+ ipcMain.handle("agents:interrupt", (_event, projectId: string, taskId: string) => {
+ const project = projectById(projectId);
+ if (!activeAgents(project).some((agent) => agent.taskId === taskId))
+ throw new Error("That agent is no longer active");
if (!interruptAgent(taskId)) throw new Error("That agent is no longer interruptible");
return {
feedback: "Interrupt received. I’m stopping safely now; completed changes and the available handoff evidence will be preserved in session history.",
- agents: activeAgents(),
+ agents: activeAgents(project),
};
});
- ipcMain.handle("project:open-directory", async () => {
- const error = await shell.openPath(active().repo);
+ ipcMain.handle("project:open-directory", async (_event, projectId: string) => {
+ const error = await shell.openPath(projectById(projectId).repo);
if (error) throw new Error(error);
});
ipcMain.handle("agents:run", async (_event, input: AgentRunInput) => {
- const project = active();
- if (!project || typeof project.repo !== "string" || !project.repo.trim()) throw new Error("The active project has no repository path. Re-select the project or add it again before deploying an agent.");
+ const project = projectById(input.projectId);
+ if (input.createPullRequest) assertGitHubWriteAccess();
+ if (typeof project.repo !== "string" || !project.repo.trim()) throw new Error("The selected project has no repository path. Re-select the project or add it again before deploying an agent.");
const
objective = input.objective.trim(),
providers = input.providers.filter(
@@ -337,6 +411,7 @@ function registerAgentIPC() {
retries,
timeoutMs: timeoutMinutes * 60_000,
integrate: Boolean(input.integrate),
+ createPullRequest: Boolean(input.createPullRequest),
...(validationCommands.length ? { validationCommands } : {}),
});
return {
@@ -345,12 +420,14 @@ function registerAgentIPC() {
...(summary.integrationBranch
? { integrationBranch: summary.integrationBranch }
: {}),
+ ...(summary.pullRequestUrl ? { pullRequestUrl: summary.pullRequestUrl } : {}),
review: summary.review,
};
});
ipcMain.handle("agents:run-batch", async (_event, input: AgentBatchRunInput) => {
- const project = active();
- if (!project?.repo?.trim()) throw new Error("The active project has no repository path.");
+ const project = projectById(input.projectId);
+ if (input.createPullRequest) assertGitHubWriteAccess();
+ if (!project.repo?.trim()) throw new Error("The selected project has no repository path.");
const items = input.items.slice(0, 20).map((item) => ({
id: item.id.trim(),
objective: item.objective.trim(),
@@ -374,29 +451,33 @@ function registerAgentIPC() {
retries,
timeoutMs: timeoutMinutes * 60_000,
integrate: Boolean(input.integrate),
+ createPullRequest: Boolean(input.createPullRequest),
...(validationCommands.length ? { validationCommands } : {}),
});
return {
runId: summary.runId,
status: summary.status,
...(summary.integrationBranch ? { integrationBranch: summary.integrationBranch } : {}),
+ ...(summary.pullRequestUrl ? { pullRequestUrl: summary.pullRequestUrl } : {}),
review: summary.review,
};
});
}
-function active() {
- const state = load();
- return (
- state.projects.find((project) => project.id === state.activeProjectId) ??
- state.projects[0]!
- );
+function projectById(projectId: string): Project {
+ const project = load().projects.find((candidate) => candidate.id === projectId);
+ if (!project) throw new Error("Project not found. Refresh the workspace and try again.");
+ return project;
}
-function projectBrief(project = active()) { const path=join(project.repo,"PROJECT.md"); return {path,content:existsSync(path)?readFileSync(path,"utf8"):"",exists:existsSync(path)}; }
-function saveProjectBrief(content:string){const brief=projectBrief();writeFileSync(brief.path,`${content.trim()}\n`);return projectBrief()}
-function projectActivity(){return Object.fromEntries(load().projects.map(project=>{const path=join(project.repo,".codepilot","codepilot.db");if(!existsSync(path))return[project.id,0];try{using database=new DatabaseSync(path,{readOnly:true});const row=database.prepare("SELECT COUNT(*) AS count FROM tasks WHERE status IN ('leased','running','validating')").get() as Record;return[project.id,Number(row.count)]}catch{return[project.id,0]}}))}
+function samePath(left: string, right: string): boolean {
+ const normalize = (value: string) => resolve(value).replace(/[\\/]+$/, "").toLowerCase();
+ return normalize(left) === normalize(right);
+}
+function projectBrief(project: Project) { const path=join(project.repo,"PROJECT.md"); return {path,content:existsSync(path)?readFileSync(path,"utf8"):"",exists:existsSync(path)}; }
+function saveProjectBrief(project: Project,content:string){const brief=projectBrief(project);writeFileSync(brief.path,`${content.trim()}\n`);return projectBrief(project)}
+function projectActivity(){return Object.fromEntries(load().projects.map(project=>{const path=join(project.repo,".codepilot","codepilot.db"),pm=pmActivity.get(project.id)??0;if(!existsSync(path))return[project.id,pm];try{using database=new DatabaseSync(path,{readOnly:true});const row=database.prepare("SELECT COUNT(*) AS count FROM tasks WHERE status IN ('leased','running','validating')").get() as Record;return[project.id,Number(row.count)+pm]}catch{return[project.id,pm]}}))}
const defaultAppConfig: AppConfig = { command: "", workingDirectory: ".", url: "http://localhost:3000" };
-function appConfigPath(project = active()) { return join(project.repo, ".codepilot", "app-config.json"); }
-function readAppConfig(project = active()): AppConfig {
+function appConfigPath(project: Project) { return join(project.repo, ".codepilot", "app-config.json"); }
+function readAppConfig(project: Project): AppConfig {
try { return { ...defaultAppConfig, ...(JSON.parse(readFileSync(appConfigPath(project), "utf8")) as Partial) }; }
catch {
try {
@@ -406,7 +487,7 @@ function readAppConfig(project = active()): AppConfig {
return { ...defaultAppConfig };
}
}
-function saveAppConfig(input: AppConfig, project = active()) {
+function saveAppConfig(input: AppConfig, project: Project) {
const command = String(input.command ?? "").trim().slice(0, 1000);
const workingDirectory = String(input.workingDirectory ?? ".").trim() || ".";
const cwd = resolve(project.repo, workingDirectory);
@@ -417,7 +498,7 @@ function saveAppConfig(input: AppConfig, project = active()) {
mkdirSync(dirname(appConfigPath(project)), { recursive: true });
writeFileSync(appConfigPath(project), JSON.stringify({ command, workingDirectory, url }, null, 2));
}
-function projectAppStatus(project = active()) {
+function projectAppStatus(project: Project) {
const process = appProcesses.get(project.id), state = appProcessState.get(project.id) ?? { logs: [] };
return { configured: Boolean(readAppConfig(project).command), running: Boolean(process && process.exitCode === null), ...(process?.pid ? { pid: process.pid } : {}), ...(state.startedAt ? { startedAt: state.startedAt } : {}), config: readAppConfig(project), logs: state.logs };
}
@@ -439,8 +520,8 @@ function projectEnvironment(...directories: string[]): NodeJS.ProcessEnv {
}
return values;
}
-function startProjectApp() {
- const project = active(), current = appProcesses.get(project.id);
+function startProjectApp(project: Project) {
+ const current = appProcesses.get(project.id);
if (current?.exitCode === null) return projectAppStatus(project);
const config = readAppConfig(project);
if (!config.command) throw new Error("Configure a start command first");
@@ -456,8 +537,8 @@ function startProjectApp() {
appProcesses.set(project.id, child); appProcessState.set(project.id, state);
return projectAppStatus(project);
}
-function stopProjectApp() {
- const project = active(), child = appProcesses.get(project.id);
+function stopProjectApp(project: Project) {
+ const child = appProcesses.get(project.id);
if (child?.pid) {
if (process.platform === "win32") { try { execFileSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true }); } catch { child.kill(); } }
else child.kill("SIGTERM");
@@ -466,9 +547,8 @@ function stopProjectApp() {
}
return projectAppStatus(project);
}
-function clearProjectPort() {
- const project = active();
- stopProjectApp();
+function clearProjectPort(project: Project) {
+ stopProjectApp(project);
const config = readAppConfig(project);
let parsed: URL;
try { parsed = new URL(config.url); } catch { throw new Error("Configure a valid http:// or https:// app URL before clearing its port."); }
@@ -490,10 +570,10 @@ function clearProjectPort() {
}
return projectAppStatus(project);
}
-function knowledgeDir() {
- return join(active().repo, ".codepilot", "knowledge");
+function knowledgeDir(project: Project) {
+ return join(project.repo, ".codepilot", "knowledge");
}
-function entries(directory = knowledgeDir()): KnowledgeEntry[] {
+function entries(directory: string): KnowledgeEntry[] {
if (!existsSync(directory)) return [];
return readdirSync(directory)
.filter((name) => name.endsWith(".json"))
@@ -510,11 +590,11 @@ function entries(directory = knowledgeDir()): KnowledgeEntry[] {
})
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
-function history(): ChatMessage[] {
+function history(project: Project): ChatMessage[] {
try {
return JSON.parse(
readFileSync(
- join(active().repo, ".codepilot", "knowledge-chat.json"),
+ join(project.repo, ".codepilot", "knowledge-chat.json"),
"utf8",
),
) as ChatMessage[];
@@ -522,18 +602,18 @@ function history(): ChatMessage[] {
return [];
}
}
-function knowledgeState(): KnowledgeState {
- return { entries: entries(), messages: history() };
+function knowledgeState(project: Project): KnowledgeState {
+ return { entries: entries(knowledgeDir(project)), messages: history(project) };
}
-function saveEntry(entry: KnowledgeEntry) {
- mkdirSync(knowledgeDir(), { recursive: true });
+function saveEntry(project: Project, entry: KnowledgeEntry) {
+ mkdirSync(knowledgeDir(project), { recursive: true });
writeFileSync(
- join(knowledgeDir(), `${entry.id}.json`),
+ join(knowledgeDir(project), `${entry.id}.json`),
JSON.stringify(entry, null, 2),
);
}
-function saveChat(messages: ChatMessage[]) {
- const path = join(active().repo, ".codepilot", "knowledge-chat.json");
+function saveChat(project: Project, messages: ChatMessage[]) {
+ const path = join(project.repo, ".codepilot", "knowledge-chat.json");
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(messages.slice(-100), null, 2));
}
@@ -560,7 +640,7 @@ function matchesPMTask(objective: string, title: string, taskId?: string): boole
const overlap = [...taskWords].filter((word) => runWords.has(word)).length;
return overlap / Math.min(taskWords.size, runWords.size) >= 0.8;
}
-function pmState(repo = active().repo): PMState {
+function pmState(repo: string): PMState {
const paths = pmPaths(repo);
const storedTasks = loadPMTasks(repo) as PMTask[];
const committedTaskIds = new Set(
@@ -642,8 +722,8 @@ function pmState(repo = active().repo): PMState {
messages: readJson(paths.chat, []),
};
}
-function pmTaskDetail(id: string) {
- const project = active(), state = pmState(project.repo), task = state.tasks.find((item) => item.id === id);
+function pmTaskDetail(project: Project, id: string) {
+ const state = pmState(project.repo), task = state.tasks.find((item) => item.id === id);
if (!task) throw new Error("Task not found");
const prerequisites = task.dependencies.map((dependency) => {
const prerequisite = state.tasks.find((item) => item.id === dependency)!;
@@ -674,18 +754,20 @@ function ensureProjectGit(repo: string, initialize = false) {
mkdirSync(root, { recursive: true });
execFileSync("git", ["init", root]);
} else {
- const gitRoot = resolve(execFileSync("git", ["-C", root, "rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim());
- if (gitRoot.toLowerCase() !== root.toLowerCase()) throw new Error("Each codePilot project must be its own Git repository, not a subdirectory of another repository.");
+ const gitRoot = canonicalGitRepository(root);
+ if (!samePath(gitRoot, root)) throw new Error("Each codePilot project must be its own Git repository, not a subdirectory of another repository.");
}
- const ignorePath = join(root, ".gitignore");
+ const ignorePath = initialize
+ ? join(root, ".gitignore")
+ : resolve(root, execFileSync("git", ["-C", root, "rev-parse", "--git-path", "info/exclude"], { encoding: "utf8", windowsHide: true }).trim());
const existing = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
if (!/(^|\r?\n)\.codepilot\/?(?:\r?\n|$)/m.test(existing)) {
+ mkdirSync(dirname(ignorePath), { recursive: true });
writeFileSync(ignorePath, `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}.codepilot/\n`);
if (initialize) execFileSync("git", ["-C", root, "add", ".gitignore"]);
}
}
-function commitPMTaskBin(taskIds: string[]): TaskBin {
- const project = active();
+function commitPMTaskBin(project: Project, taskIds: string[]): TaskBin {
const state = pmState(project.repo);
const selected = [...new Set(taskIds)].map((id) => state.tasks.find((task) => task.id === id)).filter((task): task is PMTask => Boolean(task));
if (!selected.length) throw new Error("Select at least one task to create a commit bin.");
@@ -702,18 +784,18 @@ function commitPMTaskBin(taskIds: string[]): TaskBin {
saveCommittedTaskBin(project.repo, bin, state.tasks);
return bin;
}
-function createPMTask(input: NewPMTask): PMState {
+function createPMTask(project: Project, input: NewPMTask): PMState {
const title = input.title.trim(), description = input.description.trim();
if (!title || !description) throw new Error("Task title and description are required.");
- const project = active(), state = pmState(project.repo), now = new Date().toISOString();
+ const state = pmState(project.repo), now = new Date().toISOString();
const task: PMTask = { id: randomUUID(), title, description, acceptanceCriteria: (input.acceptanceCriteria ?? []).map((item) => item.trim()).filter(Boolean), priority: input.priority === "high" || input.priority === "low" ? input.priority : "medium", status: "ready", queueId: "deploy", queueTitle: "Deploy queue", dependencies: [], recommendedProvider: input.recommendedProvider === "claude" ? "claude" : "codex", createdAt: now, updatedAt: now };
state.tasks.push(task);
state.tasks = reconcileTaskStatuses(state.tasks);
savePM(project.repo, state);
return pmState(project.repo);
}
-function deletePMTask(id: string): PMState {
- const project = active(), state = pmState(project.repo);
+function deletePMTask(project: Project, id: string): PMState {
+ const state = pmState(project.repo);
if (!state.tasks.some((task) => task.id === id)) throw new Error("Task not found");
state.tasks = omitTaskDependencies(state.tasks.filter((task) => task.id !== id), new Set([id]));
state.tasks = reconcileTaskStatuses(state.tasks);
@@ -744,11 +826,10 @@ const schema = {
required: ["message", "operations"],
additionalProperties: false,
};
-async function chat(message: string) {
+async function chat(project: Project, message: string) {
if (!message.trim()) throw new Error("Message required");
- const project = active(),
- before = entries(),
- messages = history(),
+ const before = entries(knowledgeDir(project)),
+ messages = history(project),
temp = join(app.getPath("temp"), `codepilot-chat-${randomUUID()}`);
mkdirSync(temp, { recursive: true });
const schemaPath = join(temp, "schema.json"),
@@ -787,7 +868,7 @@ async function chat(message: string) {
operation.content.trim()
) {
const now = new Date().toISOString();
- saveEntry({
+ saveEntry(project, {
id: randomUUID(),
title: operation.title.trim(),
content: operation.content.trim(),
@@ -800,7 +881,7 @@ async function chat(message: string) {
} else if (operation.action === "update") {
const old = before.find((item) => item.id === operation.id);
if (old && operation.title.trim() && operation.content.trim()) {
- saveEntry({
+ saveEntry(project, {
...old,
title: operation.title.trim(),
content: operation.content.trim(),
@@ -814,12 +895,12 @@ async function chat(message: string) {
operation.action === "delete" &&
before.some((item) => item.id === operation.id)
) {
- rmSync(join(knowledgeDir(), `${operation.id}.json`), { force: true });
+ rmSync(join(knowledgeDir(project), `${operation.id}.json`), { force: true });
applied++;
}
}
const now = new Date().toISOString();
- saveChat([
+ saveChat(project, [
...messages,
{ role: "user", content: message, timestamp: now },
{
@@ -830,7 +911,7 @@ async function chat(message: string) {
},
]);
rmSync(temp, { recursive: true, force: true });
- return knowledgeState();
+ return knowledgeState(project);
}
function run(command: string, args: string[], input = ""): Promise {
return new Promise((done, fail) => {
@@ -855,9 +936,9 @@ function run(command: string, args: string[], input = ""): Promise {
});
}
function registerKnowledgeIPC() {
- ipcMain.handle("knowledge:get", () => knowledgeState());
- ipcMain.handle("knowledge:chat", (_event, message: string) =>
- chat(message.slice(0, 8000)),
+ ipcMain.handle("knowledge:get", (_event, projectId: string) => knowledgeState(projectById(projectId)));
+ ipcMain.handle("knowledge:chat", (_event, projectId: string, message: string) =>
+ chat(projectById(projectId), message.slice(0, 8000)),
);
}
const pmSchema = {
@@ -956,7 +1037,7 @@ async function persistentPMReply(project: Project, prompt: string): Promise pmState());
- ipcMain.handle("pm:task-detail", (_event, id: string) => pmTaskDetail(id));
- ipcMain.handle("pm:chat", async (_event, message: string) => {
- const state = await chatPM(message.slice(0, 8000));
- if (state.requiresUserInput)
- for (const browserWindow of BrowserWindow.getAllWindows())
- browserWindow.webContents.send("pm:user-input-required");
- return state;
+ ipcMain.handle("pm:get", (_event, projectId: string) => pmState(projectById(projectId).repo));
+ ipcMain.handle("pm:task-detail", (_event, projectId: string, id: string) => pmTaskDetail(projectById(projectId), id));
+ ipcMain.handle("pm:chat", async (_event, projectId: string, message: string) => {
+ const project = projectById(projectId);
+ pmActivity.set(project.id, (pmActivity.get(project.id) ?? 0) + 1);
+ try {
+ const state = await pmTurns.run(project.id, () => chatPM(message.slice(0, 8000), project));
+ if (state.requiresUserInput)
+ for (const browserWindow of BrowserWindow.getAllWindows())
+ browserWindow.webContents.send("pm:user-input-required", project.id);
+ return state;
+ } finally {
+ const remaining = (pmActivity.get(project.id) ?? 1) - 1;
+ if (remaining > 0) pmActivity.set(project.id, remaining);
+ else pmActivity.delete(project.id);
+ }
});
- ipcMain.handle("pm:clear-chat", () => {
- const project = active(),
+ ipcMain.handle("pm:clear-chat", (_event, projectId: string) => {
+ const project = projectById(projectId),
state = pmState(project.repo),
next = { ...state, messages: [] };
pmThreads.delete(project.id);
@@ -1129,10 +1218,10 @@ function registerPMIPC() {
});
ipcMain.handle(
"pm:task-status",
- (_event, id: string, status: PMTask["status"], summary?: string) => {
+ (_event, projectId: string, id: string, status: PMTask["status"], summary?: string) => {
if (!["backlog", "blocked", "ready", "launched", "done", "committed"].includes(status))
throw new Error("Invalid task status");
- const project = active(),
+ const project = projectById(projectId),
state = pmState(project.repo),
task = state.tasks.find((item) => item.id === id);
if (!task) throw new Error("Task not found");
@@ -1147,8 +1236,8 @@ function registerPMIPC() {
return pmState(project.repo);
},
);
- ipcMain.handle("pm:task-failure", (_event, id: string, summary: string) => {
- const project = active(),
+ ipcMain.handle("pm:task-failure", (_event, projectId: string, id: string, summary: string) => {
+ const project = projectById(projectId),
state = pmState(project.repo),
task = state.tasks.find((item) => item.id === id);
if (!task) throw new Error("Task not found");
@@ -1165,9 +1254,9 @@ function registerPMIPC() {
savePM(project.repo, state);
return pmState(project.repo);
});
- ipcMain.handle("pm:commit-bin", (_event, taskIds: string[]) => commitPMTaskBin(taskIds));
- ipcMain.handle("pm:create-task", (_event, input: NewPMTask) => createPMTask(input));
- ipcMain.handle("pm:delete-task", (_event, id: string) => deletePMTask(id));
+ ipcMain.handle("pm:commit-bin", (_event, projectId: string, taskIds: string[]) => commitPMTaskBin(projectById(projectId), taskIds));
+ ipcMain.handle("pm:create-task", (_event, projectId: string, input: NewPMTask) => createPMTask(projectById(projectId), input));
+ ipcMain.handle("pm:delete-task", (_event, projectId: string, id: string) => deletePMTask(projectById(projectId), id));
}
function createWindow() {
window = new BrowserWindow({
diff --git a/desktop/src/pm.tsx b/desktop/src/pm.tsx
index 9ed6536..d8494d2 100644
--- a/desktop/src/pm.tsx
+++ b/desktop/src/pm.tsx
@@ -6,7 +6,7 @@ import {
ArrowsPointingInIcon,
} from "@heroicons/react/24/outline";
import type { PMState, PMTask } from "./types";
-export function PM({ initialMessage, triageMessage, triageTaskIds = [], visible, projectName, expanded, onExpand }: { initialMessage?: string; triageMessage?: string; triageTaskIds?: string[]; visible: boolean; projectName: string; expanded?: boolean; onExpand?: () => void }) {
+export function PM({ projectId, initialMessage, triageMessage, triageTaskIds = [], visible, projectName, expanded, onExpand }: { projectId: string; initialMessage?: string; triageMessage?: string; triageTaskIds?: string[]; visible: boolean; projectName: string; expanded?: boolean; onExpand?: () => void }) {
const [state, setState] = useState(),
[message, setMessage] = useState(""),
[queue, setQueue] = useState([]),
@@ -21,8 +21,8 @@ export function PM({ initialMessage, triageMessage, triageTaskIds = [], visible,
autonomousReviewQueued = useRef(false),
chat = useRef(null);
useEffect(() => {
- void window.codepilot.getPM().then(setState);
- }, []);
+ void window.codepilot.getPM(projectId).then(setState);
+ }, [projectId]);
useEffect(() => { if (initialMessage && !onboardingStarted.current) { onboardingStarted.current = true; setQueue([initialMessage]); } }, [initialMessage]);
useEffect(() => {
if (!triageMessage || triageMessage === lastTriageMessage.current) return;
@@ -38,7 +38,7 @@ export function PM({ initialMessage, triageMessage, triageTaskIds = [], visible,
processing.current = true;
setBusy(true);
void window.codepilot
- .chatPM(next)
+ .chatPM(projectId, next)
.then(async (nextState) => {
setState(nextState);
const settings = await window.codepilot.getGlobalSettings();
@@ -80,7 +80,7 @@ export function PM({ initialMessage, triageMessage, triageTaskIds = [], visible,
)
return;
setQueue([]);
- setState(await window.codepilot.clearPMChat());
+ setState(await window.codepilot.clearPMChat(projectId));
}
async function launch(task: PMTask, provider: "codex" | "claude") {
if (dispatchedTaskIdsRef.current.has(task.id)) return;
@@ -94,9 +94,10 @@ export function PM({ initialMessage, triageMessage, triageTaskIds = [], visible,
setDispatchedTaskIds((items) => new Set(items).add(task.id));
setError("");
try {
- setState(await window.codepilot.setPMTaskStatus(task.id, "launched"));
+ setState(await window.codepilot.setPMTaskStatus(projectId, task.id, "launched"));
void window.codepilot
.runAgents({
+ projectId,
objective: `${task.title}\n\ncodePilot PM task: ${task.id}\nQueue: ${task.queueTitle}\n\n${task.description}\n\nAcceptance criteria:\n${task.acceptanceCriteria.map((item) => `- ${item}`).join("\n")}`,
providers: [provider],
retries: 0,
@@ -108,7 +109,7 @@ export function PM({ initialMessage, triageMessage, triageTaskIds = [], visible,
.then(async (result) => {
if (result.status === "completed") {
const summary = result.review.findings.length ? result.review.findings.join(" ") : `Agent run completed with review verdict: ${result.review.verdict}.`;
- const nextState = await window.codepilot.setPMTaskStatus(task.id, "done", summary);
+ const nextState = await window.codepilot.setPMTaskStatus(projectId, task.id, "done", summary);
setState(nextState);
const settings = await window.codepilot.getGlobalSettings();
const readyTasks = nextState.tasks.filter((item) => item.status === "ready" && !dispatchedTaskIdsRef.current.has(item.id));
@@ -118,7 +119,7 @@ export function PM({ initialMessage, triageMessage, triageTaskIds = [], visible,
if (!readyTasks.length) queueAutonomousReview(`Completed "${task.title}". ${summary}`);
} else {
const reason = result.review.findings.length ? result.review.findings.join(" ") : `The agent run returned ${result.status} with review verdict ${result.review.verdict}.`;
- setState(await window.codepilot.recordPMTaskFailure(task.id, `Task did not advance because it was not eligible for completion: ${reason}`));
+ setState(await window.codepilot.recordPMTaskFailure(projectId, task.id, `Task did not advance because it was not eligible for completion: ${reason}`));
restoreForTriage();
queueAutonomousReview(`"${task.title}" needs re-triage after ${result.status}: ${reason}`);
setError(
@@ -128,21 +129,21 @@ export function PM({ initialMessage, triageMessage, triageTaskIds = [], visible,
})
.catch(async (cause) => {
setError(cause instanceof Error ? cause.message : String(cause));
- setState(await window.codepilot.recordPMTaskFailure(task.id, `Task did not advance because the agent run failed: ${cause instanceof Error ? cause.message : String(cause)}`));
+ setState(await window.codepilot.recordPMTaskFailure(projectId, task.id, `Task did not advance because the agent run failed: ${cause instanceof Error ? cause.message : String(cause)}`));
restoreForTriage();
queueAutonomousReview(`"${task.title}" failed: ${cause instanceof Error ? cause.message : String(cause)}`);
})
.finally(() => setLaunching((items) => { const next = new Set(items); next.delete(task.id); return next; }));
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
- try { setState(await window.codepilot.recordPMTaskFailure(task.id, `Task did not advance because launch failed: ${cause instanceof Error ? cause.message : String(cause)}`)); } catch {}
+ try { setState(await window.codepilot.recordPMTaskFailure(projectId, task.id, `Task did not advance because launch failed: ${cause instanceof Error ? cause.message : String(cause)}`)); } catch {}
restoreForTriage();
queueAutonomousReview(`"${task.title}" could not launch: ${cause instanceof Error ? cause.message : String(cause)}`);
setLaunching((items) => { const next = new Set(items); next.delete(task.id); return next; });
}
}
async function requeueTask(task: PMTask) {
- setState(await window.codepilot.setPMTaskStatus(task.id, "ready", "Requeued from the Project Manager recent work list."));
+ setState(await window.codepilot.setPMTaskStatus(projectId, task.id, "ready", "Requeued from the Project Manager recent work list."));
setTriagedTaskIds((items) => { const next = new Set(items); next.delete(task.id); return next; });
setDispatchedTaskIds((items) => { const next = new Set(items); next.delete(task.id); return next; });
dispatchedTaskIdsRef.current.delete(task.id);
diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts
index 03eee6e..ac81b9c 100644
--- a/desktop/src/preload.ts
+++ b/desktop/src/preload.ts
@@ -5,41 +5,45 @@ const api: CodePilotAPI = {
getState: () => ipcRenderer.invoke("state:get"),
getGlobalSettings: () => ipcRenderer.invoke("settings:get"),
saveGlobalSettings: (settings) => ipcRenderer.invoke("settings:save", settings),
- getProjectBrief: () => ipcRenderer.invoke("project:brief-get"),
- saveProjectBrief: (content) => ipcRenderer.invoke("project:brief-save", content),
+ getGitHubStatus: () => ipcRenderer.invoke("github:status"),
+ loginGitHub: () => ipcRenderer.invoke("github:login"),
+ getProjectBrief: (projectId) => ipcRenderer.invoke("project:brief-get", projectId),
+ saveProjectBrief: (projectId, content) => ipcRenderer.invoke("project:brief-save", projectId, content),
getProjectActivity: () => ipcRenderer.invoke("projects:activity"),
selectDirectory: (defaultPath) => ipcRenderer.invoke("dialog:directory", defaultPath),
addProject: (input) => ipcRenderer.invoke("project:add", input),
+ onboardProject: (input) => ipcRenderer.invoke("project:onboard", input),
+ removeProject: (projectId) => ipcRenderer.invoke("project:remove", projectId),
switchProject: (id) => ipcRenderer.invoke("project:switch", id),
- openProjectDirectory: () => ipcRenderer.invoke("project:open-directory"),
- getAppStatus: () => ipcRenderer.invoke("app:status"),
- saveAppConfig: (config) => ipcRenderer.invoke("app:save-config", config),
- startProjectApp: () => ipcRenderer.invoke("app:start"),
- stopProjectApp: () => ipcRenderer.invoke("app:stop"),
- openProjectApp: () => ipcRenderer.invoke("app:open"),
- getKnowledge: () => ipcRenderer.invoke("knowledge:get"),
- chatKnowledge: (message) => ipcRenderer.invoke("knowledge:chat", message),
+ openProjectDirectory: (projectId) => ipcRenderer.invoke("project:open-directory", projectId),
+ getAppStatus: (projectId) => ipcRenderer.invoke("app:status", projectId),
+ saveAppConfig: (projectId, config) => ipcRenderer.invoke("app:save-config", projectId, config),
+ startProjectApp: (projectId) => ipcRenderer.invoke("app:start", projectId),
+ stopProjectApp: (projectId) => ipcRenderer.invoke("app:stop", projectId),
+ clearProjectPort: (projectId) => ipcRenderer.invoke("app:clear-port", projectId),
+ openProjectApp: (projectId) => ipcRenderer.invoke("app:open", projectId),
+ getKnowledge: (projectId) => ipcRenderer.invoke("knowledge:get", projectId),
+ chatKnowledge: (projectId, message) => ipcRenderer.invoke("knowledge:chat", projectId, message),
runAgents: (input) => ipcRenderer.invoke("agents:run", input),
runAgentBatch: (input) => ipcRenderer.invoke("agents:run-batch", input),
- getActiveAgents: () => ipcRenderer.invoke("agents:active"),
- guideAgent: (taskId, message) => ipcRenderer.invoke("agents:guide", taskId, message),
- interruptAgent: (taskId) => ipcRenderer.invoke("agents:interrupt", taskId),
- getRunDetail: (id) => ipcRenderer.invoke("run:detail", id),
- getPM: () => ipcRenderer.invoke("pm:get"),
- getPMTaskDetail: (id) => ipcRenderer.invoke("pm:task-detail", id),
- chatPM: (message) => ipcRenderer.invoke("pm:chat", message),
- clearPMChat: () => ipcRenderer.invoke("pm:clear-chat"),
- setPMTaskStatus: (id, status, summary) => ipcRenderer.invoke("pm:task-status", id, status, summary),
- recordPMTaskFailure: (id, summary) => ipcRenderer.invoke("pm:task-failure", id, summary),
+ getActiveAgents: (projectId) => ipcRenderer.invoke("agents:active", projectId),
+ guideAgent: (projectId, taskId, message) => ipcRenderer.invoke("agents:guide", projectId, taskId, message),
+ interruptAgent: (projectId, taskId) => ipcRenderer.invoke("agents:interrupt", projectId, taskId),
+ getRunDetail: (projectId, id) => ipcRenderer.invoke("run:detail", projectId, id),
+ getPM: (projectId) => ipcRenderer.invoke("pm:get", projectId),
+ getPMTaskDetail: (projectId, id) => ipcRenderer.invoke("pm:task-detail", projectId, id),
+ chatPM: (projectId, message) => ipcRenderer.invoke("pm:chat", projectId, message),
+ clearPMChat: (projectId) => ipcRenderer.invoke("pm:clear-chat", projectId),
+ setPMTaskStatus: (projectId, id, status, summary) => ipcRenderer.invoke("pm:task-status", projectId, id, status, summary),
+ recordPMTaskFailure: (projectId, id, summary) => ipcRenderer.invoke("pm:task-failure", projectId, id, summary),
onPMUserInputRequired: (listener) => {
- const handler = () => listener();
+ const handler = (_event: Electron.IpcRendererEvent, projectId: string) => listener(projectId);
ipcRenderer.on("pm:user-input-required", handler);
return () => ipcRenderer.removeListener("pm:user-input-required", handler);
},
- createPMTask: (input) => ipcRenderer.invoke("pm:create-task", input),
- commitPMTaskBin: (taskIds) => ipcRenderer.invoke("pm:commit-bin", taskIds),
+ createPMTask: (projectId, input) => ipcRenderer.invoke("pm:create-task", projectId, input),
+ commitPMTaskBin: (projectId, taskIds) => ipcRenderer.invoke("pm:commit-bin", projectId, taskIds),
+ deletePMTask: (projectId, id) => ipcRenderer.invoke("pm:delete-task", projectId, id),
};
-(api as CodePilotAPI & { clearProjectPort: () => Promise }).clearProjectPort = () => ipcRenderer.invoke("app:clear-port");
-(api as CodePilotAPI & { deletePMTask: (id: string) => Promise }).deletePMTask = (id) => ipcRenderer.invoke("pm:delete-task", id);
contextBridge.exposeInMainWorld("codepilot", api);
diff --git a/desktop/src/project-settings.tsx b/desktop/src/project-settings.tsx
index 24e0c78..f24c778 100644
--- a/desktop/src/project-settings.tsx
+++ b/desktop/src/project-settings.tsx
@@ -9,7 +9,7 @@ export function ProjectSettings({ projectId, projectName }: { projectId: string;
const [saved, setSaved] = useState(false);
useEffect(() => {
let mounted = true;
- Promise.all([window.codepilot.getProjectBrief(), window.codepilot.getAppStatus()]).then(([nextBrief, runtime]) => {
+ Promise.all([window.codepilot.getProjectBrief(projectId), window.codepilot.getAppStatus(projectId)]).then(([nextBrief, runtime]) => {
if (!mounted) return;
setBrief(nextBrief);
setContent(withRunInstructions(nextBrief.content, runtime.config));
@@ -18,7 +18,7 @@ export function ProjectSettings({ projectId, projectName }: { projectId: string;
}, [projectId]);
async function save() {
setBusy(true);
- try { const next = await window.codepilot.saveProjectBrief(content); setBrief(next); setContent(next.content); setSaved(true); window.setTimeout(() => setSaved(false), 1800); }
+ try { const next = await window.codepilot.saveProjectBrief(projectId, content); setBrief(next); setContent(next.content); setSaved(true); window.setTimeout(() => setSaved(false), 1800); }
finally { setBusy(false); }
}
return Project README · {projectName}
README User-facing documentation for running and understanding this project.
{brief?.path ?? "Loading README path…"}
{brief?.exists ? "Tracked" : "Not created"} ;
diff --git a/desktop/src/renderer.tsx b/desktop/src/renderer.tsx
index c37ff7e..3278eb0 100644
--- a/desktop/src/renderer.tsx
+++ b/desktop/src/renderer.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
-import { ArrowsPointingInIcon, ArrowsPointingOutIcon, BoltIcon, ChevronLeftIcon, ChevronRightIcon, CircleStackIcon, ClipboardDocumentListIcon, Cog6ToothIcon, CommandLineIcon, FolderIcon, PlayIcon, QueueListIcon, SignalIcon, Squares2X2Icon, StopIcon, WrenchScrewdriverIcon } from "@heroicons/react/24/outline";
-import type { AppStatus, DesktopState, PMTask } from "./types";
+import { ArrowsPointingInIcon, ArrowsPointingOutIcon, BoltIcon, ChevronLeftIcon, ChevronRightIcon, CircleStackIcon, ClipboardDocumentListIcon, Cog6ToothIcon, CommandLineIcon, FolderIcon, PlayIcon, PlusIcon, QueueListIcon, RocketLaunchIcon, SignalIcon, Squares2X2Icon, StopIcon, TrashIcon, WrenchScrewdriverIcon, XMarkIcon } from "@heroicons/react/24/outline";
+import type { AppConfig, AppStatus, DesktopState, PMTask } from "./types";
import { Agents } from "./agents";
import { ActiveAgents } from "./active-agents";
import { Knowledge } from "./knowledge";
@@ -14,11 +14,20 @@ import { ProjectSettings } from "./project-settings";
import "./styles.css";
type View = "overview" | "pm" | "tasks" | "agents" | "active-agents" | "runtime" | "projects" | "knowledge" | "settings" | "project-settings" | "global-chat";
+type ProjectDialog = "choose" | "github" | "new";
+type LaunchSetup = { projectId: string; projectName: string; onboardingMessage?: string };
function App() {
const [state, setState] = useState();
const [view, setView] = useState("overview");
const [name, setName] = useState("");
+ const [remoteUrl, setRemoteUrl] = useState("");
+ const [projectDialog, setProjectDialog] = useState();
+ const [launchSetup, setLaunchSetup] = useState();
+ const [launchConfig, setLaunchConfig] = useState({ command: "", workingDirectory: ".", url: "http://localhost:3000" });
+ const [launchSetupBusy, setLaunchSetupBusy] = useState(false);
+ const [launchSetupError, setLaunchSetupError] = useState("");
+ const [onboardingBusy, setOnboardingBusy] = useState<"local"|"remote"|"">("");
const [onboardingMessage, setOnboardingMessage] = useState();
const [triageMessage, setTriageMessage] = useState();
const [triageTaskIds, setTriageTaskIds] = useState([]);
@@ -37,6 +46,12 @@ function App() {
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
+ useEffect(() => {
+ if (!projectDialog || onboardingBusy) return;
+ const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setProjectDialog(undefined); };
+ window.addEventListener("keydown", closeOnEscape);
+ return () => window.removeEventListener("keydown", closeOnEscape);
+ }, [projectDialog, onboardingBusy]);
useEffect(() => {
const audio = new Audio(new URL("./assets/user-input-required.wav", import.meta.url).href);
audio.preload = "auto";
@@ -48,7 +63,9 @@ function App() {
useEffect(()=>{if(typeof window.codepilot.getProjectActivity!=="function")return;let mounted=true;const refresh=()=>void window.codepilot.getProjectActivity().then(next=>mounted&&setProjectActivity(next)).catch(()=>{});refresh();const timer=window.setInterval(refresh,2000);return()=>{mounted=false;window.clearInterval(timer)}},[]);
useEffect(() => {
let mounted = true;
- const refresh = () => void window.codepilot.getActiveAgents().then((agents) => mounted && setAgentsActive(agents.length > 0)).catch(() => mounted && setAgentsActive(false));
+ if (!state?.activeProjectId) return;
+ const projectId = state.activeProjectId;
+ const refresh = () => void window.codepilot.getActiveAgents(projectId).then((agents) => mounted && setAgentsActive(agents.length > 0)).catch(() => mounted && setAgentsActive(false));
refresh(); const timer = window.setInterval(refresh, 2000);
return () => { mounted = false; window.clearInterval(timer); };
}, [state?.activeProjectId]);
@@ -57,14 +74,17 @@ function App() {
if (!state?.activeProjectId) return;
setAppStatus(undefined);
let mounted = true;
- const refresh = () => void window.codepilot.getAppStatus().then((status) => mounted && setAppStatus(status)).catch(() => mounted && setAppStatus(undefined));
+ const projectId = state.activeProjectId;
+ const refresh = () => void window.codepilot.getAppStatus(projectId).then((status) => mounted && setAppStatus(status)).catch(() => mounted && setAppStatus(undefined));
refresh(); const timer = window.setInterval(refresh, 2000);
return () => { mounted = false; window.clearInterval(timer); };
}, [state?.activeProjectId]);
if (!state) return Starting codePilot…
;
- state.globalSettings ??= { defaultProvider: "codex", scaffold: { mode: "guided", autoDeploy: true, onboardingPrompt: "" } };
+ state.globalSettings ??= { defaultProvider: "codex", scaffold: { mode: "guided", autoDeploy: true, onboardingPrompt: "" }, github: { allowWrites: false } };
state.globalSettings.scaffold ??= { mode: "guided", autoDeploy: true, onboardingPrompt: "" };
+ state.globalSettings.github ??= { allowWrites: false };
const active = state.projects.find((project) => project.id === state.activeProjectId)!;
+ const projectsDirectory = state.projectsDirectory;
const sidebarCompact = sidebarCollapsed || windowWidth < 1180;
function triageTasks(tasks: PMTask[]) {
const single = tasks.length === 1 ? tasks[0] : undefined;
@@ -80,7 +100,58 @@ function App() {
setView("pm");
}
async function switchProject(projectId: string) { const nextProject = state?.projects.find((project) => project.id === projectId); if (!nextProject || nextProject.id === active.id) return; setProjectTransition({ from: active.name, to: nextProject.name }); setOnboardingMessage(undefined); try { setState(await window.codepilot.switchProject(projectId)); } finally { window.setTimeout(() => setProjectTransition(undefined), 650); } }
- async function add() { try { const settings=await window.codepilot.getGlobalSettings();const scaffold=settings.scaffold??{mode:"guided",autoDeploy:true,onboardingPrompt:"Help me define the requirements for this new project."};const projectName=name.trim();const next=await window.codepilot.addProject({name:projectName});setState(next);setName("");setError("");if(scaffold.mode==="guided"){setOnboardingMessage(`We just created a new project named ${projectName}. ${scaffold.onboardingPrompt}`);setView("pm")}else setOnboardingMessage(undefined); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } }
+ async function removeProject(projectId: string, projectName: string) {
+ if (!window.confirm(`Remove "${projectName}" from codePilot?\n\nThe repository and its .codepilot data will stay on disk.`)) return;
+ setError("");
+ setOnboardingMessage(undefined);
+ try { setState(await window.codepilot.removeProject(projectId)); }
+ catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
+ }
+ function openProjectDialog() { setName(""); setRemoteUrl(""); setError(""); setProjectDialog("choose"); }
+ async function beginLaunchSetup(next: DesktopState, message?: string) {
+ const project = next.projects.find((candidate) => candidate.id === next.activeProjectId)!;
+ setState(next);
+ setProjectDialog(undefined);
+ setError("");
+ const runtime = await window.codepilot.getAppStatus(project.id);
+ setAppStatus(runtime);
+ setLaunchConfig(runtime.config);
+ setLaunchSetup({ projectId: project.id, projectName: project.name, ...(message ? { onboardingMessage: message } : {}) });
+ setLaunchSetupError("");
+ }
+ function continueAfterLaunchSetup(setup = launchSetup, configured = false) {
+ if (!setup) return;
+ setLaunchSetup(undefined);
+ setLaunchSetupError("");
+ if (setup.onboardingMessage) { setOnboardingMessage(`${setup.onboardingMessage} ${configured ? "The App Core launch configuration was saved during onboarding; keep it accurate as the project evolves." : "App Core launch configuration was skipped for now; configure it once the project has a runnable application."}`); setView("pm"); }
+ else { setOnboardingMessage(undefined); setView("projects"); }
+ }
+ async function saveLaunchSetup() {
+ if (!launchSetup) return;
+ setLaunchSetupBusy(true); setLaunchSetupError("");
+ try { setAppStatus(await window.codepilot.saveAppConfig(launchSetup.projectId, launchConfig)); continueAfterLaunchSetup(launchSetup, true); }
+ catch (cause) { setLaunchSetupError(cause instanceof Error ? cause.message : String(cause)); }
+ finally { setLaunchSetupBusy(false); }
+ }
+ async function add() { try { const settings=await window.codepilot.getGlobalSettings();const scaffold=settings.scaffold??{mode:"guided",autoDeploy:true,onboardingPrompt:"Help me define the requirements for this new project."};const projectName=name.trim();const next=await window.codepilot.addProject({name:projectName});setName("");await beginLaunchSetup(next,scaffold.mode==="guided"?`We just created a new project named ${projectName}. ${scaffold.onboardingPrompt}`:undefined); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } }
+ async function finishRepositoryOnboarding(next: DesktopState, source: "local"|"remote") {
+ const project = next.projects.find((candidate) => candidate.id === next.activeProjectId)!;
+ const settings = await window.codepilot.getGlobalSettings();
+ await beginLaunchSetup(next, settings.scaffold.mode === "guided" ? `We just onboarded the ${source === "remote" ? "cloned" : "local"} Git repository for ${project.name}. Inspect the repository and existing PROJECT.md read-only, summarize its current architecture and maturity, identify missing context, then help me establish goals and a safe initial backlog. ${settings.scaffold.onboardingPrompt}` : undefined);
+ }
+ async function onboardLocal() {
+ setProjectDialog(undefined); setOnboardingBusy("local"); setError("");
+ try { const repo = await window.codepilot.selectDirectory(projectsDirectory); if (repo) await finishRepositoryOnboarding(await window.codepilot.onboardProject({ source: "local", repo }), "local"); }
+ catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
+ finally { setOnboardingBusy(""); }
+ }
+ async function onboardRemote() {
+ const url = remoteUrl.trim(); if (!url) return;
+ setOnboardingBusy("remote"); setError("");
+ try { const next = await window.codepilot.onboardProject({ source: "remote", url }); setRemoteUrl(""); await finishRepositoryOnboarding(next, "remote"); }
+ catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
+ finally { setOnboardingBusy(""); }
+ }
return
@@ -98,23 +169,33 @@ function App() {
setView("projects")} active={view === "projects"} icon={ }>Projects
setView("settings")} active={view === "settings"} icon={ }>Settings
- {!sidebarCompact && <>
Active project
{active.name}
{active.repo}
>}
void window.codepilot.openProjectDirectory()} className="flex items-center justify-center gap-2 rounded-lg border border-white/10 bg-white/[.04] px-2 py-2 text-xs text-zinc-300 hover:bg-white/[.08]"> {!sidebarCompact && "Folder"} void (appStatus?.running ? window.codepilot.stopProjectApp() : window.codepilot.startProjectApp()).then(setAppStatus).catch(() => setView("runtime"))} className={`analog-control flex items-center justify-center gap-2 rounded-lg border px-2 py-2 text-xs ${appStatus?.running ? "analog-red" : "analog-green"}`}>{appStatus?.running ? : }{!sidebarCompact && (appStatus?.running ? "Stop" : "Start")}
+ {!sidebarCompact && <>
Active project
{active.name}
{active.repo}
>}
void window.codepilot.openProjectDirectory(active.id)} className="flex items-center justify-center gap-2 rounded-lg border border-white/10 bg-white/[.04] px-2 py-2 text-xs text-zinc-300 hover:bg-white/[.08]"> {!sidebarCompact && "Folder"} void (appStatus?.running ? window.codepilot.stopProjectApp(active.id) : window.codepilot.startProjectApp(active.id)).then(setAppStatus).catch(() => setView("runtime"))} className={`analog-control flex items-center justify-center gap-2 rounded-lg border px-2 py-2 text-xs ${appStatus?.running ? "analog-red" : "analog-green"}`}>{appStatus?.running ? : }{!sidebarCompact && (appStatus?.running ? "Stop" : "Start")}
-
setPMExpanded(value => !value)} />
+ setPMExpanded(value => !value)} />
- {view === "pm" ? null : view === "tasks" ? : view === "agents" ? : view === "active-agents" ? : view === "runtime" ? : view === "knowledge" ? : view === "project-settings" ? : view === "settings" ? : view === "projects" ? <>
-
- {state.projects.map((project) =>
{setOnboardingMessage(undefined);void window.codepilot.switchProject(project.id).then(setState)}} className={`w-full rounded-xl border p-5 text-left ${project.id === active.id ? "border-indigo-400/30 bg-indigo-400/10" : "border-white/10 bg-white/[.025]"}`}>{project.name}
{Boolean(projectActivity[project.id])&&
{projectActivity[project.id]} working}
{project.repo}
)}
Project genesis
Create a new project {state.globalSettings.scaffold.mode==="guided"?"Create the Git foundation, then enter a guided requirements conversation with Project Manager.":"Create a clean Git repository without starting onboarding."}
setName(event.target.value)} onKeyDown={event=>{if(event.key==="Enter"&&name.trim())void add()}} className="mt-5 w-full rounded-xl border border-white/10 bg-black/25 px-4 py-3 text-sm outline-none focus:border-violet-400/40" placeholder="What should we call it?" />{error &&
{error}
}
void add()} className="mt-4 w-full rounded-xl bg-violet-500 px-4 py-3 text-sm font-semibold shadow-[0_0_18px_rgba(139,92,246,.18)] hover:bg-violet-400 disabled:opacity-40">{state.globalSettings.scaffold.mode==="guided"?"Create & begin onboarding":"Create blank project"} Model: {state.globalSettings.defaultProvider} {state.globalSettings.scaffold.autoDeploy?"Auto-deploy on":"Auto-deploy off"}
- > : }
+ {view === "pm" ? null : view === "tasks" ? : view === "agents" ? : view === "active-agents" ? : view === "runtime" ? : view === "knowledge" ? : view === "project-settings" ? : view === "settings" ? : view === "projects" ? <>
+
+
+ {state.projects.map((project) =>
{setOnboardingMessage(undefined);void switchProject(project.id)}} className="min-w-0 flex-1 p-5 text-left">{project.name}
{Boolean(projectActivity[project.id])&&
{projectActivity[project.id]} working}
{project.repo}
void removeProject(project.id, project.name)} title={state.projects.length === 1 ? "CodePilot needs at least one registered project" : "Remove from codePilot"} className="grid w-14 shrink-0 place-items-center border-l border-white/10 text-zinc-600 hover:bg-red-400/10 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-25 disabled:hover:bg-transparent disabled:hover:text-zinc-600">Remove {project.name} from codePilot )}
+ {error && !projectDialog &&
{error}
}
+
+ > : }
+ {projectDialog &&
{if(event.target===event.currentTarget&&!onboardingBusy)setProjectDialog(undefined)}} className="fixed inset-0 z-[90] grid place-items-center bg-zinc-950/75 p-6 backdrop-blur-sm">
{projectDialog!=="choose"&&
{setError("");setProjectDialog("choose")}} className="mb-3 flex items-center gap-1 text-xs text-zinc-500 hover:text-indigo-300"> All options }
Project registry
{projectDialog==="choose"?"Add a project":projectDialog==="github"?"Clone from GitHub":"Create a new project"} {projectDialog==="choose"?"Choose how this project should enter your CodePilot workspace.":projectDialog==="github"?"Paste an HTTPS or SSH repository URL. Git's credential helper or SSH agent will handle access.":state.globalSettings.scaffold.mode==="guided"?"Name the project. CodePilot will create its Git repository and begin guided onboarding.":"Name the project. CodePilot will create a clean Git repository."}
setProjectDialog(undefined)} title="Close" className="rounded-lg p-2 text-zinc-600 hover:bg-white/5 hover:text-zinc-200 disabled:opacity-30">
+ {projectDialog==="choose"?void onboardLocal()} className="group rounded-2xl border border-white/10 bg-white/[.025] p-5 text-left hover:border-indigo-400/30 hover:bg-indigo-400/[.07]">Local folder
Select an existing local Git checkout.
{setError("");setProjectDialog("github")}} className="group rounded-2xl border border-white/10 bg-white/[.025] p-5 text-left hover:border-indigo-400/30 hover:bg-indigo-400/[.07]">GitHub
Clone an HTTPS or SSH Git repository.
{setError("");setProjectDialog("new")}} className="group rounded-2xl border border-white/10 bg-white/[.025] p-5 text-left hover:border-violet-400/30 hover:bg-violet-400/[.07]">New project
Create a new Git project from scratch.
:projectDialog==="github"?:}
+ {error&&{error}
}}
+ {launchSetup &&
void saveLaunchSetup()} onSkip={()=>continueAfterLaunchSetup(launchSetup)}/>}
{projectTransition && Switching project
{projectTransition.from} → {projectTransition.to}
Loading the selected project’s files, tasks, and sessions
}
;
}
function Nav({ active, on, icon, children }: { active: boolean; on(): void; icon: React.ReactNode; children: React.ReactNode }) { return {icon} {children} ; }
function RuntimeGlyph({ running }: { running: boolean }) { return ; }
+function LaunchSetupDialog({ projectName, config, busy, error, onChange, onSave, onSkip }: { projectName: string; config: AppConfig; busy: boolean; error: string; onChange(config: AppConfig): void; onSave(): void; onSkip(): void }) { return ; }
+function OnboardingField({ label, hint, children }: { label: string; hint: string; children: React.ReactNode }) { return {label} {hint} {children}
; }
+function GitHubMark({ className = "" }: { className?: string }) { return ; }
function Title({ eyebrow, title, text }: { eyebrow: string; title: string; text: string }) { return <>{eyebrow}
{title} {text}
>; }
const rendererWindow = window as Window & { __codepilotRoot?: Root };
rendererWindow.__codepilotRoot ??= createRoot(document.getElementById("root")!);
diff --git a/desktop/src/run-history.tsx b/desktop/src/run-history.tsx
index b880367..7204018 100644
--- a/desktop/src/run-history.tsx
+++ b/desktop/src/run-history.tsx
@@ -1,12 +1,12 @@
import React, { useEffect, useState } from "react";
import type { DesktopState, RunDetail } from "./types";
-export function RunHistory({ state }: { state: DesktopState }) {
+export function RunHistory({ state, projectId }: { state: DesktopState; projectId: string }) {
const [selected, setSelected] = useState();
const [summaries, setSummaries] = useState>({});
const [loading, setLoading] = useState("");
- useEffect(() => { let live = true; void Promise.all(state.runs.map(async (run) => { try { const detail = await window.codepilot.getRunDetail(run.id); return [run.id, detail.workers.find((worker) => worker.report?.summary)?.report?.summary ?? detail.summary?.review?.findings?.[0] ?? `Run ${run.status}.`] as const; } catch { return [run.id, `Run ${run.status}.`] as const; } })).then((items) => { if (live) setSummaries(Object.fromEntries(items)); }); return () => { live = false; }; }, [state.runs]);
- async function open(id: string) { setLoading(id); try { setSelected(await window.codepilot.getRunDetail(id)); } finally { setLoading(""); } }
+ useEffect(() => { let live = true; void Promise.all(state.runs.map(async (run) => { try { const detail = await window.codepilot.getRunDetail(projectId, run.id); return [run.id, detail.workers.find((worker) => worker.report?.summary)?.report?.summary ?? detail.summary?.review?.findings?.[0] ?? `Run ${run.status}.`] as const; } catch { return [run.id, `Run ${run.status}.`] as const; } })).then((items) => { if (live) setSummaries(Object.fromEntries(items)); }); return () => { live = false; }; }, [projectId, state.runs]);
+ async function open(id: string) { setLoading(id); try { setSelected(await window.codepilot.getRunDetail(projectId, id)); } finally { setLoading(""); } }
return <>Mission control
Agent sessions Open a session to see what was delivered, changed, tested, and flagged.
run.status === "needs_review").length} />
{state.runs.length ? state.runs.map((run) => void open(run.id)} className={`w-full border-b border-white/5 p-5 text-left hover:bg-white/5 ${selected?.id === run.id ? "bg-indigo-500/10" : ""}`}>{summaries[run.id] ?? "Loading result summary…"}
Task · {run.objective.split("\n")[0]} {new Date(run.createdAt).toLocaleString()}{loading === run.id ? " · loading…" : ""}
) : No agent sessions yet.
} {selected ? : Select a session to inspect its results.
} >;
}
function Detail({ run }: { run: RunDetail }) { return {run.workers.map((worker) =>
{worker.provider} result {worker.report ?
{worker.report.summary}
:
No structured worker report was produced.
}{worker.verification?.resultCommit &&
commit {worker.verification.resultCommit}
}
)}{run.summary?.review &&
Manager review · {run.summary.review.verdict}
}
; }
diff --git a/desktop/src/settings.tsx b/desktop/src/settings.tsx
index 97c4bb5..bde994a 100644
--- a/desktop/src/settings.tsx
+++ b/desktop/src/settings.tsx
@@ -1,17 +1,93 @@
-import { useState } from "react";
-import { CheckIcon, CpuChipIcon, SparklesIcon } from "@heroicons/react/24/outline";
-import type { DesktopState, GlobalSettings } from "./types";
+import { useEffect, useState } from "react";
+import { CheckIcon, CloudIcon, CpuChipIcon, SparklesIcon } from "@heroicons/react/24/outline";
+import type { DesktopState, GitHubStatus, GlobalSettings } from "./types";
export function Settings({ state, onState }: { state: DesktopState; onState(state: DesktopState): void }) {
- const [settings,setSettings]=useState(state.globalSettings),[saved,setSaved]=useState(false),[busy,setBusy]=useState(false);
- async function save(){setBusy(true);try{const next=await window.codepilot.saveGlobalSettings(settings);setSettings(next);onState(await window.codepilot.getState());setSaved(true);window.setTimeout(()=>setSaved(false),1800)}finally{setBusy(false)}}
- return Global configuration
Settings Defaults apply across projects and sessions.
-
} title="Default model provider" text="Used by guided scaffolding, automatic tasks, and as the initial choice in Deploy."/>{(["codex","claude"] as const).map(provider=>setSettings({...settings,defaultProvider:provider})} title={provider} text={provider==="codex"?"OpenAI coding agent":"Anthropic Claude Code"}/>)}
-
} title="Project scaffolding" text="Choose whether new projects open as blank repositories or begin a guided Project Manager onboarding."/>setSettings({...settings,scaffold:{...settings.scaffold,mode:"guided"}})} title="Guided onboarding" text="Start a requirements conversation and build through agreed tasks."/>setSettings({...settings,scaffold:{...settings.scaffold,mode:"blank"}})} title="Blank repository" text="Create Git foundations without starting a conversation."/>
- Onboarding instructionsInjected as the opening Project Manager message for every guided project.
- Auto-deploy agreed tasks
Use the global model as requirements become implementation-ready.
setSettings({...settings,scaffold:{...settings.scaffold,autoDeploy:event.target.checked}})} className="size-4 accent-violet-500"/>
- {saved&&Global settings saved. }void save()} className="rounded-xl bg-violet-500 px-5 py-2.5 text-sm font-semibold hover:bg-violet-400 disabled:opacity-40">{busy?"Saving…":"Save global settings"}
+ const [settings, setSettings] = useState
(state.globalSettings);
+ const [github, setGitHub] = useState();
+ const [saved, setSaved] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [githubBusy, setGitHubBusy] = useState(false);
+ const [githubError, setGitHubError] = useState("");
+
+ useEffect(() => {
+ void window.codepilot.getGitHubStatus().then(setGitHub).catch(cause => setGitHubError(cause instanceof Error ? cause.message : String(cause)));
+ }, []);
+
+ async function save() {
+ setBusy(true);
+ try {
+ const next = await window.codepilot.saveGlobalSettings(settings);
+ setSettings(next);
+ onState(await window.codepilot.getState());
+ setSaved(true);
+ window.setTimeout(() => setSaved(false), 1800);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function connectGitHub() {
+ setGitHubBusy(true);
+ setGitHubError("");
+ try {
+ const next = await window.codepilot.loginGitHub();
+ setGitHub(next);
+ setSettings(current => ({ ...current, github: { allowWrites: true } }));
+ } catch (cause) {
+ setGitHubError(cause instanceof Error ? cause.message : String(cause));
+ } finally {
+ setGitHubBusy(false);
+ }
+ }
+
+ return
+
Global configuration
+
Settings
+
Defaults apply across projects and sessions.
+
+
+ } title="GitHub connection" text="Delegate authentication to GitHub CLI and its system credential store. CodePilot never reads or saves your token."/>
+
+
+
+
+ {github?.authenticated ? `Connected as @${github.account}` : github?.installed === false ? "GitHub CLI not found" : "GitHub is not connected"}
+
+
+ {github?.authenticated ? `${github.gitProtocol?.toUpperCase() || "Git"} · ${github.scopes.length ? github.scopes.join(", ") : "permissions managed by GitHub"}` : "Sign-in opens GitHub's browser flow and copies its one-time code to your clipboard."}
+
+
+
void connectGitHub()} className="rounded-lg border border-violet-400/30 bg-violet-400/10 px-4 py-2 text-xs font-semibold text-violet-200 hover:bg-violet-400/15 disabled:opacity-40">
+ {githubBusy ? "Waiting for GitHub…" : github?.authenticated ? "Reconnect account" : "Connect GitHub"}
+
+
+ {githubError &&
{githubError}
}
+
+
+
+
Allow GitHub write operations
+
Permits branch pushes and pull-request creation only when a deploy explicitly selects GitHub publishing.
+
+ setSettings({...settings, github: { allowWrites: event.target.checked }})} className="size-4 accent-violet-500"/>
+
+
+
+
+ } title="Default model provider" text="Used by guided scaffolding, automatic tasks, and as the initial choice in Deploy."/>
+ {(["codex","claude"] as const).map(provider => setSettings({...settings, defaultProvider: provider})} title={provider} text={provider === "codex" ? "OpenAI coding agent" : "Anthropic Claude Code"}/>)}
+
+
+
+ } title="Project scaffolding" text="Choose whether new projects open as blank repositories or begin a guided Project Manager onboarding."/>
+ setSettings({...settings, scaffold: {...settings.scaffold, mode: "guided"}})} title="Guided onboarding" text="Start a requirements conversation and build through agreed tasks."/> setSettings({...settings, scaffold: {...settings.scaffold, mode: "blank"}})} title="Blank repository" text="Create Git foundations without starting a conversation."/>
+ Onboarding instructionsInjected as the opening Project Manager message for every guided project.
+ Auto-deploy agreed tasks
Use the global model as requirements become implementation-ready.
setSettings({...settings, scaffold: {...settings.scaffold, autoDeploy: event.target.checked}})} className="size-4 accent-violet-500"/>
+
+
+
{saved && Global settings saved. } void save()} className="rounded-xl bg-violet-500 px-5 py-2.5 text-sm font-semibold hover:bg-violet-400 disabled:opacity-40">{busy ? "Saving…" : "Save global settings"}
;
}
+
function Header({icon,title,text}:{icon:React.ReactNode;title:string;text:string}){return }
function Choice({active,on,title,text}:{active:boolean;on():void;title:string;text:string}){return {active&& } }
diff --git a/desktop/src/task-board.tsx b/desktop/src/task-board.tsx
index 67affe6..4c9b8bb 100644
--- a/desktop/src/task-board.tsx
+++ b/desktop/src/task-board.tsx
@@ -19,7 +19,7 @@ export function TaskBoard({ projectId, projectName, onTriage, onReviewProject }:
const commitTaskBin = window.codepilot.commitPMTaskBin;
useEffect(() => {
let active = true;
- const refresh = () => void window.codepilot.getPM().then((next) => active && setState(next));
+ const refresh = () => void window.codepilot.getPM(projectId).then((next) => active && setState(next));
refresh();
const timer = window.setInterval(refresh, 2000);
return () => { active = false; window.clearInterval(timer); };
@@ -42,8 +42,8 @@ export function TaskBoard({ projectId, projectName, onTriage, onReviewProject }:
setRequeueingColumn(column.title);
try {
for (const task of column.tasks)
- await window.codepilot.setPMTaskStatus(task.id, "ready", `Requeued from the ${column.title} task-board column.`);
- setState(await window.codepilot.getPM());
+ await window.codepilot.setPMTaskStatus(projectId, task.id, "ready", `Requeued from the ${column.title} task-board column.`);
+ setState(await window.codepilot.getPM(projectId));
} finally {
setRequeueingColumn("");
}
@@ -52,7 +52,7 @@ export function TaskBoard({ projectId, projectName, onTriage, onReviewProject }:
if (requeueingColumn) return;
setRequeueingColumn("Blocked");
try {
- setState(await window.codepilot.getPM());
+ setState(await window.codepilot.getPM(projectId));
} finally {
setRequeueingColumn("");
}
@@ -62,8 +62,9 @@ export function TaskBoard({ projectId, projectName, onTriage, onReviewProject }:
setRunningReady(true);
try {
for (const task of readyTasks)
- setState(await window.codepilot.setPMTaskStatus(task.id, "launched"));
+ setState(await window.codepilot.setPMTaskStatus(projectId, task.id, "launched"));
const result = await window.codepilot.runAgentBatch({
+ projectId,
objective: `Task Board coordinated delivery batch (${readyTasks.length} tasks)`,
items: readyTasks.map((task) => ({
id: task.id,
@@ -79,14 +80,14 @@ export function TaskBoard({ projectId, projectName, onTriage, onReviewProject }:
const summary = result.review.findings.length ? result.review.findings.join(" ") : `Coordinated batch completed with review verdict: ${result.review.verdict}.`;
for (const task of readyTasks)
setState(result.status === "completed"
- ? await window.codepilot.setPMTaskStatus(task.id, "done", summary)
- : await window.codepilot.recordPMTaskFailure(task.id, summary));
- setState(await window.codepilot.getPM());
+ ? await window.codepilot.setPMTaskStatus(projectId, task.id, "done", summary)
+ : await window.codepilot.recordPMTaskFailure(projectId, task.id, summary));
+ setState(await window.codepilot.getPM(projectId));
} catch (cause) {
const reason = cause instanceof Error ? cause.message : String(cause);
for (const task of readyTasks)
try {
- setState(await window.codepilot.recordPMTaskFailure(task.id, `Coordinated batch failed before delivery: ${reason}`));
+ setState(await window.codepilot.recordPMTaskFailure(projectId, task.id, `Coordinated batch failed before delivery: ${reason}`));
} catch {}
} finally {
setRunningReady(false);
@@ -97,9 +98,9 @@ export function TaskBoard({ projectId, projectName, onTriage, onReviewProject }:
Project planning
Suggested tasks Review { setCommitMessage("Committing…"); try {
if (typeof commitTaskBin !== "function") throw new Error("The desktop bridge is out of date. Restart codePilot, then try the commit again.");
- const bin = await commitTaskBin(selectedTaskIds);
+ const bin = await commitTaskBin(projectId, selectedTaskIds);
setCommitMessage(`Bin ${bin.hash} · commit ${bin.commit.slice(0, 8)}`);
- setState(await window.codepilot.getPM());
+ setState(await window.codepilot.getPM(projectId));
if (selected && bin.taskIds.includes(selected.task.id)) setSelected(undefined);
setSelectedTaskIds([]);
} catch (cause) { setCommitMessage(cause instanceof Error ? cause.message : String(cause)); } finally { window.setTimeout(() => setCommitMessage(""), 5000); } }} className="rounded-lg border border-indigo-400/30 bg-indigo-400/10 px-3 py-2 text-xs font-semibold text-indigo-200 disabled:cursor-not-allowed disabled:opacity-40">Commit selected tasks
@@ -111,15 +112,15 @@ export function TaskBoard({ projectId, projectName, onTriage, onReviewProject }:
{column.tasks.length}
- {column.tasks.map((task) =>
allTasks.find((candidate) => candidate.id === id)?.title ?? id)} onContext={(event) => { event.preventDefault(); event.stopPropagation(); setContext({ task, x: event.clientX, y: event.clientY }); }} onToggle={() => setExpandedTaskIds((ids) => ids.includes(task.id) ? ids.filter((id) => id !== task.id) : [...ids, task.id])} onOpen={async () => { setLoading(task.id); try { setSelected(await window.codepilot.getPMTaskDetail(task.id)); } finally { setLoading(""); } }} />)}
+ {column.tasks.map((task) => allTasks.find((candidate) => candidate.id === id)?.title ?? id)} onContext={(event) => { event.preventDefault(); event.stopPropagation(); setContext({ task, x: event.clientX, y: event.clientY }); }} onToggle={() => setExpandedTaskIds((ids) => ids.includes(task.id) ? ids.filter((id) => id !== task.id) : [...ids, task.id])} onOpen={async () => { setLoading(task.id); try { setSelected(await window.codepilot.getPMTaskDetail(projectId, task.id)); } finally { setLoading(""); } }} />)}
{!column.tasks.length && No tasks here
}
)}
{commitMessage && {commitMessage}
}
- { setLoading(task.id); try { setSelected(await window.codepilot.getPMTaskDetail(task.id)); } finally { setLoading(""); } }} />
- {selected && setSelected(undefined)} revise={async () => { setState(await window.codepilot.setPMTaskStatus(selected.task.id, "ready", "Marked for revision after review.")); setSelected(undefined); }} />}
- {context && setContext(undefined)} onState={setState} onTriage={(task) => onTriage([task])} />}
+ { setLoading(task.id); try { setSelected(await window.codepilot.getPMTaskDetail(projectId, task.id)); } finally { setLoading(""); } }} />
+ {selected && setSelected(undefined)} revise={async () => { setState(await window.codepilot.setPMTaskStatus(projectId, selected.task.id, "ready", "Marked for revision after review.")); setSelected(undefined); }} />}
+ {context && setContext(undefined)} onState={setState} onTriage={(task) => onTriage([task])} />}
;
}
@@ -191,13 +192,13 @@ function TaskDrawer({ detail, close, revise }: { detail: PMTaskDetail; close():
function Pill({ value }: { value: string }) { return {value.replace("_", " ")} ; }
-function TaskContextMenu({ task, x, y, close, onState, onTriage }: { task: PMTask; x: number; y: number; close(): void; onState(state: PMState): void; onTriage(task: PMTask): void }) {
- const api = window.codepilot as typeof window.codepilot & { deletePMTask: (id: string) => Promise };
+function TaskContextMenu({ projectId, task, x, y, close, onState, onTriage }: { projectId: string; task: PMTask; x: number; y: number; close(): void; onState(state: PMState): void; onTriage(task: PMTask): void }) {
+ const api = window.codepilot;
const [busy, setBusy] = useState(false);
useEffect(() => { if (busy) close(); }, [busy]);
- async function requeue() { setBusy(true); try { onState(await window.codepilot.setPMTaskStatus(task.id, "ready", "Requeued manually from the task board.")); close(); } finally { setBusy(false); } }
+ async function requeue() { setBusy(true); try { onState(await window.codepilot.setPMTaskStatus(projectId, task.id, "ready", "Requeued manually from the task board.")); close(); } finally { setBusy(false); } }
function triage() { close(); onTriage(task); }
- async function remove() { if (!confirm(`Delete task “${task.title}”?`)) return; setBusy(true); try { onState(await api.deletePMTask(task.id)); close(); } finally { setBusy(false); } }
- async function run() { setBusy(true); try { onState(await window.codepilot.setPMTaskStatus(task.id, "launched")); const result = await window.codepilot.runAgents({ objective: `${task.title}\n\n${task.description}\n\nAcceptance criteria:\n${task.acceptanceCriteria.map((item) => `- ${item}`).join("\n")}`, providers: [task.recommendedProvider === "claude" ? "claude" : "codex"], retries: 0, timeoutMinutes: 30, validationCommands: [], integrate: true, dryRun: false }); const summary = result.review.findings.length ? result.review.findings.join(" ") : `Agent run completed with review verdict: ${result.review.verdict}.`; onState(result.status === "completed" ? await window.codepilot.setPMTaskStatus(task.id, "done", summary) : await window.codepilot.recordPMTaskFailure(task.id, summary)); close(); } catch (cause) { const reason = cause instanceof Error ? cause.message : String(cause); try { onState(await window.codepilot.recordPMTaskFailure(task.id, `Task did not advance because the run failed: ${reason}`)); } catch {} close(); } finally { setBusy(false); } }
+ async function remove() { if (!confirm(`Delete task “${task.title}”?`)) return; setBusy(true); try { onState(await api.deletePMTask(projectId, task.id)); close(); } finally { setBusy(false); } }
+ async function run() { setBusy(true); try { onState(await window.codepilot.setPMTaskStatus(projectId, task.id, "launched")); const result = await window.codepilot.runAgents({ projectId, objective: `${task.title}\n\n${task.description}\n\nAcceptance criteria:\n${task.acceptanceCriteria.map((item) => `- ${item}`).join("\n")}`, providers: [task.recommendedProvider === "claude" ? "claude" : "codex"], retries: 0, timeoutMinutes: 30, validationCommands: [], integrate: true, dryRun: false }); const summary = result.review.findings.length ? result.review.findings.join(" ") : `Agent run completed with review verdict: ${result.review.verdict}.`; onState(result.status === "completed" ? await window.codepilot.setPMTaskStatus(projectId, task.id, "done", summary) : await window.codepilot.recordPMTaskFailure(projectId, task.id, summary)); close(); } catch (cause) { const reason = cause instanceof Error ? cause.message : String(cause); try { onState(await window.codepilot.recordPMTaskFailure(projectId, task.id, `Task did not advance because the run failed: ${reason}`)); } catch {} close(); } finally { setBusy(false); } }
return event.stopPropagation()} style={{ left: Math.min(x, window.innerWidth - 190), top: Math.min(y, window.innerHeight - 130) }} className="fixed z-[70] w-44 overflow-hidden rounded-xl border border-white/10 bg-zinc-900 py-1 shadow-2xl ring-1 ring-black/50">{["ready", "backlog"].includes(task.status) && void run()} className="w-full px-3 py-2 text-left text-xs text-indigo-200 hover:bg-indigo-500/15 disabled:opacity-40">Run task }Triage in PM void requeue()} className="w-full px-3 py-2 text-left text-xs text-zinc-200 hover:bg-indigo-500/15 disabled:opacity-40">Requeue task void remove()} className="w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/15 disabled:opacity-40">Delete task
;
}
diff --git a/desktop/src/types.ts b/desktop/src/types.ts
index c929af0..e00dd49 100644
--- a/desktop/src/types.ts
+++ b/desktop/src/types.ts
@@ -1,14 +1,16 @@
export type Project={id:string;name:string;repo:string;createdAt:string}
-export type GlobalSettings={defaultProvider:'codex'|'claude';scaffold:{mode:'guided'|'blank';autoDeploy:boolean;onboardingPrompt:string}}
+export type ProjectOnboardingInput={source:'local';repo:string;name?:string}|{source:'remote';url:string;name?:string;directoryName?:string}
+export type GlobalSettings={defaultProvider:'codex'|'claude';scaffold:{mode:'guided'|'blank';autoDeploy:boolean;onboardingPrompt:string};github:{allowWrites:boolean}}
+export type GitHubStatus={installed:boolean;authenticated:boolean;host:'github.com';account?:string;gitProtocol?:'https'|'ssh';scopes:string[];error?:string}
export type DesktopState={projects:Project[];activeProjectId:string;projectsDirectory:string;globalSettings:GlobalSettings;runs:Array<{id:string;objective:string;status:string;createdAt:string}>}
export type KnowledgeEntry={id:string;title:string;content:string;tags:string[];published:boolean;createdAt:string;updatedAt:string}
export type ChatMessage={role:'user'|'assistant';content:string;timestamp:string;operations?:number}
export type KnowledgeState={entries:KnowledgeEntry[];messages:ChatMessage[]}
export type ProjectBrief={path:string;content:string;exists:boolean}
-export type AgentRunInput={objective:string;providers:Array<'codex'|'claude'>;retries:number;timeoutMinutes:number;validationCommands:string[];integrate:boolean;dryRun:boolean}
-export type AgentBatchRunInput={objective:string;items:Array<{id:string;objective:string;provider:'codex'|'claude'}>;retries:number;timeoutMinutes:number;validationCommands:string[];integrate:boolean;dryRun:boolean}
-export type AgentRunResult={runId:string;status:string;integrationBranch?:string;review:{verdict:string;findings:string[]}}
-export type ActiveAgent={taskId:string;runId:string;provider:'codex'|'claude';status:string;objective:string;updatedAt:string;pendingGuidance:number;events:Array<{timestamp:string;stream:string;payload:unknown}>}
+export type AgentRunInput={projectId:string;objective:string;providers:Array<'codex'|'claude'>;retries:number;timeoutMinutes:number;validationCommands:string[];integrate:boolean;createPullRequest?:boolean;dryRun:boolean}
+export type AgentBatchRunInput={projectId:string;objective:string;items:Array<{id:string;objective:string;provider:'codex'|'claude'}>;retries:number;timeoutMinutes:number;validationCommands:string[];integrate:boolean;createPullRequest?:boolean;dryRun:boolean}
+export type AgentRunResult={runId:string;status:string;integrationBranch?:string;pullRequestUrl?:string;review:{verdict:string;findings:string[]}}
+export type ActiveAgent={projectId:string;taskId:string;runId:string;provider:'codex'|'claude';status:string;objective:string;updatedAt:string;pendingGuidance:number;events:Array<{timestamp:string;stream:string;payload:unknown}>}
export type PMTaskStatus='backlog'|'blocked'|'ready'|'launched'|'done'|'committed'
export type PMTask={id:string;title:string;description:string;acceptanceCriteria:string[];priority:'low'|'medium'|'high';status:PMTaskStatus;queueId:string;queueTitle:string;dependencies:string[];summary?:string;lastRunStatus?:string;failures?:Array<{timestamp:string;summary:string}>;recommendedProvider?:'codex'|'claude'|'either';createdAt:string;updatedAt:string}
export type PMState={tasks:PMTask[];messages:ChatMessage[];requiresUserInput?:boolean;continueAutonomously?:boolean}
@@ -16,7 +18,7 @@ export type TaskBin={name:'committed';hash:string;taskIds:string[];taskTitles:st
export type PMTaskDetail={task:PMTask;prerequisites:Array<{id:string;title:string;status:PMTaskStatus}>;run?:{id:string;status:string;objective:string;createdAt:string;updatedAt:string};workers:Array<{taskId:string;provider:string;status:string}>;events:Array<{timestamp:string;stream:string;payload:unknown}>}
export type AppConfig={command:string;workingDirectory:string;url:string}
export type AppStatus={configured:boolean;running:boolean;pid?:number;startedAt?:string;config:AppConfig;logs:string[]}
-export type RunDetail={id:string;objective:string;status:string;createdAt:string;updatedAt:string;summary?:{integrationBranch?:string;deliveryArtifact?:string;review?:{verdict:string;findings:string[]};collisions?:Array<{path:string;taskIds:string[]}>;validations?:Array<{command:string;exitCode:number;durationMs:number;stdout:string;stderr:string}>};workers:Array<{taskId:string;provider:string;status:string;report?:{status:string;summary:string;claimedTests:string[];concerns:string[];followUps:string[]};verification?:{changedPaths:string[];dirtyPaths:string[];resultCommit?:string;processExitCode:number};warnings:string[]}>}
+export type RunDetail={id:string;objective:string;status:string;createdAt:string;updatedAt:string;summary?:{integrationBranch?:string;pullRequestBody?:string;pullRequestUrl?:string;review?:{verdict:string;findings:string[]};collisions?:Array<{path:string;taskIds:string[]}>;validations?:Array<{command:string;exitCode:number;durationMs:number;stdout:string;stderr:string}>};workers:Array<{taskId:string;provider:string;status:string;report?:{status:string;summary:string;claimedTests:string[];concerns:string[];followUps:string[]};verification?:{changedPaths:string[];dirtyPaths:string[];resultCommit?:string;processExitCode:number};warnings:string[]}>}
export type NewPMTask={title:string;description:string;acceptanceCriteria?:string[];priority?:PMTask['priority'];recommendedProvider?:PMTask['recommendedProvider']}
-export type CodePilotAPI={getState():Promise;getGlobalSettings():Promise;saveGlobalSettings(settings:GlobalSettings):Promise;getProjectBrief():Promise;saveProjectBrief(content:string):Promise;getProjectActivity():Promise>;selectDirectory(defaultPath?:string):Promise;addProject(input:{name:string;repo?:string}):Promise;switchProject(id:string):Promise;openProjectDirectory():Promise;getAppStatus():Promise;saveAppConfig(config:AppConfig):Promise;startProjectApp():Promise;stopProjectApp():Promise;openProjectApp():Promise;getKnowledge():Promise;chatKnowledge(message:string):Promise;runAgents(input:AgentRunInput):Promise;runAgentBatch(input:AgentBatchRunInput):Promise;getActiveAgents():Promise;guideAgent(taskId:string,message:string):Promise;interruptAgent(taskId:string):Promise<{feedback:string;agents:ActiveAgent[]}>;getRunDetail(id:string):Promise;getPM():Promise;getPMTaskDetail(id:string):Promise;chatPM(message:string):Promise;clearPMChat():Promise;setPMTaskStatus(id:string,status:PMTask['status'],summary?:string):Promise;recordPMTaskFailure(id:string,summary:string):Promise;onPMUserInputRequired(listener:()=>void):()=>void;createPMTask(input:NewPMTask):Promise;commitPMTaskBin(taskIds:string[]):Promise}
+export type CodePilotAPI={getState():Promise;getGlobalSettings():Promise;saveGlobalSettings(settings:GlobalSettings):Promise;getGitHubStatus():Promise;loginGitHub():Promise;getProjectBrief(projectId:string):Promise;saveProjectBrief(projectId:string,content:string):Promise;getProjectActivity():Promise>;selectDirectory(defaultPath?:string):Promise;addProject(input:{name:string}):Promise;onboardProject(input:ProjectOnboardingInput):Promise;removeProject(projectId:string):Promise;switchProject(id:string):Promise;openProjectDirectory(projectId:string):Promise;getAppStatus(projectId:string):Promise;saveAppConfig(projectId:string,config:AppConfig):Promise;startProjectApp(projectId:string):Promise;stopProjectApp(projectId:string):Promise;clearProjectPort(projectId:string):Promise;openProjectApp(projectId:string):Promise;getKnowledge(projectId:string):Promise;chatKnowledge(projectId:string,message:string):Promise;runAgents(input:AgentRunInput):Promise;runAgentBatch(input:AgentBatchRunInput):Promise;getActiveAgents(projectId:string):Promise;guideAgent(projectId:string,taskId:string,message:string):Promise;interruptAgent(projectId:string,taskId:string):Promise<{feedback:string;agents:ActiveAgent[]}>;getRunDetail(projectId:string,id:string):Promise;getPM(projectId:string):Promise;getPMTaskDetail(projectId:string,id:string):Promise;chatPM(projectId:string,message:string):Promise;clearPMChat(projectId:string):Promise;setPMTaskStatus(projectId:string,id:string,status:PMTask['status'],summary?:string):Promise;recordPMTaskFailure(projectId:string,id:string,summary:string):Promise;onPMUserInputRequired(listener:(projectId:string)=>void):()=>void;createPMTask(projectId:string,input:NewPMTask):Promise;commitPMTaskBin(projectId:string,taskIds:string[]):Promise;deletePMTask(projectId:string,id:string):Promise}
declare global{interface Window{codepilot:CodePilotAPI}}
diff --git a/docs/architecture.md b/docs/architecture.md
index da5daa4..b449e8e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -9,7 +9,7 @@
5. The trusted manager commits successful workspace changes, independently inspects Git state, retains a run ref, and removes the temporary checkout.
6. Eligible task commits are cherry-picked onto `codepilot/` based on the latest primary head. Repository-wide validation runs there.
7. Deterministic policy combines process, report, Git, collision, and validation evidence into a review verdict.
-8. codePilot writes a local pull-request draft. Remote push, PR creation, and primary-branch mutation remain human actions.
+8. codePilot builds a pull-request description from branch and validation evidence. With an explicit publication option, it pushes the integration branch and opens the GitHub pull request.
## Boundaries
@@ -19,6 +19,14 @@ The manager owns authoritative orchestration state: scheduling, attempts, timeou
Runs targeting the same repository are serialized in one manager process. Tasks within a run may execute concurrently when their dependency graph permits it.
+### Desktop project controllers
+
+Every project-scoped desktop request carries an explicit project ID across the typed preload bridge. The main process resolves that ID before reading PM state, changing tasks, launching agents, inspecting runs, curating knowledge, or controlling the project runtime. Switching the visible project therefore cannot retarget an in-flight operation.
+
+Project Manager threads are keyed by project ID and persisted inside their repository. A keyed queue serializes turns for one PM so multiple windows or queued messages cannot race its conversation state; different project keys may run concurrently through the shared Codex app-server. The Projects screen reports PM and worker activity across the registry.
+
+Repository onboarding either canonicalizes an existing local Git root or clones an explicit HTTPS/SSH remote into the configured projects directory. Existing repositories receive `.codepilot/` in their local Git exclude file, avoiding an onboarding-only source change. Git authentication remains delegated to the operator's credential helper or SSH agent. A post-registration launch step reads any existing or inferred App Core configuration and asks the operator to review the start command, repository-relative working directory, and local URL. Saving this step persists `.codepilot/app-config.json` but deliberately does not start a process; guided Project Manager onboarding begins only after the operator saves or skips it. Removing a project only unregisters it from the desktop workspace; the repository and `.codepilot` state are preserved, and removal is blocked while that project's PM, agents, or app process is active.
+
### Provider adapters
Adapters translate the stable `AgentTask` contract into official CLI arguments. Authentication remains owned by each provider CLI. Parent conversation identifiers and common unrelated credential variables are removed from child environments; the selected provider's authentication variable may be forwarded.
@@ -39,7 +47,9 @@ Leases are cooperative rather than operating-system locks. Independent filesyste
### Integration and delivery
-Task commits are retained under durable Git refs. The manager assembles eligible commits on a dedicated integration branch, runs validation, and creates a PR draft. It does not stash, reset, fast-forward, push, or merge the user's primary branch.
+Task commits are retained under durable Git refs. The manager normalizes each worker workspace into one provenance-rich commit, assembles eligible commits on a dedicated integration branch, runs validation, and builds the PR description directly from that evidence. It does not stash, reset, fast-forward, or merge the user's primary branch. Push and GitHub PR creation occur only when explicitly requested and the operator has enabled GitHub writes in Settings.
+
+GitHub authentication is owned by the official `gh` CLI. The desktop launches its browser/device flow and reads only redacted `gh auth status` output; it never receives a token. The main process checks both authenticated status and the global write policy immediately before any requested publication.
### Persistence
@@ -53,4 +63,5 @@ One SQLite database owns runs, tasks, attempts, bounded events, worker results,
- Validation discovery is Node-centric and custom commands intentionally use a shell.
- Review is deterministic policy, not an independent semantic reviewer.
- The Docker worker protocol is not yet controlled by the manager.
-- Remote delivery and crash-safe resume are intentionally out of scope for v0.1.
+- GitHub delivery currently depends directly on the authenticated `gh` CLI; hosting adapters, idempotent retries, and crash-safe resume are not yet implemented.
+- Multiple repositories can operate independently, but one coordinated initiative spanning their task graphs and delivery policies is not yet implemented.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index ba5393c..9e57e4d 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -6,8 +6,8 @@
- Codex and Claude provider adapters
- durable SQLite run evidence and bounded logs
- cooperative file leases
-- integration branches, validation, review verdicts, and PR drafts
-- Electron project and task control plane
+- integration branches, validation, review verdicts, Git-native handoffs, and opt-in GitHub pull requests
+- Electron project and task control plane with Git repository onboarding and concurrent project-scoped PMs
- CI, security policy, and public documentation
## v0.2 — reviewable planning
@@ -31,6 +31,6 @@
- one initiative spanning multiple repositories with repository-specific task DAGs
- commit-pinned retrieval and deliberate shared knowledge publication
- independent structured semantic review behind deterministic policy
-- opt-in, idempotent GitHub delivery with explicit repository and confirmation gates
+- hosting-provider delivery adapters with idempotent retries and explicit repository policy gates
The v1 success criterion is explainability after interruption: codePilot can reconstruct what ran, what changed, what was validated, which policy decision was made, and which external action—if any—occurred.
diff --git a/docs/threat-model.md b/docs/threat-model.md
index 1ac7646..5a08d66 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -16,7 +16,7 @@ codePilot v0.1 is local developer tooling for repositories, objectives, provider
- The manager is trusted and owns commits, integration, persistence, and policy.
- Provider processes are semi-trusted: they may edit and execute inside a temporary checkout, but their reports are advisory.
- Repository code and configured validation commands are trusted by the operator.
-- Remote Git hosting is outside the v0.1 execution path.
+- Remote Git hosting is contacted only when the operator explicitly clones a repository or requests pull-request creation for a run.
## Existing mitigations
@@ -30,6 +30,10 @@ codePilot v0.1 is local developer tooling for repositories, objectives, provider
- bounded process lines, event counts, database event history, validation output, artifact count, and artifact size
- timeouts on providers and validation commands
- Electron context isolation and a typed preload bridge
+- explicit project IDs on desktop PM, task, agent, knowledge, run, and runtime operations
+- HTTPS/SSH-only remote onboarding without embedded URL credentials; authentication stays with Git's credential helper or SSH agent
+- GitHub browser authentication is delegated to the official `gh` CLI; tokens never cross the Electron preload bridge or enter codePilot state
+- remote writes require a persisted global opt-in plus an explicit per-run pull-request request
## Known risks
@@ -39,13 +43,15 @@ codePilot v0.1 is local developer tooling for repositories, objectives, provider
- Cooperative file leases do not prevent a provider from ignoring the protocol.
- SQLite state is local and is not tamper-evident.
- The experimental Docker files do not yet represent an enforced manager-owned sandbox.
+- Explicit GitHub publication uses the current user's authenticated `gh` and Git credentials to push the generated branch and open a pull request. Repository permissions are enforced by GitHub, not codePilot.
+- Explicit repository cloning runs Git with the current user's network access and credentials; the cloned repository remains untrusted until the operator reviews it.
## Operator guidance
- Run codePilot only against repositories you trust and review objectives before launch.
- Keep `.codepilot/` ignored and do not publish run databases or retained artifacts without inspection.
- Use least-privilege provider and Git credentials.
-- Review the integration branch and PR draft before manually pushing or merging.
+- Review the integration branch and generated pull-request description before publication; review the resulting draft before merging when a run needs review.
- Do not treat a `ready` verdict as a security review.
Security issues in codePilot itself should be reported through the process in [`SECURITY.md`](../SECURITY.md).
diff --git a/src/cli.ts b/src/cli.ts
index f68a878..b24ba58 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -18,12 +18,13 @@ interface Options {
timeoutMs: number;
validationCommands?: string[];
integrate: boolean;
+ createPullRequest: boolean;
}
function usage(): never {
process.stderr.write(
"Usage:\n" +
- " codepilot run [--repo ] [--agents codex,claude] [--retry ] [--timeout ] [--validate ] [--no-integrate] [--dry-run]\n" +
+ " codepilot run [--repo ] [--agents codex,claude] [--retry ] [--timeout ] [--validate ] [--no-integrate] [--create-pr] [--dry-run]\n" +
" codepilot checkout acquire --owner [--repo ] [--wait ] [--lease ] -- \n" +
" codepilot checkout release --owner [--repo ] [-- ]\n" +
" codepilot checkout renew --owner [--repo ] [--lease ]\n" +
@@ -42,12 +43,14 @@ export function parseArgs(args: string[]): Options {
retries: 0,
timeoutMs: 30 * 60_000,
integrate: true,
+ createPullRequest: false,
};
for (let index = 2; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--dry-run") options.dryRun = true;
else if (arg === "--no-integrate") options.integrate = false;
+ else if (arg === "--create-pr") options.createPullRequest = true;
else if (arg === "--retry" && args[index + 1]) {
options.retries = Number.parseInt(args[++index]!, 10);
if (!Number.isSafeInteger(options.retries) || options.retries < 0) usage();
@@ -64,6 +67,7 @@ export function parseArgs(args: string[]): Options {
options.providers = requested as ProviderName[];
} else usage();
}
+ if (options.createPullRequest && (options.dryRun || !options.integrate)) usage();
return options;
}
diff --git a/src/delivery.ts b/src/delivery.ts
index bae94d0..79c35af 100644
--- a/src/delivery.ts
+++ b/src/delivery.ts
@@ -1,23 +1,115 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { dirname, join } from "node:path";
+import { execFile, spawn } from "node:child_process";
+import { promisify } from "node:util";
import type { RunSummary } from "./domain.js";
-export async function writePullRequestDraft(
- stateDirectory: string,
+const execFileAsync = promisify(execFile);
+const MAX_PULL_REQUEST_BODY_CHARS = 60_000;
+
+/** Build the durable run handoff that is sent directly to the hosting PR. */
+export function buildPullRequestBody(objective: string, summary: RunSummary): string {
+ const workers = summary.taskResults.map((result, index) => {
+ const report = result.report;
+ const changedPaths = result.verification.changedPaths
+ .filter((path) => path !== ".codepilot-result.json")
+ .slice(0, 100);
+ return [
+ `### ${index + 1}. ${result.externalId ?? result.taskId} · ${report?.status ?? "unreported"}`,
+ safeMarkdown(report?.summary || "The worker did not provide a valid summary.", 2_000),
+ `- Provider: ${result.provider}`,
+ `- Process: ${result.verification.processExitCode === 0 ? "completed" : `exit ${result.verification.processExitCode}`}`,
+ `- Commit: ${result.verification.resultCommit ? inlineCode(result.verification.resultCommit) : "None"}`,
+ `- Changed paths: ${changedPaths.length ? changedPaths.map(inlineCode).join(", ") : "None"}`,
+ report?.claimedTests.length
+ ? `- Claimed checks: ${report.claimedTests.slice(0, 20).map((test) => inlineCode(compact(test, 500))).join(", ")}`
+ : "- Claimed checks: None",
+ report?.concerns.length ? `- Concerns: ${safeMarkdown(report.concerns.join("; "), 1_000)}` : undefined,
+ report?.followUps.length ? `- Follow-ups: ${safeMarkdown(report.followUps.join("; "), 1_000)}` : undefined,
+ result.warnings.length ? `- Coordinator warnings: ${safeMarkdown(result.warnings.join("; "), 1_000)}` : undefined,
+ ].filter((line): line is string => Boolean(line)).join("\n").slice(0, 3_000);
+ }).join("\n\n");
+ const validations = summary.validations.length
+ ? summary.validations.map((result) =>
+ `- ${result.exitCode === 0 ? "✅" : "❌"} ${inlineCode(result.command)} (${result.durationMs} ms)`,
+ ).join("\n")
+ : "- Not run";
+ const findings = summary.review.findings.length
+ ? summary.review.findings.map((finding) => `- ${safeMarkdown(finding, 2_000)}`).join("\n")
+ : "- No findings";
+ const body = [
+ "## Objective",
+ safeMarkdown(objective, 4_000),
+ "## Integration",
+ `- Branch: \`${summary.integrationBranch ?? "none"}\``,
+ `- Base commit: \`${summary.baseCommit}\``,
+ `- Verdict: **${summary.review.verdict}**`,
+ "## Agent work",
+ workers || "No worker results were produced.",
+ "## Validation",
+ validations,
+ "## Coordinator review",
+ findings,
+ "---",
+ "Generated by codePilot from commit and validation evidence.",
+ ].join("\n\n");
+ return body.length <= MAX_PULL_REQUEST_BODY_CHARS
+ ? body
+ : `${body.slice(0, MAX_PULL_REQUEST_BODY_CHARS - 80)}\n\n_Description truncated by codePilot._`;
+}
+
+function compact(value: string, max: number): string {
+ const normalized = value.replaceAll("\0", "").trim();
+ return normalized.length <= max ? normalized : `${normalized.slice(0, max - 1)}…`;
+}
+
+function safeMarkdown(value: string, max: number): string {
+ return compact(value, max)
+ .replaceAll("@", "@\u200b")
+ .replace(/\b(closes?|closed|fixes?|fixed|resolves?|resolved)(\s+#\d+)/gi, "$1\u200b$2");
+}
+
+function inlineCode(value: string): string {
+ return `\`${compact(value, 1_000).replaceAll("`", "'")}\``;
+}
+
+export async function createGitHubPullRequest(
+ repo: string,
+ branch: string,
objective: string,
- summary: RunSummary,
+ body: string,
+ draft: boolean,
): Promise {
- const path = join(stateDirectory, "runs", summary.runId, "pull-request.md");
- await mkdir(dirname(path), { recursive: true });
- const commits = summary.taskResults
- .flatMap((result) => (result.verification.resultCommit ? [`- ${result.verification.resultCommit}`] : []))
- .join("\n");
- const validations = summary.validations
- .map((result) => `- ${result.exitCode === 0 ? "PASS" : "FAIL"}: \`${result.command}\``)
- .join("\n");
- await writeFile(
- path,
- `# ${objective}\n\nIntegration branch: \`${summary.integrationBranch ?? "none"}\`\n\n## Worker commits\n\n${commits || "- None"}\n\n## Validation\n\n${validations || "- Not run"}\n\n## Review\n\nVerdict: **${summary.review.verdict}**\n\n${summary.review.findings.map((finding) => `- ${finding}`).join("\n") || "- No findings"}\n`,
- );
- return path;
+ await execFileAsync("gh", ["--version"]);
+ await execFileAsync("git", ["-C", repo, "push", "--set-upstream", "origin", branch]);
+ const title = objective.split(/\r?\n/, 1)[0]!.trim().slice(0, 240) || "codePilot delivery";
+ const args = [
+ "pr",
+ "create",
+ "--head",
+ branch,
+ "--title",
+ title,
+ "--body-file",
+ "-",
+ ...(draft ? ["--draft"] : []),
+ ];
+ const stdout = await runWithInput("gh", args, repo, body);
+ const url = stdout.trim().split(/\r?\n/).reverse().find((line: string) => /^https:\/\//.test(line));
+ if (!url) throw new Error(`GitHub CLI did not return a pull request URL: ${stdout.trim()}`);
+ return url;
+}
+
+async function runWithInput(command: string, args: string[], cwd: string, input: string): Promise {
+ return await new Promise((resolve, reject) => {
+ const child = spawn(command, args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"] });
+ let stdout = "";
+ let stderr = "";
+ child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); });
+ child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); });
+ child.once("error", reject);
+ child.once("close", (code) => {
+ if (code === 0) resolve(stdout);
+ else reject(new Error(`${command} exited with ${code ?? 1}: ${stderr.trim() || stdout.trim()}`));
+ });
+ child.stdin.end(input);
+ });
}
diff --git a/src/domain.ts b/src/domain.ts
index 0a1d9b6..32ad2df 100644
--- a/src/domain.ts
+++ b/src/domain.ts
@@ -57,6 +57,7 @@ export interface WorkerVerification {
export interface WorkerResult {
version: 0;
taskId: string;
+ provider: ProviderName;
externalId?: string;
report?: WorkerReport;
verification: WorkerVerification;
@@ -88,6 +89,7 @@ export interface RunRequest {
timeoutMs?: number;
validationCommands?: string[];
integrate?: boolean;
+ createPullRequest?: boolean;
}
export interface PathCollision {
@@ -114,7 +116,8 @@ export interface RunSummary {
status: RunStatus;
baseCommit: string;
integrationBranch?: string;
- deliveryArtifact?: string;
+ pullRequestBody?: string;
+ pullRequestUrl?: string;
taskResults: WorkerResult[];
collisions: PathCollision[];
validations: ValidationResult[];
diff --git a/src/git.ts b/src/git.ts
index d9b1b07..68d216d 100644
--- a/src/git.ts
+++ b/src/git.ts
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
import { mkdir, readFile, readdir, rm } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { promisify } from "node:util";
+import type { AgentTask, WorkerReport } from "./domain.js";
const execFileAsync = promisify(execFile);
@@ -43,7 +44,15 @@ export async function preserveTaskCommit(repo: string, runId: string, taskId: st
await execFileAsync("git", ["-C", repo, "update-ref", `refs/codepilot/runs/${runId}/tasks/${taskId}`, commit]);
}
-export async function commitWorkspaceChanges(workspace: string, taskId: string): Promise {
+export async function commitWorkspaceChanges(
+ workspace: string,
+ task: AgentTask,
+ report?: WorkerReport,
+): Promise {
+ // Normalize whatever the worker did into one manager-owned commit. This
+ // preserves the complete base-to-workspace diff while making the durable
+ // branch history independent of whether an agent created its own commits.
+ await execFileAsync("git", ["-C", workspace, "reset", "--soft", task.baseCommit]);
await execFileAsync("git", ["-C", workspace, "add", "--all"]);
await execFileAsync("git", ["-C", workspace, "reset", "--", ".codepilot-result.json"]);
try {
@@ -59,12 +68,35 @@ export async function commitWorkspaceChanges(workspace: string, taskId: string):
"user.email=codepilot@local",
"commit",
"-m",
- `codepilot: task ${taskId}`,
+ `codepilot: task ${task.externalId ?? task.id}`,
+ "-m",
+ taskCommitBody(task, report),
]);
return true;
}
}
+function taskCommitBody(task: AgentTask, report?: WorkerReport): string {
+ const list = (items: string[]): string => items
+ .slice(0, 20)
+ .map((item) => `- ${item.replaceAll("\0", "").trim().slice(0, 500)}`)
+ .join("\n");
+ const sections = [
+ report?.summary?.replaceAll("\0", "").trim().slice(0, 4_000) || "Worker changes captured by the codePilot manager.",
+ report?.claimedTests.length
+ ? `Claimed validation:\n${list(report.claimedTests)}`
+ : "Claimed validation:\n- Not reported",
+ report?.concerns.length
+ ? `Concerns:\n${list(report.concerns)}`
+ : undefined,
+ report?.followUps.length
+ ? `Follow-ups:\n${list(report.followUps)}`
+ : undefined,
+ `CodePilot-Run: ${task.runId}\nCodePilot-Task: ${task.externalId ?? task.id}\nCodePilot-Provider: ${task.provider}\nCodePilot-Outcome: ${report?.status ?? "unreported"}`,
+ ];
+ return sections.filter((section): section is string => Boolean(section)).join("\n\n");
+}
+
export async function cherryPick(workspace: string, commit: string): Promise {
await execFileAsync("git", ["-C", workspace, "cherry-pick", commit]);
}
diff --git a/src/github-auth.ts b/src/github-auth.ts
new file mode 100644
index 0000000..5dfd841
--- /dev/null
+++ b/src/github-auth.ts
@@ -0,0 +1,35 @@
+export type GitHubStatus = {
+ installed: boolean;
+ authenticated: boolean;
+ host: "github.com";
+ account?: string;
+ gitProtocol?: "https" | "ssh";
+ scopes: string[];
+ error?: string;
+};
+
+export function parseGitHubAuthStatus(output: string): Omit {
+ const account = output.match(/Logged in to github\.com account\s+([^\s(]+)/i)?.[1];
+ const protocol = output.match(/Git operations protocol:\s*(https|ssh)/i)?.[1]?.toLowerCase() as "https" | "ssh" | undefined;
+ const scopeLine = output.match(/Token scopes:\s*(.+)$/im)?.[1] ?? "";
+ const quotedScopes = [...scopeLine.matchAll(/['"]([^'"]+)['"]/g)].map(match => match[1]!);
+ const scopes = (quotedScopes.length ? quotedScopes : scopeLine.split(","))
+ .map(scope => scope.trim().replace(/^['"]|['"]$/g, ""))
+ .filter(Boolean)
+ .sort();
+ return {
+ authenticated: Boolean(account),
+ host: "github.com",
+ ...(account ? { account } : {}),
+ ...(protocol ? { gitProtocol: protocol } : {}),
+ scopes,
+ ...(!account ? { error: "GitHub CLI is not authenticated" } : {}),
+ };
+}
+
+export function assertGitHubPublicationAllowed(allowWrites: boolean, status: GitHubStatus): void {
+ if (!allowWrites)
+ throw new Error("GitHub publishing is disabled. Enable write operations in Settings before creating a pull request.");
+ if (!status.authenticated)
+ throw new Error("Connect GitHub in Settings before creating a pull request.");
+}
diff --git a/src/keyed-queue.ts b/src/keyed-queue.ts
new file mode 100644
index 0000000..cd792da
--- /dev/null
+++ b/src/keyed-queue.ts
@@ -0,0 +1,12 @@
+export class KeyedSerialQueue {
+ readonly #tails = new Map>();
+
+ run(key: string, operation: () => Promise): Promise {
+ const previous = this.#tails.get(key) ?? Promise.resolve();
+ const current = previous.catch(() => undefined).then(operation);
+ this.#tails.set(key, current);
+ return current.finally(() => {
+ if (this.#tails.get(key) === current) this.#tails.delete(key);
+ });
+ }
+}
diff --git a/src/manager.ts b/src/manager.ts
index 9b5e2c3..6e7605a 100644
--- a/src/manager.ts
+++ b/src/manager.ts
@@ -5,12 +5,13 @@ import { tmpdir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { detectPathCollisions } from "./collision.js";
import { clearAgentGuidance, clearAgentInterrupt, drainAgentGuidance, registerAgentInterrupt } from "./control.js";
-import { writePullRequestDraft } from "./delivery.js";
+import { buildPullRequestBody, createGitHubPullRequest } from "./delivery.js";
import type {
AgentEvent,
AgentTask,
ProviderName,
RunRequest,
+ RunStatus,
RunSummary,
WorkerResult,
} from "./domain.js";
@@ -254,7 +255,7 @@ export class Manager {
review.verdict = "needs_review";
review.findings.push("No validated integration branch was available for delivery.");
}
- const status = review.verdict === "ready" ? "completed" : review.verdict === "rejected" ? "failed" : "needs_review";
+ let status: RunStatus = review.verdict === "ready" ? "completed" : review.verdict === "rejected" ? "failed" : "needs_review";
const summary: RunSummary = {
version: 0,
runId,
@@ -266,7 +267,29 @@ export class Manager {
validations,
review,
};
- summary.deliveryArtifact = await writePullRequestDraft(stateDirectory, request.objective, summary);
+ summary.pullRequestBody = buildPullRequestBody(request.objective, summary);
+ if (request.createPullRequest) {
+ if (!integrationBranch) {
+ review.verdict = "needs_review";
+ review.findings.push("A pull request was requested, but no integration branch was available.");
+ } else {
+ try {
+ summary.pullRequestUrl = await createGitHubPullRequest(
+ repo,
+ integrationBranch,
+ request.objective,
+ summary.pullRequestBody,
+ status !== "completed",
+ );
+ } catch (error) {
+ review.verdict = "needs_review";
+ review.findings.push(`GitHub pull request creation failed: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+ status = review.verdict === "ready" ? "completed" : review.verdict === "rejected" ? "failed" : "needs_review";
+ summary.status = status;
+ summary.pullRequestBody = buildPullRequestBody(request.objective, summary);
+ }
store.finishRun(runId, status, summary);
return summary;
}
@@ -337,7 +360,7 @@ export class Manager {
// The manager owns Git. Preserve every successful worker process's changes;
// the report outcome still controls whether that commit is eligible to ship.
if (!failure) {
- await commitWorkspaceChanges(task.workspace, task.id);
+ await commitWorkspaceChanges(task.workspace, task, parsed.report);
}
const inspected = await inspectWorktree(task.workspace, task.baseCommit);
const gitFacts = {
@@ -359,6 +382,7 @@ export class Manager {
const result: WorkerResult = {
version: 0,
taskId: task.id,
+ provider: task.provider,
...(task.externalId ? { externalId: task.externalId } : {}),
...(parsed.report ? { report: parsed.report } : {}),
verification: { processExitCode, baseCommit: task.baseCommit, ...gitFacts },
diff --git a/src/project-onboarding.ts b/src/project-onboarding.ts
new file mode 100644
index 0000000..93a24c7
--- /dev/null
+++ b/src/project-onboarding.ts
@@ -0,0 +1,80 @@
+import { execFile, execFileSync } from "node:child_process";
+import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
+import { basename, relative, resolve } from "node:path";
+
+const remoteUrl = /^(?:https:\/\/[^\s]+|ssh:\/\/[^\s]+|git@[\w.-]+:[^\s]+)$/i;
+
+export function canonicalGitRepository(path: string): string {
+ const requested = resolve(path);
+ const root = execFileSync(
+ "git",
+ ["-C", requested, "rev-parse", "--show-toplevel"],
+ { encoding: "utf8", windowsHide: true },
+ ).trim();
+ return realpathSync(root);
+}
+
+export function repositoryNameFromRemote(url: string): string {
+ assertRemoteGitUrl(url);
+ const clean = url.trim().replace(/[?#].*$/, "").replace(/[\\/]+$/, "");
+ const candidate = clean.split(/[\\/:]/).pop()?.replace(/\.git$/i, "") ?? "";
+ const name = candidate.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
+ if (!name) throw new Error("Could not derive a project name from the Git URL");
+ return name;
+}
+
+export async function cloneGitRepository(input: {
+ url: string;
+ projectsDirectory: string;
+ directoryName?: string;
+}): Promise {
+ const url = input.url.trim();
+ assertRemoteGitUrl(url);
+ const root = resolve(input.projectsDirectory);
+ mkdirSync(root, { recursive: true });
+ const requestedName = input.directoryName?.trim() || repositoryNameFromRemote(url);
+ const safeName = requestedName
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ if (!safeName || safeName === "." || safeName === "..")
+ throw new Error("A valid destination directory name is required");
+ const destination = resolve(root, safeName);
+ const rel = relative(root, destination);
+ if (!rel || rel.startsWith("..") || resolve(root, rel) !== destination)
+ throw new Error("The clone destination must be inside the projects directory");
+ if (existsSync(destination))
+ throw new Error(`The clone destination already exists: ${destination}`);
+
+ try {
+ await new Promise((done, fail) => {
+ execFile("git", ["clone", "--", url, destination], {
+ encoding: "utf8",
+ windowsHide: true,
+ timeout: 10 * 60_000,
+ maxBuffer: 4 * 1024 * 1024,
+ }, (error, _stdout, stderr) => error
+ ? fail(new Error(stderr.trim() || error.message, { cause: error }))
+ : done());
+ });
+ return canonicalGitRepository(destination);
+ } catch (cause) {
+ if (existsSync(destination)) rmSync(destination, { recursive: true, force: true });
+ const detail = cause instanceof Error ? cause.message : String(cause);
+ throw new Error(`Could not clone the Git repository: ${detail}`, { cause });
+ }
+}
+
+export function suggestedProjectName(path: string): string {
+ return basename(resolve(path));
+}
+
+function assertRemoteGitUrl(url: string): void {
+ const value = url.trim();
+ if (!remoteUrl.test(value) || value.includes("\0"))
+ throw new Error("Use an HTTPS or SSH Git repository URL");
+ if (value.startsWith("https://") || value.startsWith("ssh://")) {
+ const parsed = new URL(value);
+ if (parsed.password || (parsed.protocol === "https:" && parsed.username))
+ throw new Error("Do not embed credentials in the Git URL; use your configured Git credential helper");
+ }
+}
diff --git a/src/project-registry.ts b/src/project-registry.ts
new file mode 100644
index 0000000..06a1988
--- /dev/null
+++ b/src/project-registry.ts
@@ -0,0 +1,16 @@
+export function removeProjectRegistration(
+ projects: T[],
+ activeProjectId: string,
+ projectId: string,
+): { projects: T[]; activeProjectId: string } {
+ if (!projects.some((project) => project.id === projectId))
+ throw new Error("Project not found. Refresh the workspace and try again.");
+ if (projects.length === 1)
+ throw new Error("CodePilot needs at least one registered project.");
+ const remaining = projects.filter((project) => project.id !== projectId);
+ return {
+ projects: remaining,
+ activeProjectId:
+ activeProjectId === projectId ? remaining[0]!.id : activeProjectId,
+ };
+}
diff --git a/src/worker.ts b/src/worker.ts
index 62cb23b..34e6df2 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -22,6 +22,7 @@ const facts = await inspectWorktree(request.task.workspace, request.task.baseCom
const result: WorkerResult = {
version: 0,
taskId: request.task.id,
+ provider: request.task.provider,
...(report.report ? { report: report.report } : {}),
verification: {
processExitCode: agentResult.exitCode,
diff --git a/test/cli.test.ts b/test/cli.test.ts
index 35e988b..5dddfc3 100644
--- a/test/cli.test.ts
+++ b/test/cli.test.ts
@@ -16,4 +16,12 @@ test("parses a dry run with multiple providers", () => {
assert.equal(result.objective, "implement the feature");
assert.deepEqual(result.providers, ["codex", "claude"]);
assert.equal(result.dryRun, true);
+ assert.equal(result.createPullRequest, false);
+});
+
+test("parses explicit GitHub pull request publication", () => {
+ const result = parseArgs(["run", "ship the feature", "--create-pr"]);
+ assert.equal(result.createPullRequest, true);
+ assert.equal(result.integrate, true);
+ assert.equal(result.dryRun, false);
});
diff --git a/test/delivery.test.ts b/test/delivery.test.ts
new file mode 100644
index 0000000..ea0cb7b
--- /dev/null
+++ b/test/delivery.test.ts
@@ -0,0 +1,64 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import type { RunSummary } from "../src/domain.js";
+import { buildPullRequestBody } from "../src/delivery.js";
+
+test("builds a pull request body from worker, Git, validation, and review evidence", () => {
+ const summary: RunSummary = {
+ version: 0,
+ runId: "run-1",
+ status: "completed",
+ baseCommit: "base-commit",
+ integrationBranch: "codepilot/run-1",
+ taskResults: [{
+ version: 0,
+ taskId: "task-1",
+ provider: "codex",
+ report: {
+ version: 0,
+ status: "completed",
+ summary: "Implemented the feature.",
+ claimedTests: ["npm test"],
+ concerns: [],
+ followUps: ["Monitor the rollout."],
+ },
+ verification: {
+ processExitCode: 0,
+ baseCommit: "base-commit",
+ headCommit: "result-commit",
+ resultCommit: "result-commit",
+ changedPaths: ["src/feature.ts"],
+ dirtyPaths: [],
+ commitDescendsFromBase: true,
+ },
+ warnings: [],
+ }],
+ collisions: [],
+ validations: [{ command: "npm test", exitCode: 0, durationMs: 42, stdout: "", stderr: "" }],
+ review: { verdict: "ready", findings: [] },
+ };
+
+ const body = buildPullRequestBody("Ship the feature", summary);
+ assert.match(body, /Ship the feature/);
+ assert.match(body, /Implemented the feature/);
+ assert.match(body, /src\/feature\.ts/);
+ assert.match(body, /npm test/);
+ assert.match(body, /Monitor the rollout/);
+});
+
+test("neutralizes unintended mentions and issue-closing directives in generated prose", () => {
+ const summary: RunSummary = {
+ version: 0,
+ runId: "run-2",
+ status: "needs_review",
+ baseCommit: "base",
+ taskResults: [],
+ collisions: [],
+ validations: [],
+ review: { verdict: "needs_review", findings: ["Ask @maintainer; fixes #123"] },
+ };
+ const body = buildPullRequestBody("Notify @team and closes #9", summary);
+ assert.doesNotMatch(body, /@team/);
+ assert.doesNotMatch(body, /closes #9/i);
+ assert.doesNotMatch(body, /fixes #123/i);
+});
diff --git a/test/github-auth.test.ts b/test/github-auth.test.ts
new file mode 100644
index 0000000..53620c2
--- /dev/null
+++ b/test/github-auth.test.ts
@@ -0,0 +1,36 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { assertGitHubPublicationAllowed, parseGitHubAuthStatus } from "../src/github-auth.js";
+
+test("parses a connected GitHub CLI account without retaining the token", () => {
+ const result = parseGitHubAuthStatus(`github.com
+ ✓ Logged in to github.com account hhuumm (keyring)
+ - Active account: true
+ - Git operations protocol: https
+ - Token: gho_secret
+ - Token scopes: 'repo', 'read:org', 'workflow'`);
+ assert.deepEqual(result, {
+ authenticated: true,
+ host: "github.com",
+ account: "hhuumm",
+ gitProtocol: "https",
+ scopes: ["read:org", "repo", "workflow"],
+ });
+ assert.equal(JSON.stringify(result).includes("gho_secret"), false);
+});
+
+test("reports a disconnected GitHub CLI", () => {
+ assert.deepEqual(parseGitHubAuthStatus("You are not logged into any GitHub hosts."), {
+ authenticated: false,
+ host: "github.com",
+ scopes: [],
+ error: "GitHub CLI is not authenticated",
+ });
+});
+
+test("requires both authentication and an explicit write opt-in", () => {
+ const connected = { installed: true, authenticated: true, host: "github.com" as const, account: "hhuumm", scopes: ["repo"] };
+ assert.doesNotThrow(() => assertGitHubPublicationAllowed(true, connected));
+ assert.throws(() => assertGitHubPublicationAllowed(false, connected), /disabled/i);
+ assert.throws(() => assertGitHubPublicationAllowed(true, { ...connected, authenticated: false }), /Connect GitHub/i);
+});
diff --git a/test/keyed-queue.test.ts b/test/keyed-queue.test.ts
new file mode 100644
index 0000000..9f7dc19
--- /dev/null
+++ b/test/keyed-queue.test.ts
@@ -0,0 +1,34 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { KeyedSerialQueue } from "../src/keyed-queue.js";
+
+test("serializes work for one project while allowing different projects to overlap", async () => {
+ const queue = new KeyedSerialQueue();
+ const events: string[] = [];
+ let releaseFirst!: () => void;
+ const firstGate = new Promise((resolve) => (releaseFirst = resolve));
+
+ const first = queue.run("project-a", async () => {
+ events.push("a1:start");
+ await firstGate;
+ events.push("a1:end");
+ });
+ const second = queue.run("project-a", async () => {
+ events.push("a2:start");
+ });
+ const other = queue.run("project-b", async () => {
+ events.push("b:start");
+ });
+
+ await other;
+ assert.deepEqual(events, ["a1:start", "b:start"]);
+ releaseFirst();
+ await Promise.all([first, second]);
+ assert.deepEqual(events, ["a1:start", "b:start", "a1:end", "a2:start"]);
+});
+
+test("continues a project queue after a failed operation", async () => {
+ const queue = new KeyedSerialQueue();
+ await assert.rejects(queue.run("project", async () => { throw new Error("failed"); }));
+ assert.equal(await queue.run("project", async () => "recovered"), "recovered");
+});
diff --git a/test/manager.test.ts b/test/manager.test.ts
index 94ab2f0..6c98ed6 100644
--- a/test/manager.test.ts
+++ b/test/manager.test.ts
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
-import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
+import { access, mkdtemp, readFile, realpath, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import test from "node:test";
@@ -19,7 +19,17 @@ class FixtureProvider implements ProviderAdapter {
this.lastTask = task;
await writeFile(join(task.workspace, "result.txt"), "implemented\n");
await execFileAsync("git", ["-C", task.workspace, "add", "result.txt"]);
- await execFileAsync("git", ["-C", task.workspace, "commit", "-m", "worker result"]);
+ await execFileAsync("git", [
+ "-C",
+ task.workspace,
+ "-c",
+ "user.name=Fixture Worker",
+ "-c",
+ "user.email=fixture@example.com",
+ "commit",
+ "-m",
+ "worker result",
+ ]);
await writeFile(
task.reportPath,
JSON.stringify({
@@ -141,7 +151,7 @@ class PrimaryMovingFixtureProvider implements ProviderAdapter {
}
}
-test("runs a worker, preserves its commit, integrates it, and writes a delivery artifact", async () => {
+test("runs a worker, preserves its commit, and builds a Git-native delivery handoff", async () => {
const root = await mkdtemp(join(tmpdir(), "codepilot-manager-"));
const repo = join(root, "repo");
await execFileAsync("git", ["init", repo]);
@@ -165,11 +175,21 @@ test("runs a worker, preserves its commit, integrates it, and writes a delivery
assert.equal(summary.review.verdict, "ready");
assert.match(summary.integrationBranch!, /^codepilot\//);
assert.equal(summary.taskResults[0]?.verification.changedPaths[0], "result.txt");
- await access(summary.deliveryArtifact!);
+ assert.match(summary.pullRequestBody ?? "", /Added the fixture result/);
+ assert.match(summary.pullRequestBody ?? "", /result\.txt/);
const { stdout } = await execFileAsync("git", ["-C", repo, "show", `${summary.integrationBranch}:result.txt`]);
assert.equal(stdout, "implemented\n");
+ const { stdout: commitMessage } = await execFileAsync("git", ["-C", repo, "log", "-1", "--format=%B", summary.integrationBranch!]);
+ assert.match(commitMessage, /CodePilot-Run:/);
+ assert.match(commitMessage, /CodePilot-Provider: codex/);
+ const { stdout: commitCount } = await execFileAsync("git", ["-C", repo, "rev-list", "--count", `HEAD..${summary.integrationBranch}`]);
+ assert.equal(commitCount.trim(), "1");
+ await assert.rejects(access(join(repo, ".codepilot", "runs", summary.runId, "pull-request.md")));
await assert.rejects(execFileAsync("git", ["-C", repo, "show", "HEAD:result.txt"]));
- assert.equal(resolve(provider.lastTask?.coordinationRepo ?? ""), resolve(repo));
+ assert.equal(
+ (await realpath(resolve(provider.lastTask?.coordinationRepo ?? ""))).toLowerCase(),
+ (await realpath(resolve(repo))).toLowerCase(),
+ );
assert.ok(provider.lastTask?.checkoutCommand);
});
diff --git a/test/orchestration.test.ts b/test/orchestration.test.ts
index 1db74ce..9a8f7e4 100644
--- a/test/orchestration.test.ts
+++ b/test/orchestration.test.ts
@@ -22,6 +22,7 @@ test("detects changed-path collisions", () => {
const makeResult = (taskId: string, changedPaths: string[]): WorkerResult => ({
version: 0,
taskId,
+ provider: "codex",
verification: {
processExitCode: 0,
baseCommit: "base",
diff --git a/test/project-onboarding.test.ts b/test/project-onboarding.test.ts
new file mode 100644
index 0000000..f859a8a
--- /dev/null
+++ b/test/project-onboarding.test.ts
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, mkdirSync, rmSync, statSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+import {
+ canonicalGitRepository,
+ repositoryNameFromRemote,
+} from "../src/project-onboarding.js";
+
+test("canonicalizes an existing Git checkout from a nested directory", () => {
+ const root = mkdtempSync(join(tmpdir(), "codepilot-onboard-"));
+ try {
+ execFileSync("git", ["init", root]);
+ mkdirSync(join(root, "nested"));
+ const expected = statSync(root);
+ const actual = statSync(canonicalGitRepository(join(root, "nested")));
+ assert.equal(actual.dev, expected.dev);
+ assert.equal(actual.ino, expected.ino);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("derives safe project names from HTTPS and SSH remotes", () => {
+ assert.equal(repositoryNameFromRemote("https://github.com/hhuumm/codePilot.git"), "codePilot");
+ assert.equal(repositoryNameFromRemote("git@github.com:hhuumm/codePilot.git"), "codePilot");
+ assert.throws(() => repositoryNameFromRemote("file:///private/repo"), /HTTPS or SSH/);
+ assert.throws(() => repositoryNameFromRemote("https://token@github.com/hhuumm/codePilot.git"), /credential helper/);
+ assert.throws(() => repositoryNameFromRemote("ssh://git:secret@github.com/hhuumm/codePilot.git"), /credential helper/);
+});
diff --git a/test/project-registry.test.ts b/test/project-registry.test.ts
new file mode 100644
index 0000000..deae1a1
--- /dev/null
+++ b/test/project-registry.test.ts
@@ -0,0 +1,26 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { removeProjectRegistration } from "../src/project-registry.js";
+
+test("removes a project registration and selects a replacement when needed", () => {
+ const projects = [{ id: "a" }, { id: "b" }, { id: "c" }];
+ assert.deepEqual(removeProjectRegistration(projects, "b", "b"), {
+ projects: [{ id: "a" }, { id: "c" }],
+ activeProjectId: "a",
+ });
+ assert.deepEqual(removeProjectRegistration(projects, "a", "c"), {
+ projects: [{ id: "a" }, { id: "b" }],
+ activeProjectId: "a",
+ });
+});
+
+test("refuses to remove an unknown or final project", () => {
+ assert.throws(
+ () => removeProjectRegistration([{ id: "a" }], "a", "missing"),
+ /Project not found/,
+ );
+ assert.throws(
+ () => removeProjectRegistration([{ id: "a" }], "a", "a"),
+ /at least one/,
+ );
+});