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() {

- + {agents.length > 1 && <>
+ {message &&

{message}

}
- - - + + +
App output
{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"}