From 48ed17ae287b43f398c7e3e93a8a3c6f47ef4ca3 Mon Sep 17 00:00:00 2001 From: hhuumm Date: Thu, 27 Aug 2026 21:25:56 -0400 Subject: [PATCH 01/12] feat: make delivery Git-native --- README.md | 10 +-- desktop/src/agents.tsx | 9 ++- desktop/src/main.ts | 4 ++ desktop/src/types.ts | 8 +-- docs/architecture.md | 6 +- docs/roadmap.md | 4 +- docs/threat-model.md | 5 +- src/cli.ts | 6 +- src/delivery.ts | 128 +++++++++++++++++++++++++++++++------ src/domain.ts | 5 +- src/git.ts | 36 ++++++++++- src/manager.ts | 32 ++++++++-- src/worker.ts | 1 + test/cli.test.ts | 8 +++ test/delivery.test.ts | 64 +++++++++++++++++++ test/manager.test.ts | 11 +++- test/orchestration.test.ts | 1 + 17 files changed, 294 insertions(+), 44 deletions(-) create mode 100644 test/delivery.test.ts diff --git a/README.md b/README.md index fb84a7f..542a00a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # codePilot -codePilot is a local control plane for coordinating Codex and Claude Code across Git repositories. It gives each coding agent an isolated checkout, persists its evidence, integrates eligible commits on a review branch, validates the combined result, and produces a pull-request draft—without silently changing your primary branch. +codePilot is a local control plane for coordinating Codex and Claude Code across Git repositories. It gives each coding agent an isolated checkout, persists its handoff in Git commits, integrates eligible commits on a review branch, validates the combined result, and produces a pull-request description—without silently changing your primary branch. > **Public preview (v0.1):** the local execution path works end to end and is covered by integration tests. This is still developer tooling for trusted repositories, not a hardened sandbox for hostile code or unattended production use. @@ -17,14 +17,14 @@ Coding agents are effective inside one task and one checkout. The harder problem - bounded JSONL event ingestion and SQLite-backed run history - manager-owned commits retained under `refs/codepilot/runs/...` - collision detection, integration branches, repository validation, and deterministic review -- local PR drafts under `.codepilot/runs//pull-request.md` +- Git-native task provenance and generated GitHub pull-request descriptions - an Electron desktop control plane for projects, task queues, knowledge, and live agent events ## Trust boundary Workers can run commands from a repository inside their temporary checkout. Validation commands are also user-configured shell commands. Use codePilot only with repositories and objectives you trust. -codePilot filters parent conversation identifiers and common unrelated secrets from worker environments, bounds stored events and validation output, and never pushes or opens a remote pull request. These are guardrails, not a security sandbox. Read the [threat model](docs/threat-model.md) before live use. +codePilot filters parent conversation identifiers and common unrelated secrets from worker environments and bounds stored events and validation output. Remote delivery is disabled unless a run explicitly requests `--create-pr`. These are guardrails, not a security sandbox. Read the [threat model](docs/threat-model.md) before live use. ## Requirements @@ -61,7 +61,9 @@ npm run dev -- run "implement the requested change" ` Repeat `--validate` to run multiple commands. Without explicit validation, codePilot discovers `check`, or `test`, `build`, and `lint` package scripts. Use `--no-integrate` to retain task commits and evidence without assembling a review branch. -Successful changed runs create `codepilot/`. Review that branch and its generated PR draft, then push or merge it yourself. codePilot does not mutate the checked-out primary branch or contact GitHub. +Successful changed runs create `codepilot/`. Each task becomes a manager-owned commit containing its agent outcome, claimed checks, concerns, follow-ups, and codePilot provenance. The combined run handoff is returned as `pullRequestBody`; no standalone Markdown draft is written to the project. + +Add `--create-pr` to explicitly push the integration branch and create a GitHub pull request using that generated description. Runs needing review are opened as drafts. Without this flag, codePilot does not contact GitHub. ## Desktop app diff --git a/desktop/src/agents.tsx b/desktop/src/agents.tsx index 7fe3b4c..769b67e 100644 --- a/desktop/src/agents.tsx +++ b/desktop/src/agents.tsx @@ -7,6 +7,7 @@ export function Agents({ onState }: { onState(state: DesktopState): void }) { [claude, setClaude] = useState(false), [validation, setValidation] = useState(""), [integrate, setIntegrate] = useState(true), + [createPullRequest, setCreatePullRequest] = useState(false), [dryRun, setDryRun] = useState(false), [running, setRunning] = useState(0), [error, setError] = useState(""), @@ -16,7 +17,7 @@ export function Agents({ onState }: { onState(state: DesktopState): void }) { 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: true, dryRun: false }); 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; }); } } + 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; }); } } async function launch() { const requestObjective = objective.trim(); if (!requestObjective) return; @@ -101,6 +102,7 @@ export function Agents({ onState }: { onState(state: DesktopState): void }) { • {item} ))} + {result.pullRequestUrl && Open pull request →} ))} @@ -115,6 +117,11 @@ export function Agents({ onState }: { onState(state: DesktopState): void }) { checked={integrate} set={setIntegrate} /> + { setCreatePullRequest(value); if (value) { setIntegrate(true); setDryRun(false); } }} + />

