From 2e2979083b59eb4054a2881cd51559018b764fa5 Mon Sep 17 00:00:00 2001
From: hhuumm
Date: Fri, 28 Aug 2026 09:00:57 -0400
Subject: [PATCH 1/9] feat: isolate project managers and onboard Git
repositories
---
README.md | 4 +-
desktop/src/active-agents.tsx | 12 +-
desktop/src/agents.tsx | 8 +-
desktop/src/app-control.tsx | 8 +-
desktop/src/knowledge.tsx | 8 +-
desktop/src/main.ts | 292 +++++++++++++++++--------------
desktop/src/pm.tsx | 23 +--
desktop/src/preload.ts | 51 +++---
desktop/src/project-settings.tsx | 4 +-
desktop/src/renderer.tsx | 58 +++++-
desktop/src/run-history.tsx | 6 +-
desktop/src/task-board.tsx | 41 ++---
desktop/src/types.ts | 9 +-
docs/architecture.md | 9 +
docs/roadmap.md | 2 +-
docs/threat-model.md | 5 +-
src/keyed-queue.ts | 12 ++
src/project-onboarding.ts | 80 +++++++++
test/keyed-queue.test.ts | 34 ++++
test/project-onboarding.test.ts | 29 +++
20 files changed, 466 insertions(+), 229 deletions(-)
create mode 100644 src/keyed-queue.ts
create mode 100644 src/project-onboarding.ts
create mode 100644 test/keyed-queue.test.ts
create mode 100644 test/project-onboarding.test.ts
diff --git a/README.md b/README.md
index 542a00a..de93c71 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Coding agents are effective inside one task and one checkout. The harder problem
- manager-owned commits retained under `refs/codepilot/runs/...`
- collision detection, integration branches, repository validation, and deterministic review
- Git-native task provenance and generated GitHub pull-request descriptions
-- an Electron desktop control plane for projects, task queues, knowledge, and live agent events
+- an Electron desktop control plane with local/remote Git onboarding, project-scoped PM threads, task queues, knowledge, and live agent events
## Trust boundary
@@ -74,6 +74,8 @@ 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. 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.
+
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() {
-
void window.codepilot.openProjectDirectory()} className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-xs text-zinc-300 hover:bg-white/5"> Open project folder
+
void window.codepilot.openProjectDirectory(projectId)} className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-xs text-zinc-300 hover:bg-white/5"> Open project folder
{agents.length > 1 && <>
diff --git a/desktop/src/agents.tsx b/desktop/src/agents.tsx
index 769b67e..0f93275 100644
--- a/desktop/src/agents.tsx
+++ b/desktop/src/agents.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { PlayIcon } from "@heroicons/react/24/outline";
import type { AgentRunResult, DesktopState, PMState, PMTask } from "./types";
-export function Agents({ onState }: { onState(state: DesktopState): void }) {
+export function Agents({ projectId, onState }: { projectId: string; onState(state: DesktopState): void }) {
const [objective, setObjective] = useState(""),
[codex, setCodex] = useState(true),
[claude, setClaude] = useState(false),
@@ -16,8 +16,8 @@ export function Agents({ onState }: { onState(state: DesktopState): void }) {
const [showAllTasks, setShowAllTasks] = useState(false);
const [poofing, setPoofing] = useState>(() => new Set());
useEffect(() => { void window.codepilot.getGlobalSettings().then((settings) => { setCodex(settings.defaultProvider === "codex"); setClaude(settings.defaultProvider === "claude"); }); }, []);
- useEffect(() => { let active = true; const refresh = () => void window.codepilot.getPM().then((next) => active && setPMState(next)); refresh(); const timer = window.setInterval(refresh, 2000); return () => { active = false; window.clearInterval(timer); }; }, []);
- async function deployTask(task: PMTask) { setError(""); try { setPMState(await window.codepilot.setPMTaskStatus(task.id, "launched")); setPoofing((items) => new Set(items).add(task.id)); await new Promise((resolve) => window.setTimeout(resolve, 900)); const result = await window.codepilot.runAgents({ objective: `${task.title}\n\ncodePilot PM task: ${task.id}\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, createPullRequest, dryRun }); setResults((current) => [result, ...current].slice(0, 5)); const nextStatus = result.status === "completed" ? "done" : "ready"; const summary = result.review.findings.length ? result.review.findings.join(" ") : `Task did not advance because the agent run returned ${result.status} with review verdict ${result.review.verdict}.`; setPMState(await window.codepilot.setPMTaskStatus(task.id, nextStatus, summary)); } catch (cause) { const reason = cause instanceof Error ? cause.message : String(cause); setError(reason); try { setPMState(await window.codepilot.setPMTaskStatus(task.id, "ready", `Task did not advance because deployment failed: ${reason}`)); } catch {} } finally { setPoofing((items) => { const next = new Set(items); next.delete(task.id); return next; }); } }
+ useEffect(() => { let active = true; const refresh = () => void window.codepilot.getPM(projectId).then((next) => active && setPMState(next)); refresh(); const timer = window.setInterval(refresh, 2000); return () => { active = false; window.clearInterval(timer); }; }, [projectId]);
+ async function deployTask(task: PMTask) { setError(""); try { setPMState(await window.codepilot.setPMTaskStatus(projectId, task.id, "launched")); setPoofing((items) => new Set(items).add(task.id)); await new Promise((resolve) => window.setTimeout(resolve, 900)); const result = await window.codepilot.runAgents({ projectId, objective: `${task.title}\n\ncodePilot PM task: ${task.id}\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, createPullRequest, dryRun }); setResults((current) => [result, ...current].slice(0, 5)); const nextStatus = result.status === "completed" ? "done" : "ready"; const summary = result.review.findings.length ? result.review.findings.join(" ") : `Task did not advance because the agent run returned ${result.status} with review verdict ${result.review.verdict}.`; setPMState(await window.codepilot.setPMTaskStatus(projectId, task.id, nextStatus, summary)); } catch (cause) { const reason = cause instanceof Error ? cause.message : String(cause); setError(reason); try { setPMState(await window.codepilot.setPMTaskStatus(projectId, task.id, "ready", `Task did not advance because deployment failed: ${reason}`)); } catch {} } finally { setPoofing((items) => { const next = new Set(items); next.delete(task.id); return next; }); } }
async function launch() {
const requestObjective = objective.trim();
if (!requestObjective) return;
@@ -26,7 +26,7 @@ export function Agents({ onState }: { onState(state: DesktopState): void }) {
setValidation("");
try {
const [title, ...rest] = requestObjective.split(/\r?\n/);
- const nextPM = await window.codepilot.createPMTask({ title: title.slice(0, 160), description: `${rest.join("\n").trim() || title}${validation.trim() ? `\n\nVerification criteria:\n${validation.trim()}` : ""}`, acceptanceCriteria: validation.split("\n").map((item) => item.trim()).filter(Boolean), recommendedProvider: codex ? "codex" : "claude" });
+ const nextPM = await window.codepilot.createPMTask(projectId, { title: title.slice(0, 160), description: `${rest.join("\n").trim() || title}${validation.trim() ? `\n\nVerification criteria:\n${validation.trim()}` : ""}`, acceptanceCriteria: validation.split("\n").map((item) => item.trim()).filter(Boolean), recommendedProvider: codex ? "codex" : "claude" });
setPMState(nextPM);
const created = [...nextPM.tasks].reverse().find((task) => task.title === title.slice(0, 160));
if (created) void deployTask(created);
diff --git a/desktop/src/app-control.tsx b/desktop/src/app-control.tsx
index 3939267..2708b25 100644
--- a/desktop/src/app-control.tsx
+++ b/desktop/src/app-control.tsx
@@ -26,12 +26,12 @@ export function AppControl({ projectId, status, onStatus }: { projectId: string;
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 7447aac..85e0fbf 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,12 +46,21 @@ 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";
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." } };
@@ -129,8 +139,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,8 +181,8 @@ 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:save", (_event, settings: GlobalSettings) => {
@@ -180,17 +190,18 @@ function registerIPC() {
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) } };
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("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 +223,50 @@ 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();
+ });
}
-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 +281,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 +298,31 @@ 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 (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(
@@ -351,8 +361,8 @@ function registerAgentIPC() {
};
});
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 (!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(),
@@ -388,19 +398,21 @@ function registerAgentIPC() {
};
});
}
-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 samePath(left: string, right: string): boolean {
+ const normalize = (value: string) => resolve(value).replace(/[\\/]+$/, "").toLowerCase();
+ return normalize(left) === normalize(right);
}
-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 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 {
@@ -410,7 +422,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);
@@ -421,7 +433,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 };
}
@@ -443,8 +455,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");
@@ -460,8 +472,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");
@@ -470,9 +482,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."); }
@@ -494,10 +505,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"))
@@ -514,11 +525,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[];
@@ -526,18 +537,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));
}
@@ -564,7 +575,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(
@@ -646,8 +657,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)!;
@@ -678,18 +689,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.");
@@ -706,18 +719,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);
@@ -748,11 +761,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"),
@@ -791,7 +803,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(),
@@ -804,7 +816,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(),
@@ -818,12 +830,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 },
{
@@ -834,7 +846,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) => {
@@ -859,9 +871,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 = {
@@ -960,7 +972,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);
@@ -1133,10 +1153,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");
@@ -1151,8 +1171,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");
@@ -1169,9 +1189,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..a80acef 100644
--- a/desktop/src/preload.ts
+++ b/desktop/src/preload.ts
@@ -5,41 +5,42 @@ 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),
+ 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),
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..f77341a 100644
--- a/desktop/src/renderer.tsx
+++ b/desktop/src/renderer.tsx
@@ -19,6 +19,8 @@ function App() {
const [state, setState] = useState();
const [view, setView] = useState("overview");
const [name, setName] = useState("");
+ const [remoteUrl, setRemoteUrl] = useState("");
+ const [onboardingBusy, setOnboardingBusy] = useState<"local"|"remote"|"">("");
const [onboardingMessage, setOnboardingMessage] = useState();
const [triageMessage, setTriageMessage] = useState();
const [triageTaskIds, setTriageTaskIds] = useState([]);
@@ -48,7 +50,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,7 +61,8 @@ 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]);
@@ -65,6 +70,7 @@ function App() {
state.globalSettings ??= { defaultProvider: "codex", scaffold: { mode: "guided", autoDeploy: true, onboardingPrompt: "" } };
state.globalSettings.scaffold ??= { mode: "guided", autoDeploy: true, onboardingPrompt: "" };
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;
@@ -81,6 +87,29 @@ function App() {
}
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 finishRepositoryOnboarding(next: DesktopState, source: "local"|"remote") {
+ const project = next.projects.find((candidate) => candidate.id === next.activeProjectId)!;
+ const settings = await window.codepilot.getGlobalSettings();
+ setState(next);
+ setError("");
+ if (settings.scaffold.mode === "guided") {
+ setOnboardingMessage(`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}`);
+ setView("pm");
+ } else setOnboardingMessage(undefined);
+ }
+ async function onboardLocal() {
+ 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,16 +127,31 @@ 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" ? <>
+ {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"}
- > : }
+
+
{state.projects.map((project) =>
{setOnboardingMessage(undefined);void switchProject(project.id)}} 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}
)}
+
+
+ Repository onboarding
+ Bring an existing project
+ Register a local Git checkout or clone an HTTPS/SSH remote. Each repository receives an independent Project Manager.
+ void onboardLocal()} className="mt-5 w-full rounded-xl border border-indigo-400/25 bg-indigo-400/10 px-4 py-3 text-sm font-semibold text-indigo-100 hover:bg-indigo-400/15 disabled:opacity-40">{onboardingBusy === "local" ? "Inspecting checkout…" : "Choose local Git repository"}
+ or clone remote
+ setRemoteUrl(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && remoteUrl.trim()) void onboardRemote(); }} className="w-full rounded-xl border border-white/10 bg-black/25 px-4 py-3 font-mono text-xs outline-none focus:border-indigo-400/40" placeholder="https://github.com/owner/repository.git" />
+ void onboardRemote()} className="mt-3 w-full rounded-xl bg-indigo-500 px-4 py-3 text-sm font-semibold hover:bg-indigo-400 disabled:opacity-40">{onboardingBusy === "remote" ? "Cloning repository…" : "Clone & onboard"}
+
+
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?" /> 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"}
+ {error &&
{error}
}
+
+
+ > : }
{projectTransition &&
Switching project
{projectTransition.from} → {projectTransition.to}
Loading the selected project’s files, tasks, and sessions
}
;
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/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 72f088a..1a2bbe2 100644
--- a/desktop/src/types.ts
+++ b/desktop/src/types.ts
@@ -1,14 +1,15 @@
export type Project={id:string;name:string;repo:string;createdAt: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}}
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;createPullRequest?: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;createPullRequest?:boolean;dryRun:boolean}
+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={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 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}
@@ -18,5 +19,5 @@ 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;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;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;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 00de033..16f29cb 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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.
+
### 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.
@@ -54,3 +62,4 @@ One SQLite database owns runs, tasks, attempts, bounded events, worker results,
- Review is deterministic policy, not an independent semantic reviewer.
- The Docker worker protocol is not yet controlled by the manager.
- 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 6039de5..9e57e4d 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -7,7 +7,7 @@
- durable SQLite run evidence and bounded logs
- cooperative file leases
- integration branches, validation, review verdicts, Git-native handoffs, and opt-in GitHub pull requests
-- Electron project and task control plane
+- 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
diff --git a/docs/threat-model.md b/docs/threat-model.md
index 4b1a223..398161f 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 contacted only when the operator explicitly requests pull-request creation for a run.
+- 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,8 @@ 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
## Known risks
@@ -40,6 +42,7 @@ codePilot v0.1 is local developer tooling for repositories, objectives, provider
- 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.
+- 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
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/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/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/project-onboarding.test.ts b/test/project-onboarding.test.ts
new file mode 100644
index 0000000..9d294df
--- /dev/null
+++ b/test/project-onboarding.test.ts
@@ -0,0 +1,29 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, mkdirSync, realpathSync, rmSync } 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"));
+ assert.equal(canonicalGitRepository(join(root, "nested")), realpathSync(root));
+ } 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/);
+});
From c7744deaa255f582f0bec69b9be7f00651feacde Mon Sep 17 00:00:00 2001
From: hhuumm
Date: Fri, 28 Aug 2026 09:02:27 -0400
Subject: [PATCH 2/9] test: compare repository directory identity
---
test/project-onboarding.test.ts | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/test/project-onboarding.test.ts b/test/project-onboarding.test.ts
index 9d294df..f859a8a 100644
--- a/test/project-onboarding.test.ts
+++ b/test/project-onboarding.test.ts
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
-import { mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs";
+import { mkdtempSync, mkdirSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
@@ -14,7 +14,10 @@ test("canonicalizes an existing Git checkout from a nested directory", () => {
try {
execFileSync("git", ["init", root]);
mkdirSync(join(root, "nested"));
- assert.equal(canonicalGitRepository(join(root, "nested")), realpathSync(root));
+ 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 });
}
From d8fe3e404b8edddbd6f670598c19948c71a2202f Mon Sep 17 00:00:00 2001
From: hhuumm
Date: Fri, 28 Aug 2026 11:35:52 -0400
Subject: [PATCH 3/9] feat: remove project registrations safely
---
README.md | 2 +-
desktop/src/main.ts | 18 ++++++++++++++++++
desktop/src/preload.ts | 1 +
desktop/src/renderer.tsx | 13 ++++++++++---
desktop/src/types.ts | 2 +-
docs/architecture.md | 2 +-
src/project-registry.ts | 16 ++++++++++++++++
test/project-registry.test.ts | 26 ++++++++++++++++++++++++++
8 files changed, 74 insertions(+), 6 deletions(-)
create mode 100644 src/project-registry.ts
create mode 100644 test/project-registry.test.ts
diff --git a/README.md b/README.md
index de93c71..ac8808f 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ 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. 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.
+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. 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.
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.
diff --git a/desktop/src/main.ts b/desktop/src/main.ts
index 85e0fbf..04e0057 100644
--- a/desktop/src/main.ts
+++ b/desktop/src/main.ts
@@ -53,6 +53,7 @@ import {
repositoryNameFromRemote,
suggestedProjectName,
} from "../../src/project-onboarding";
+import { removeProjectRegistration } from "../../src/project-registry";
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
declare const MAIN_WINDOW_VITE_NAME: string;
if (started) app.quit();
@@ -264,6 +265,23 @@ function registerIPC() {
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(project: Project) {
const db = join(project.repo, ".codepilot", "codepilot.db");
diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts
index a80acef..0752c76 100644
--- a/desktop/src/preload.ts
+++ b/desktop/src/preload.ts
@@ -11,6 +11,7 @@ const api: CodePilotAPI = {
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: (projectId) => ipcRenderer.invoke("project:open-directory", projectId),
getAppStatus: (projectId) => ipcRenderer.invoke("app:status", projectId),
diff --git a/desktop/src/renderer.tsx b/desktop/src/renderer.tsx
index f77341a..17baaf0 100644
--- a/desktop/src/renderer.tsx
+++ b/desktop/src/renderer.tsx
@@ -1,6 +1,6 @@
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 { ArrowsPointingInIcon, ArrowsPointingOutIcon, BoltIcon, ChevronLeftIcon, ChevronRightIcon, CircleStackIcon, ClipboardDocumentListIcon, Cog6ToothIcon, CommandLineIcon, FolderIcon, PlayIcon, QueueListIcon, SignalIcon, Squares2X2Icon, StopIcon, TrashIcon, WrenchScrewdriverIcon } from "@heroicons/react/24/outline";
import type { AppStatus, DesktopState, PMTask } from "./types";
import { Agents } from "./agents";
import { ActiveAgents } from "./active-agents";
@@ -86,6 +86,13 @@ 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 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)); }
+ }
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 finishRepositoryOnboarding(next: DesktopState, source: "local"|"remote") {
const project = next.projects.find((candidate) => candidate.id === next.activeProjectId)!;
@@ -134,9 +141,9 @@ function App() {
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 switchProject(project.id)}} 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}
)}
+
{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 )}
Repository onboarding
diff --git a/desktop/src/types.ts b/desktop/src/types.ts
index 1a2bbe2..bab4c29 100644
--- a/desktop/src/types.ts
+++ b/desktop/src/types.ts
@@ -19,5 +19,5 @@ 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;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(projectId:string):Promise;saveProjectBrief(projectId:string,content:string):Promise;getProjectActivity():Promise>;selectDirectory(defaultPath?:string):Promise;addProject(input:{name:string}):Promise;onboardProject(input:ProjectOnboardingInput):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}
+export type CodePilotAPI={getState():Promise;getGlobalSettings():Promise;saveGlobalSettings(settings:GlobalSettings):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 16f29cb..a92c1fc 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -25,7 +25,7 @@ Every project-scoped desktop request carries an explicit project ID across the t
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.
+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. 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
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/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/,
+ );
+});
From 29171d8b62270caa93b8893448d0217a7d4d4439 Mon Sep 17 00:00:00 2001
From: hhuumm
Date: Fri, 28 Aug 2026 16:27:46 -0400
Subject: [PATCH 4/9] feat: streamline project onboarding dialogs
---
desktop/src/renderer.tsx | 40 +++++++++++++++++++++-------------------
1 file changed, 21 insertions(+), 19 deletions(-)
diff --git a/desktop/src/renderer.tsx b/desktop/src/renderer.tsx
index 17baaf0..9043ec9 100644
--- a/desktop/src/renderer.tsx
+++ b/desktop/src/renderer.tsx
@@ -1,6 +1,6 @@
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, TrashIcon, WrenchScrewdriverIcon } from "@heroicons/react/24/outline";
+import { ArrowsPointingInIcon, ArrowsPointingOutIcon, BoltIcon, ChevronLeftIcon, ChevronRightIcon, CircleStackIcon, ClipboardDocumentListIcon, Cog6ToothIcon, CommandLineIcon, FolderIcon, PlayIcon, PlusIcon, QueueListIcon, SignalIcon, Squares2X2Icon, StopIcon, TrashIcon, WrenchScrewdriverIcon, XMarkIcon } from "@heroicons/react/24/outline";
import type { AppStatus, DesktopState, PMTask } from "./types";
import { Agents } from "./agents";
import { ActiveAgents } from "./active-agents";
@@ -14,12 +14,14 @@ 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";
function App() {
const [state, setState] = useState();
const [view, setView] = useState("overview");
const [name, setName] = useState("");
const [remoteUrl, setRemoteUrl] = useState("");
+ const [projectDialog, setProjectDialog] = useState();
const [onboardingBusy, setOnboardingBusy] = useState<"local"|"remote"|"">("");
const [onboardingMessage, setOnboardingMessage] = useState();
const [triageMessage, setTriageMessage] = useState();
@@ -39,6 +41,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";
@@ -93,19 +101,21 @@ function App() {
try { setState(await window.codepilot.removeProject(projectId)); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
}
- 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)); } }
+ function openProjectDialog() { setName(""); setRemoteUrl(""); setError(""); setProjectDialog("choose"); }
+ 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("");setProjectDialog(undefined);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 finishRepositoryOnboarding(next: DesktopState, source: "local"|"remote") {
const project = next.projects.find((candidate) => candidate.id === next.activeProjectId)!;
const settings = await window.codepilot.getGlobalSettings();
setState(next);
setError("");
+ setProjectDialog(undefined);
if (settings.scaffold.mode === "guided") {
setOnboardingMessage(`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}`);
setView("pm");
} else setOnboardingMessage(undefined);
}
async function onboardLocal() {
- setOnboardingBusy("local"); setError("");
+ 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(""); }
@@ -141,31 +151,23 @@ function App() {
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 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 )}
-
-
- Repository onboarding
- Bring an existing project
- Register a local Git checkout or clone an HTTPS/SSH remote. Each repository receives an independent Project Manager.
- void onboardLocal()} className="mt-5 w-full rounded-xl border border-indigo-400/25 bg-indigo-400/10 px-4 py-3 text-sm font-semibold text-indigo-100 hover:bg-indigo-400/15 disabled:opacity-40">{onboardingBusy === "local" ? "Inspecting checkout…" : "Choose local Git repository"}
- or clone remote
- setRemoteUrl(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && remoteUrl.trim()) void onboardRemote(); }} className="w-full rounded-xl border border-white/10 bg-black/25 px-4 py-3 font-mono text-xs outline-none focus:border-indigo-400/40" placeholder="https://github.com/owner/repository.git" />
- void onboardRemote()} className="mt-3 w-full rounded-xl bg-indigo-500 px-4 py-3 text-sm font-semibold hover:bg-indigo-400 disabled:opacity-40">{onboardingBusy === "remote" ? "Cloning repository…" : "Clone & onboard"}
-
-
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?" /> 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"}
- {error &&
{error}
}
-
+
+
+ {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}
}}
{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 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")!);
From 47fb410947a634f2892a46971557d52213bdc713 Mon Sep 17 00:00:00 2001
From: hhuumm
Date: Fri, 28 Aug 2026 16:37:23 -0400
Subject: [PATCH 5/9] feat: review launch configuration during onboarding
---
README.md | 2 +-
desktop/src/renderer.tsx | 47 +++++++++++++++++++++++++++++++---------
docs/architecture.md | 2 +-
3 files changed, 39 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
index ac8808f..7fc7266 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ 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. 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.
+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.
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.
diff --git a/desktop/src/renderer.tsx b/desktop/src/renderer.tsx
index 9043ec9..2d53661 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, PlusIcon, QueueListIcon, SignalIcon, Squares2X2Icon, StopIcon, TrashIcon, WrenchScrewdriverIcon, XMarkIcon } 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";
@@ -15,6 +15,7 @@ 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();
@@ -22,6 +23,10 @@ function App() {
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();
@@ -102,17 +107,36 @@ function App() {
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
}
function openProjectDialog() { setName(""); setRemoteUrl(""); setError(""); setProjectDialog("choose"); }
- 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("");setProjectDialog(undefined);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 finishRepositoryOnboarding(next: DesktopState, source: "local"|"remote") {
+ async function beginLaunchSetup(next: DesktopState, message?: string) {
const project = next.projects.find((candidate) => candidate.id === next.activeProjectId)!;
- const settings = await window.codepilot.getGlobalSettings();
setState(next);
- setError("");
setProjectDialog(undefined);
- if (settings.scaffold.mode === "guided") {
- setOnboardingMessage(`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}`);
- setView("pm");
- } else setOnboardingMessage(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("");
@@ -161,12 +185,15 @@ function App() {
{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 };
diff --git a/docs/architecture.md b/docs/architecture.md
index a92c1fc..be0c2f9 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -25,7 +25,7 @@ Every project-scoped desktop request carries an explicit project ID across the t
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. 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.
+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
From 3da53ebf68bda378be74d1d6d404cd1e86d1f85c Mon Sep 17 00:00:00 2001
From: hhuumm
Date: Fri, 28 Aug 2026 17:51:02 -0400
Subject: [PATCH 6/9] feat: add secure GitHub connection controls
---
README.md | 3 ++
desktop/src/agents.tsx | 11 +++--
desktop/src/main.ts | 55 ++++++++++++++++++++--
desktop/src/preload.ts | 2 +
desktop/src/renderer.tsx | 3 +-
desktop/src/settings.tsx | 98 +++++++++++++++++++++++++++++++++++-----
desktop/src/types.ts | 5 +-
docs/architecture.md | 4 +-
docs/threat-model.md | 4 +-
src/github-auth.ts | 35 ++++++++++++++
test/github-auth.test.ts | 36 +++++++++++++++
11 files changed, 233 insertions(+), 23 deletions(-)
create mode 100644 src/github-auth.ts
create mode 100644 test/github-auth.test.ts
diff --git a/README.md b/README.md
index 7fc7266..13ebaea 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@ codePilot filters parent conversation identifiers and common unrelated secrets f
- 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
@@ -76,6 +77,8 @@ Build the Windows package with `npm run desktop:build`; output is written under
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/agents.tsx b/desktop/src/agents.tsx
index 0f93275..da5a6b7 100644
--- a/desktop/src/agents.tsx
+++ b/desktop/src/agents.tsx
@@ -13,9 +13,10 @@ export function Agents({ projectId, onState }: { projectId: string; onState(stat
[error, setError] = useState(""),
[results, setResults] = useState([]);
const [pmState, setPMState] = useState();
+ const [githubReady, setGitHubReady] = useState(false);
const [showAllTasks, setShowAllTasks] = useState(false);
const [poofing, setPoofing] = useState>(() => new Set());
- useEffect(() => { void window.codepilot.getGlobalSettings().then((settings) => { setCodex(settings.defaultProvider === "codex"); setClaude(settings.defaultProvider === "claude"); }); }, []);
+ useEffect(() => { void Promise.all([window.codepilot.getGlobalSettings(), window.codepilot.getGitHubStatus()]).then(([settings, status]) => { setCodex(settings.defaultProvider === "codex"); setClaude(settings.defaultProvider === "claude"); setGitHubReady(status.authenticated && settings.github.allowWrites); }); }, []);
useEffect(() => { let active = true; const refresh = () => void window.codepilot.getPM(projectId).then((next) => active && setPMState(next)); refresh(); const timer = window.setInterval(refresh, 2000); return () => { active = false; window.clearInterval(timer); }; }, [projectId]);
async function deployTask(task: PMTask) { setError(""); try { setPMState(await window.codepilot.setPMTaskStatus(projectId, task.id, "launched")); setPoofing((items) => new Set(items).add(task.id)); await new Promise((resolve) => window.setTimeout(resolve, 900)); const result = await window.codepilot.runAgents({ projectId, objective: `${task.title}\n\ncodePilot PM task: ${task.id}\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, createPullRequest, dryRun }); setResults((current) => [result, ...current].slice(0, 5)); const nextStatus = result.status === "completed" ? "done" : "ready"; const summary = result.review.findings.length ? result.review.findings.join(" ") : `Task did not advance because the agent run returned ${result.status} with review verdict ${result.review.verdict}.`; setPMState(await window.codepilot.setPMTaskStatus(projectId, task.id, nextStatus, summary)); } catch (cause) { const reason = cause instanceof Error ? cause.message : String(cause); setError(reason); try { setPMState(await window.codepilot.setPMTaskStatus(projectId, task.id, "ready", `Task did not advance because deployment failed: ${reason}`)); } catch {} } finally { setPoofing((items) => { const next = new Set(items); next.delete(task.id); return next; }); } }
async function launch() {
@@ -118,8 +119,9 @@ export function Agents({ projectId, onState }: { projectId: string; onState(stat
set={setIntegrate}
/>
{ setCreatePullRequest(value); if (value) { setIntegrate(true); setDryRun(false); } }}
/>
@@ -154,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/main.ts b/desktop/src/main.ts
index 04e0057..29bc081 100644
--- a/desktop/src/main.ts
+++ b/desktop/src/main.ts
@@ -54,6 +54,7 @@ import {
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();
@@ -64,7 +65,7 @@ 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 {
@@ -92,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) ??
@@ -118,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");
@@ -185,12 +228,14 @@ function registerIPC() {
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("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) => {
@@ -340,6 +385,7 @@ function registerAgentIPC() {
});
ipcMain.handle("agents:run", async (_event, input: AgentRunInput) => {
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(),
@@ -380,6 +426,7 @@ function registerAgentIPC() {
});
ipcMain.handle("agents:run-batch", async (_event, input: AgentBatchRunInput) => {
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(),
diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts
index 0752c76..ac81b9c 100644
--- a/desktop/src/preload.ts
+++ b/desktop/src/preload.ts
@@ -5,6 +5,8 @@ const api: CodePilotAPI = {
getState: () => ipcRenderer.invoke("state:get"),
getGlobalSettings: () => ipcRenderer.invoke("settings:get"),
saveGlobalSettings: (settings) => ipcRenderer.invoke("settings:save", settings),
+ 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"),
diff --git a/desktop/src/renderer.tsx b/desktop/src/renderer.tsx
index 2d53661..3278eb0 100644
--- a/desktop/src/renderer.tsx
+++ b/desktop/src/renderer.tsx
@@ -80,8 +80,9 @@ function App() {
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;
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/types.ts b/desktop/src/types.ts
index bab4c29..e00dd49 100644
--- a/desktop/src/types.ts
+++ b/desktop/src/types.ts
@@ -1,6 +1,7 @@
export type Project={id:string;name:string;repo:string;createdAt: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}}
+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}
@@ -19,5 +20,5 @@ 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;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(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}
+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 be0c2f9..b449e8e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -47,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 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.
+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
diff --git a/docs/threat-model.md b/docs/threat-model.md
index 398161f..5a08d66 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -32,6 +32,8 @@ codePilot v0.1 is local developer tooling for repositories, objectives, provider
- 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
@@ -41,7 +43,7 @@ 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.
+- 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
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/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);
+});
From a86d9381d3ec299dde00ff27d327fdf9906ec485 Mon Sep 17 00:00:00 2001
From: hhuumm
Date: Fri, 28 Aug 2026 17:55:50 -0400
Subject: [PATCH 7/9] feat: embed project app preview
---
desktop/src/app-control.tsx | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/desktop/src/app-control.tsx b/desktop/src/app-control.tsx
index 2708b25..a2bcd66 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;
@@ -36,6 +37,13 @@ export function AppControl({ projectId, status, onStatus }: { projectId: string;
{status?.logs.length ? status.logs.join("") : "Start the app to see its output here."}
+
+
+ {config.url ? : No project URL configured.
}
+
;
}
From 4047d5d39795f4ece4d019910c35526167f41981 Mon Sep 17 00:00:00 2001
From: hhuumm
Date: Fri, 28 Aug 2026 17:58:10 -0400
Subject: [PATCH 8/9] refactor: prioritize live app preview
---
desktop/src/app-control.tsx | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/desktop/src/app-control.tsx b/desktop/src/app-control.tsx
index a2bcd66..f5f5666 100644
--- a/desktop/src/app-control.tsx
+++ b/desktop/src/app-control.tsx
@@ -22,6 +22,13 @@ 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"}
@@ -37,13 +44,6 @@ export function AppControl({ projectId, status, onStatus }: { projectId: string;
{status?.logs.length ? status.logs.join("") : "Start the app to see its output here."}
-
-
- {config.url ? : No project URL configured.
}
-
;
}
From 8bb0767b87ef465d6aed983f01c41aa41a77d4f4 Mon Sep 17 00:00:00 2001
From: hhuumm
Date: Fri, 28 Aug 2026 18:04:08 -0400
Subject: [PATCH 9/9] ci: streamline pull request verification
---
.github/workflows/ci.yml | 27 +++++++------------------
.github/workflows/dependency-audit.yml | 28 ++++++++++++++++++++++++++
2 files changed, 35 insertions(+), 20 deletions(-)
create mode 100644 .github/workflows/dependency-audit.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d8ba072..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@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@v7
- - uses: actions/setup-node@v7
- 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