Agents never edit your current checkout. Eligible commits are diff --git a/desktop/src/main.ts b/desktop/src/main.ts index c89da0b..7447aac 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -337,6 +337,7 @@ function registerAgentIPC() { retries, timeoutMs: timeoutMinutes * 60_000, integrate: Boolean(input.integrate), + createPullRequest: Boolean(input.createPullRequest), ...(validationCommands.length ? { validationCommands } : {}), }); return { @@ -345,6 +346,7 @@ function registerAgentIPC() { ...(summary.integrationBranch ? { integrationBranch: summary.integrationBranch } : {}), + ...(summary.pullRequestUrl ? { pullRequestUrl: summary.pullRequestUrl } : {}), review: summary.review, }; }); @@ -374,12 +376,14 @@ function registerAgentIPC() { retries, timeoutMs: timeoutMinutes * 60_000, integrate: Boolean(input.integrate), + createPullRequest: Boolean(input.createPullRequest), ...(validationCommands.length ? { validationCommands } : {}), }); return { runId: summary.runId, status: summary.status, ...(summary.integrationBranch ? { integrationBranch: summary.integrationBranch } : {}), + ...(summary.pullRequestUrl ? { pullRequestUrl: summary.pullRequestUrl } : {}), review: summary.review, }; }); diff --git a/desktop/src/types.ts b/desktop/src/types.ts index c929af0..72f088a 100644 --- a/desktop/src/types.ts +++ b/desktop/src/types.ts @@ -5,9 +5,9 @@ export type KnowledgeEntry={id:string;title:string;content:string;tags:string[]; export type ChatMessage={role:'user'|'assistant';content:string;timestamp:string;operations?:number} export type KnowledgeState={entries:KnowledgeEntry[];messages:ChatMessage[]} export type ProjectBrief={path:string;content:string;exists:boolean} -export type AgentRunInput={objective:string;providers:Array<'codex'|'claude'>;retries:number;timeoutMinutes:number;validationCommands:string[];integrate:boolean;dryRun:boolean} -export type AgentBatchRunInput={objective:string;items:Array<{id:string;objective:string;provider:'codex'|'claude'}>;retries:number;timeoutMinutes:number;validationCommands:string[];integrate:boolean;dryRun:boolean} -export type AgentRunResult={runId:string;status:string;integrationBranch?:string;review:{verdict:string;findings:string[]}} +export type 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 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 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} @@ -16,7 +16,7 @@ export type TaskBin={name:'committed';hash:string;taskIds:string[];taskTitles:st export type PMTaskDetail={task:PMTask;prerequisites:Array<{id:string;title:string;status:PMTaskStatus}>;run?:{id:string;status:string;objective:string;createdAt:string;updatedAt:string};workers:Array<{taskId:string;provider:string;status:string}>;events:Array<{timestamp:string;stream:string;payload:unknown}>} export type AppConfig={command:string;workingDirectory:string;url:string} export type AppStatus={configured:boolean;running:boolean;pid?:number;startedAt?:string;config:AppConfig;logs:string[]} -export type RunDetail={id:string;objective:string;status:string;createdAt:string;updatedAt:string;summary?:{integrationBranch?:string;deliveryArtifact?:string;review?:{verdict:string;findings:string[]};collisions?:Array<{path:string;taskIds:string[]}>;validations?:Array<{command:string;exitCode:number;durationMs:number;stdout:string;stderr:string}>};workers:Array<{taskId:string;provider:string;status:string;report?:{status:string;summary:string;claimedTests:string[];concerns:string[];followUps:string[]};verification?:{changedPaths:string[];dirtyPaths:string[];resultCommit?:string;processExitCode:number};warnings:string[]}>} +export type RunDetail={id:string;objective:string;status:string;createdAt:string;updatedAt:string;summary?:{integrationBranch?:string;pullRequestBody?:string;pullRequestUrl?:string;review?:{verdict:string;findings:string[]};collisions?:Array<{path:string;taskIds:string[]}>;validations?:Array<{command:string;exitCode:number;durationMs:number;stdout:string;stderr:string}>};workers:Array<{taskId:string;provider:string;status:string;report?:{status:string;summary:string;claimedTests:string[];concerns:string[];followUps:string[]};verification?:{changedPaths:string[];dirtyPaths:string[];resultCommit?:string;processExitCode:number};warnings:string[]}>} export type NewPMTask={title:string;description:string;acceptanceCriteria?:string[];priority?:PMTask['priority'];recommendedProvider?:PMTask['recommendedProvider']} export type CodePilotAPI={getState():Promise;getGlobalSettings():Promise;saveGlobalSettings(settings:GlobalSettings):Promise;getProjectBrief():Promise;saveProjectBrief(content:string):Promise;getProjectActivity():Promise>;selectDirectory(defaultPath?:string):Promise;addProject(input:{name:string;repo?:string}):Promise;switchProject(id:string):Promise;openProjectDirectory():Promise;getAppStatus():Promise;saveAppConfig(config:AppConfig):Promise;startProjectApp():Promise;stopProjectApp():Promise;openProjectApp():Promise;getKnowledge():Promise;chatKnowledge(message:string):Promise;runAgents(input:AgentRunInput):Promise;runAgentBatch(input:AgentBatchRunInput):Promise;getActiveAgents():Promise;guideAgent(taskId:string,message:string):Promise;interruptAgent(taskId:string):Promise<{feedback:string;agents:ActiveAgent[]}>;getRunDetail(id:string):Promise;getPM():Promise;getPMTaskDetail(id:string):Promise;chatPM(message:string):Promise;clearPMChat():Promise;setPMTaskStatus(id:string,status:PMTask['status'],summary?:string):Promise;recordPMTaskFailure(id:string,summary:string):Promise;onPMUserInputRequired(listener:()=>void):()=>void;createPMTask(input:NewPMTask):Promise;commitPMTaskBin(taskIds:string[]):Promise} declare global{interface Window{codepilot:CodePilotAPI}} diff --git a/docs/architecture.md b/docs/architecture.md index da5daa4..00de033 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,7 +9,7 @@ 5. The trusted manager commits successful workspace changes, independently inspects Git state, retains a run ref, and removes the temporary checkout. 6. Eligible task commits are cherry-picked onto `codepilot/` based on the latest primary head. Repository-wide validation runs there. 7. Deterministic policy combines process, report, Git, collision, and validation evidence into a review verdict. -8. codePilot writes a local pull-request draft. Remote push, PR creation, and primary-branch mutation remain human actions. +8. codePilot builds a pull-request description from branch and validation evidence. With an explicit publication option, it pushes the integration branch and opens the GitHub pull request. ## Boundaries @@ -39,7 +39,7 @@ Leases are cooperative rather than operating-system locks. Independent filesyste ### Integration and delivery -Task commits are retained under durable Git refs. The manager assembles eligible commits on a dedicated integration branch, runs validation, and creates a PR draft. It does not stash, reset, fast-forward, push, or merge the user's primary branch. +Task commits are retained under durable Git refs. The manager normalizes each worker workspace into one provenance-rich commit, assembles eligible commits on a dedicated integration branch, runs validation, and builds the PR description directly from that evidence. It does not stash, reset, fast-forward, or merge the user's primary branch. Push and GitHub PR creation occur only when explicitly requested. ### Persistence @@ -53,4 +53,4 @@ One SQLite database owns runs, tasks, attempts, bounded events, worker results, - Validation discovery is Node-centric and custom commands intentionally use a shell. - Review is deterministic policy, not an independent semantic reviewer. - The Docker worker protocol is not yet controlled by the manager. -- Remote delivery and crash-safe resume are intentionally out of scope for v0.1. +- GitHub delivery currently depends directly on the authenticated `gh` CLI; hosting adapters, idempotent retries, and crash-safe resume are not yet implemented. diff --git a/docs/roadmap.md b/docs/roadmap.md index ba5393c..6039de5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,7 +6,7 @@ - Codex and Claude provider adapters - durable SQLite run evidence and bounded logs - cooperative file leases -- integration branches, validation, review verdicts, and PR drafts +- integration branches, validation, review verdicts, Git-native handoffs, and opt-in GitHub pull requests - Electron project and task control plane - CI, security policy, and public documentation @@ -31,6 +31,6 @@ - one initiative spanning multiple repositories with repository-specific task DAGs - commit-pinned retrieval and deliberate shared knowledge publication - independent structured semantic review behind deterministic policy -- opt-in, idempotent GitHub delivery with explicit repository and confirmation gates +- hosting-provider delivery adapters with idempotent retries and explicit repository policy gates The v1 success criterion is explainability after interruption: codePilot can reconstruct what ran, what changed, what was validated, which policy decision was made, and which external action—if any—occurred. diff --git a/docs/threat-model.md b/docs/threat-model.md index 1ac7646..4b1a223 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -16,7 +16,7 @@ codePilot v0.1 is local developer tooling for repositories, objectives, provider - The manager is trusted and owns commits, integration, persistence, and policy. - Provider processes are semi-trusted: they may edit and execute inside a temporary checkout, but their reports are advisory. - Repository code and configured validation commands are trusted by the operator. -- Remote Git hosting is outside the v0.1 execution path. +- Remote Git hosting is contacted only when the operator explicitly requests pull-request creation for a run. ## Existing mitigations @@ -39,13 +39,14 @@ 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. ## Operator guidance - Run codePilot only against repositories you trust and review objectives before launch. - Keep `.codepilot/` ignored and do not publish run databases or retained artifacts without inspection. - Use least-privilege provider and Git credentials. -- Review the integration branch and PR draft before manually pushing or merging. +- Review the integration branch and generated pull-request description before publication; review the resulting draft before merging when a run needs review. - Do not treat a `ready` verdict as a security review. Security issues in codePilot itself should be reported through the process in [`SECURITY.md`](../SECURITY.md). diff --git a/src/cli.ts b/src/cli.ts index f68a878..b24ba58 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,12 +18,13 @@ interface Options { timeoutMs: number; validationCommands?: string[]; integrate: boolean; + createPullRequest: boolean; } function usage(): never { process.stderr.write( "Usage:\n" + - " codepilot run [--repo ] [--agents codex,claude] [--retry ] [--timeout ] [--validate ] [--no-integrate] [--dry-run]\n" + + " codepilot run [--repo ] [--agents codex,claude] [--retry ] [--timeout ] [--validate ] [--no-integrate] [--create-pr] [--dry-run]\n" + " codepilot checkout acquire --owner [--repo ] [--wait ] [--lease ] -- \n" + " codepilot checkout release --owner [--repo ] [-- ]\n" + " codepilot checkout renew --owner [--repo ] [--lease ]\n" + @@ -42,12 +43,14 @@ export function parseArgs(args: string[]): Options { retries: 0, timeoutMs: 30 * 60_000, integrate: true, + createPullRequest: false, }; for (let index = 2; index < args.length; index += 1) { const arg = args[index]; if (arg === "--dry-run") options.dryRun = true; else if (arg === "--no-integrate") options.integrate = false; + else if (arg === "--create-pr") options.createPullRequest = true; else if (arg === "--retry" && args[index + 1]) { options.retries = Number.parseInt(args[++index]!, 10); if (!Number.isSafeInteger(options.retries) || options.retries < 0) usage(); @@ -64,6 +67,7 @@ export function parseArgs(args: string[]): Options { options.providers = requested as ProviderName[]; } else usage(); } + if (options.createPullRequest && (options.dryRun || !options.integrate)) usage(); return options; } diff --git a/src/delivery.ts b/src/delivery.ts index bae94d0..79c35af 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -1,23 +1,115 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; import type { RunSummary } from "./domain.js"; -export async function writePullRequestDraft( - stateDirectory: string, +const execFileAsync = promisify(execFile); +const MAX_PULL_REQUEST_BODY_CHARS = 60_000; + +/** Build the durable run handoff that is sent directly to the hosting PR. */ +export function buildPullRequestBody(objective: string, summary: RunSummary): string { + const workers = summary.taskResults.map((result, index) => { + const report = result.report; + const changedPaths = result.verification.changedPaths + .filter((path) => path !== ".codepilot-result.json") + .slice(0, 100); + return [ + `### ${index + 1}. ${result.externalId ?? result.taskId} · ${report?.status ?? "unreported"}`, + safeMarkdown(report?.summary || "The worker did not provide a valid summary.", 2_000), + `- Provider: ${result.provider}`, + `- Process: ${result.verification.processExitCode === 0 ? "completed" : `exit ${result.verification.processExitCode}`}`, + `- Commit: ${result.verification.resultCommit ? inlineCode(result.verification.resultCommit) : "None"}`, + `- Changed paths: ${changedPaths.length ? changedPaths.map(inlineCode).join(", ") : "None"}`, + report?.claimedTests.length + ? `- Claimed checks: ${report.claimedTests.slice(0, 20).map((test) => inlineCode(compact(test, 500))).join(", ")}` + : "- Claimed checks: None", + report?.concerns.length ? `- Concerns: ${safeMarkdown(report.concerns.join("; "), 1_000)}` : undefined, + report?.followUps.length ? `- Follow-ups: ${safeMarkdown(report.followUps.join("; "), 1_000)}` : undefined, + result.warnings.length ? `- Coordinator warnings: ${safeMarkdown(result.warnings.join("; "), 1_000)}` : undefined, + ].filter((line): line is string => Boolean(line)).join("\n").slice(0, 3_000); + }).join("\n\n"); + const validations = summary.validations.length + ? summary.validations.map((result) => + `- ${result.exitCode === 0 ? "✅" : "❌"} ${inlineCode(result.command)} (${result.durationMs} ms)`, + ).join("\n") + : "- Not run"; + const findings = summary.review.findings.length + ? summary.review.findings.map((finding) => `- ${safeMarkdown(finding, 2_000)}`).join("\n") + : "- No findings"; + const body = [ + "## Objective", + safeMarkdown(objective, 4_000), + "## Integration", + `- Branch: \`${summary.integrationBranch ?? "none"}\``, + `- Base commit: \`${summary.baseCommit}\``, + `- Verdict: **${summary.review.verdict}**`, + "## Agent work", + workers || "No worker results were produced.", + "## Validation", + validations, + "## Coordinator review", + findings, + "---", + "Generated by codePilot from commit and validation evidence.", + ].join("\n\n"); + return body.length <= MAX_PULL_REQUEST_BODY_CHARS + ? body + : `${body.slice(0, MAX_PULL_REQUEST_BODY_CHARS - 80)}\n\n_Description truncated by codePilot._`; +} + +function compact(value: string, max: number): string { + const normalized = value.replaceAll("\0", "").trim(); + return normalized.length <= max ? normalized : `${normalized.slice(0, max - 1)}…`; +} + +function safeMarkdown(value: string, max: number): string { + return compact(value, max) + .replaceAll("@", "@\u200b") + .replace(/\b(closes?|closed|fixes?|fixed|resolves?|resolved)(\s+#\d+)/gi, "$1\u200b$2"); +} + +function inlineCode(value: string): string { + return `\`${compact(value, 1_000).replaceAll("`", "'")}\``; +} + +export async function createGitHubPullRequest( + repo: string, + branch: string, objective: string, - summary: RunSummary, + body: string, + draft: boolean, ): Promise { - const path = join(stateDirectory, "runs", summary.runId, "pull-request.md"); - await mkdir(dirname(path), { recursive: true }); - const commits = summary.taskResults - .flatMap((result) => (result.verification.resultCommit ? [`- ${result.verification.resultCommit}`] : [])) - .join("\n"); - const validations = summary.validations - .map((result) => `- ${result.exitCode === 0 ? "PASS" : "FAIL"}: \`${result.command}\``) - .join("\n"); - await writeFile( - path, - `# ${objective}\n\nIntegration branch: \`${summary.integrationBranch ?? "none"}\`\n\n## Worker commits\n\n${commits || "- None"}\n\n## Validation\n\n${validations || "- Not run"}\n\n## Review\n\nVerdict: **${summary.review.verdict}**\n\n${summary.review.findings.map((finding) => `- ${finding}`).join("\n") || "- No findings"}\n`, - ); - return path; + await execFileAsync("gh", ["--version"]); + await execFileAsync("git", ["-C", repo, "push", "--set-upstream", "origin", branch]); + const title = objective.split(/\r?\n/, 1)[0]!.trim().slice(0, 240) || "codePilot delivery"; + const args = [ + "pr", + "create", + "--head", + branch, + "--title", + title, + "--body-file", + "-", + ...(draft ? ["--draft"] : []), + ]; + const stdout = await runWithInput("gh", args, repo, body); + const url = stdout.trim().split(/\r?\n/).reverse().find((line: string) => /^https:\/\//.test(line)); + if (!url) throw new Error(`GitHub CLI did not return a pull request URL: ${stdout.trim()}`); + return url; +} + +async function runWithInput(command: string, args: string[], cwd: string, input: string): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); }); + child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); }); + child.once("error", reject); + child.once("close", (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(`${command} exited with ${code ?? 1}: ${stderr.trim() || stdout.trim()}`)); + }); + child.stdin.end(input); + }); } diff --git a/src/domain.ts b/src/domain.ts index 0a1d9b6..32ad2df 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -57,6 +57,7 @@ export interface WorkerVerification { export interface WorkerResult { version: 0; taskId: string; + provider: ProviderName; externalId?: string; report?: WorkerReport; verification: WorkerVerification; @@ -88,6 +89,7 @@ export interface RunRequest { timeoutMs?: number; validationCommands?: string[]; integrate?: boolean; + createPullRequest?: boolean; } export interface PathCollision { @@ -114,7 +116,8 @@ export interface RunSummary { status: RunStatus; baseCommit: string; integrationBranch?: string; - deliveryArtifact?: string; + pullRequestBody?: string; + pullRequestUrl?: string; taskResults: WorkerResult[]; collisions: PathCollision[]; validations: ValidationResult[]; diff --git a/src/git.ts b/src/git.ts index d9b1b07..68d216d 100644 --- a/src/git.ts +++ b/src/git.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import { mkdir, readFile, readdir, rm } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { promisify } from "node:util"; +import type { AgentTask, WorkerReport } from "./domain.js"; const execFileAsync = promisify(execFile); @@ -43,7 +44,15 @@ export async function preserveTaskCommit(repo: string, runId: string, taskId: st await execFileAsync("git", ["-C", repo, "update-ref", `refs/codepilot/runs/${runId}/tasks/${taskId}`, commit]); } -export async function commitWorkspaceChanges(workspace: string, taskId: string): Promise { +export async function commitWorkspaceChanges( + workspace: string, + task: AgentTask, + report?: WorkerReport, +): Promise { + // Normalize whatever the worker did into one manager-owned commit. This + // preserves the complete base-to-workspace diff while making the durable + // branch history independent of whether an agent created its own commits. + await execFileAsync("git", ["-C", workspace, "reset", "--soft", task.baseCommit]); await execFileAsync("git", ["-C", workspace, "add", "--all"]); await execFileAsync("git", ["-C", workspace, "reset", "--", ".codepilot-result.json"]); try { @@ -59,12 +68,35 @@ export async function commitWorkspaceChanges(workspace: string, taskId: string): "user.email=codepilot@local", "commit", "-m", - `codepilot: task ${taskId}`, + `codepilot: task ${task.externalId ?? task.id}`, + "-m", + taskCommitBody(task, report), ]); return true; } } +function taskCommitBody(task: AgentTask, report?: WorkerReport): string { + const list = (items: string[]): string => items + .slice(0, 20) + .map((item) => `- ${item.replaceAll("\0", "").trim().slice(0, 500)}`) + .join("\n"); + const sections = [ + report?.summary?.replaceAll("\0", "").trim().slice(0, 4_000) || "Worker changes captured by the codePilot manager.", + report?.claimedTests.length + ? `Claimed validation:\n${list(report.claimedTests)}` + : "Claimed validation:\n- Not reported", + report?.concerns.length + ? `Concerns:\n${list(report.concerns)}` + : undefined, + report?.followUps.length + ? `Follow-ups:\n${list(report.followUps)}` + : undefined, + `CodePilot-Run: ${task.runId}\nCodePilot-Task: ${task.externalId ?? task.id}\nCodePilot-Provider: ${task.provider}\nCodePilot-Outcome: ${report?.status ?? "unreported"}`, + ]; + return sections.filter((section): section is string => Boolean(section)).join("\n\n"); +} + export async function cherryPick(workspace: string, commit: string): Promise { await execFileAsync("git", ["-C", workspace, "cherry-pick", commit]); } diff --git a/src/manager.ts b/src/manager.ts index 9b5e2c3..6e7605a 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -5,12 +5,13 @@ import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { detectPathCollisions } from "./collision.js"; import { clearAgentGuidance, clearAgentInterrupt, drainAgentGuidance, registerAgentInterrupt } from "./control.js"; -import { writePullRequestDraft } from "./delivery.js"; +import { buildPullRequestBody, createGitHubPullRequest } from "./delivery.js"; import type { AgentEvent, AgentTask, ProviderName, RunRequest, + RunStatus, RunSummary, WorkerResult, } from "./domain.js"; @@ -254,7 +255,7 @@ export class Manager { review.verdict = "needs_review"; review.findings.push("No validated integration branch was available for delivery."); } - const status = review.verdict === "ready" ? "completed" : review.verdict === "rejected" ? "failed" : "needs_review"; + let status: RunStatus = review.verdict === "ready" ? "completed" : review.verdict === "rejected" ? "failed" : "needs_review"; const summary: RunSummary = { version: 0, runId, @@ -266,7 +267,29 @@ export class Manager { validations, review, }; - summary.deliveryArtifact = await writePullRequestDraft(stateDirectory, request.objective, summary); + summary.pullRequestBody = buildPullRequestBody(request.objective, summary); + if (request.createPullRequest) { + if (!integrationBranch) { + review.verdict = "needs_review"; + review.findings.push("A pull request was requested, but no integration branch was available."); + } else { + try { + summary.pullRequestUrl = await createGitHubPullRequest( + repo, + integrationBranch, + request.objective, + summary.pullRequestBody, + status !== "completed", + ); + } catch (error) { + review.verdict = "needs_review"; + review.findings.push(`GitHub pull request creation failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + status = review.verdict === "ready" ? "completed" : review.verdict === "rejected" ? "failed" : "needs_review"; + summary.status = status; + summary.pullRequestBody = buildPullRequestBody(request.objective, summary); + } store.finishRun(runId, status, summary); return summary; } @@ -337,7 +360,7 @@ export class Manager { // The manager owns Git. Preserve every successful worker process's changes; // the report outcome still controls whether that commit is eligible to ship. if (!failure) { - await commitWorkspaceChanges(task.workspace, task.id); + await commitWorkspaceChanges(task.workspace, task, parsed.report); } const inspected = await inspectWorktree(task.workspace, task.baseCommit); const gitFacts = { @@ -359,6 +382,7 @@ export class Manager { const result: WorkerResult = { version: 0, taskId: task.id, + provider: task.provider, ...(task.externalId ? { externalId: task.externalId } : {}), ...(parsed.report ? { report: parsed.report } : {}), verification: { processExitCode, baseCommit: task.baseCommit, ...gitFacts }, diff --git a/src/worker.ts b/src/worker.ts index 62cb23b..34e6df2 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -22,6 +22,7 @@ const facts = await inspectWorktree(request.task.workspace, request.task.baseCom const result: WorkerResult = { version: 0, taskId: request.task.id, + provider: request.task.provider, ...(report.report ? { report: report.report } : {}), verification: { processExitCode: agentResult.exitCode, diff --git a/test/cli.test.ts b/test/cli.test.ts index 35e988b..5dddfc3 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -16,4 +16,12 @@ test("parses a dry run with multiple providers", () => { assert.equal(result.objective, "implement the feature"); assert.deepEqual(result.providers, ["codex", "claude"]); assert.equal(result.dryRun, true); + assert.equal(result.createPullRequest, false); +}); + +test("parses explicit GitHub pull request publication", () => { + const result = parseArgs(["run", "ship the feature", "--create-pr"]); + assert.equal(result.createPullRequest, true); + assert.equal(result.integrate, true); + assert.equal(result.dryRun, false); }); diff --git a/test/delivery.test.ts b/test/delivery.test.ts new file mode 100644 index 0000000..ea0cb7b --- /dev/null +++ b/test/delivery.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { RunSummary } from "../src/domain.js"; +import { buildPullRequestBody } from "../src/delivery.js"; + +test("builds a pull request body from worker, Git, validation, and review evidence", () => { + const summary: RunSummary = { + version: 0, + runId: "run-1", + status: "completed", + baseCommit: "base-commit", + integrationBranch: "codepilot/run-1", + taskResults: [{ + version: 0, + taskId: "task-1", + provider: "codex", + report: { + version: 0, + status: "completed", + summary: "Implemented the feature.", + claimedTests: ["npm test"], + concerns: [], + followUps: ["Monitor the rollout."], + }, + verification: { + processExitCode: 0, + baseCommit: "base-commit", + headCommit: "result-commit", + resultCommit: "result-commit", + changedPaths: ["src/feature.ts"], + dirtyPaths: [], + commitDescendsFromBase: true, + }, + warnings: [], + }], + collisions: [], + validations: [{ command: "npm test", exitCode: 0, durationMs: 42, stdout: "", stderr: "" }], + review: { verdict: "ready", findings: [] }, + }; + + const body = buildPullRequestBody("Ship the feature", summary); + assert.match(body, /Ship the feature/); + assert.match(body, /Implemented the feature/); + assert.match(body, /src\/feature\.ts/); + assert.match(body, /npm test/); + assert.match(body, /Monitor the rollout/); +}); + +test("neutralizes unintended mentions and issue-closing directives in generated prose", () => { + const summary: RunSummary = { + version: 0, + runId: "run-2", + status: "needs_review", + baseCommit: "base", + taskResults: [], + collisions: [], + validations: [], + review: { verdict: "needs_review", findings: ["Ask @maintainer; fixes #123"] }, + }; + const body = buildPullRequestBody("Notify @team and closes #9", summary); + assert.doesNotMatch(body, /@team/); + assert.doesNotMatch(body, /closes #9/i); + assert.doesNotMatch(body, /fixes #123/i); +}); diff --git a/test/manager.test.ts b/test/manager.test.ts index 94ab2f0..d4fdd27 100644 --- a/test/manager.test.ts +++ b/test/manager.test.ts @@ -141,7 +141,7 @@ class PrimaryMovingFixtureProvider implements ProviderAdapter { } } -test("runs a worker, preserves its commit, integrates it, and writes a delivery artifact", async () => { +test("runs a worker, preserves its commit, and builds a Git-native delivery handoff", async () => { const root = await mkdtemp(join(tmpdir(), "codepilot-manager-")); const repo = join(root, "repo"); await execFileAsync("git", ["init", repo]); @@ -165,9 +165,16 @@ test("runs a worker, preserves its commit, integrates it, and writes a delivery assert.equal(summary.review.verdict, "ready"); assert.match(summary.integrationBranch!, /^codepilot\//); assert.equal(summary.taskResults[0]?.verification.changedPaths[0], "result.txt"); - await access(summary.deliveryArtifact!); + assert.match(summary.pullRequestBody ?? "", /Added the fixture result/); + assert.match(summary.pullRequestBody ?? "", /result\.txt/); const { stdout } = await execFileAsync("git", ["-C", repo, "show", `${summary.integrationBranch}:result.txt`]); assert.equal(stdout, "implemented\n"); + const { stdout: commitMessage } = await execFileAsync("git", ["-C", repo, "log", "-1", "--format=%B", summary.integrationBranch!]); + assert.match(commitMessage, /CodePilot-Run:/); + assert.match(commitMessage, /CodePilot-Provider: codex/); + const { stdout: commitCount } = await execFileAsync("git", ["-C", repo, "rev-list", "--count", `HEAD..${summary.integrationBranch}`]); + assert.equal(commitCount.trim(), "1"); + await assert.rejects(access(join(repo, ".codepilot", "runs", summary.runId, "pull-request.md"))); await assert.rejects(execFileAsync("git", ["-C", repo, "show", "HEAD:result.txt"])); assert.equal(resolve(provider.lastTask?.coordinationRepo ?? ""), resolve(repo)); assert.ok(provider.lastTask?.checkoutCommand); diff --git a/test/orchestration.test.ts b/test/orchestration.test.ts index 1db74ce..9a8f7e4 100644 --- a/test/orchestration.test.ts +++ b/test/orchestration.test.ts @@ -22,6 +22,7 @@ test("detects changed-path collisions", () => { const makeResult = (taskId: string, changedPaths: string[]): WorkerResult => ({ version: 0, taskId, + provider: "codex", verification: { processExitCode: 0, baseCommit: "base", From 91d81d8de53c6706b0cb480eb852d0fdb5eefce9 Mon Sep 17 00:00:00 2001 From: hhuumm Date: Thu, 27 Aug 2026 21:27:35 -0400 Subject: [PATCH 02/12] test: make CI environment-independent --- .github/workflows/ci.yml | 8 ++++---- test/manager.test.ts | 12 +++++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69e5e9f..d8ba072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,8 @@ jobs: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 22 cache: npm @@ -30,8 +30,8 @@ jobs: run: working-directory: desktop steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 22 cache: npm diff --git a/test/manager.test.ts b/test/manager.test.ts index d4fdd27..7372e30 100644 --- a/test/manager.test.ts +++ b/test/manager.test.ts @@ -19,7 +19,17 @@ class FixtureProvider implements ProviderAdapter { this.lastTask = task; await writeFile(join(task.workspace, "result.txt"), "implemented\n"); await execFileAsync("git", ["-C", task.workspace, "add", "result.txt"]); - await execFileAsync("git", ["-C", task.workspace, "commit", "-m", "worker result"]); + await execFileAsync("git", [ + "-C", + task.workspace, + "-c", + "user.name=Fixture Worker", + "-c", + "user.email=fixture@example.com", + "commit", + "-m", + "worker result", + ]); await writeFile( task.reportPath, JSON.stringify({ From 2c5114187f6d9a9fed5d33ba9d3fce7995ab1da7 Mon Sep 17 00:00:00 2001 From: hhuumm Date: Thu, 27 Aug 2026 21:29:08 -0400 Subject: [PATCH 03/12] test: compare canonical repository paths --- test/manager.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/manager.test.ts b/test/manager.test.ts index 7372e30..6c98ed6 100644 --- a/test/manager.test.ts +++ b/test/manager.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { access, mkdtemp, readFile, realpath, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; @@ -186,7 +186,10 @@ test("runs a worker, preserves its commit, and builds a Git-native delivery hand assert.equal(commitCount.trim(), "1"); await assert.rejects(access(join(repo, ".codepilot", "runs", summary.runId, "pull-request.md"))); await assert.rejects(execFileAsync("git", ["-C", repo, "show", "HEAD:result.txt"])); - assert.equal(resolve(provider.lastTask?.coordinationRepo ?? ""), resolve(repo)); + assert.equal( + (await realpath(resolve(provider.lastTask?.coordinationRepo ?? ""))).toLowerCase(), + (await realpath(resolve(repo))).toLowerCase(), + ); assert.ok(provider.lastTask?.checkoutCommand); }); From 2e2979083b59eb4054a2881cd51559018b764fa5 Mon Sep 17 00:00:00 2001 From: hhuumm Date: Fri, 28 Aug 2026 09:00:57 -0400 Subject: [PATCH 04/12] 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"}