diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a797f0da31..b8959e77946 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.1.38 + bun-version: 1.3.9 - name: Cache Bun and Turbo uses: actions/cache@v4 diff --git a/.gitignore b/.gitignore index 701ad4089f5..3ac5a88fdfa 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ node_modules *.log *.tsbuildinfo apps/*/dist -apps/*/dist-electron packages/*/dist .env .env.local diff --git a/AGENTS.md b/AGENTS.md index 7c84e207ce5..9a41abaa16b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ -# AGENTS.md +# CLAUDE.md ## Project Snapshot -CodeThing is a minimal GUI for using code agents like Codex and Claude Code (coming soon). +CodeThing is a minimal web GUI for using code agents like Codex and Claude Code (coming soon). This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged. @@ -13,17 +13,18 @@ This repository is a VERY EARLY WIP. Proposing sweeping changes that improve lon If a tradeoff is required, choose correctness and robustness over short-term convenience. ## Package Roles -- `apps/desktop`: Electron main/preload runtime. Owns provider orchestration, process/session lifecycle, and native IPC boundaries. -- `apps/renderer`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. -- `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, IPC payloads, and model/session types. +- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. +- `apps/renderer`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. +- `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. ## Codex App Server (Important) -CodeThing is currently Codex-first. The desktop app starts `codex app-server` (JSON-RPC over stdio) per provider session, then streams structured events into the renderer through the provider APIs. +CodeThing is currently Codex-first. The server starts `codex app-server` (JSON-RPC over stdio) per provider session, then streams structured events to the browser through WebSocket push messages. How we use it in this codebase: -- Session startup/resume and turn lifecycle are brokered in `apps/desktop/src/codexAppServerManager.ts`. -- Provider dispatch and thread event logging are coordinated in `apps/desktop/src/providerManager.ts`. -- Renderer consumes provider event streams via `nativeApi.providers.onEvent`. +- Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`. +- Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`. +- WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`. +- Renderer consumes provider event streams via WebSocket push on channel `providers.event`. Docs: - Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server diff --git a/README.md b/README.md index 567c2659a0b..2f80bdf9bca 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,36 @@ -# CodeThing (Electron + Vite + Bun) - -CodeThing is a desktop shell for coding agents. This first implementation is: - -1. Codex-first: connects to `codex app-server` and streams turn/item events. -2. Provider-ready: renderer speaks a provider abstraction so Claude Code can plug in later. -3. Typed end-to-end: contracts validate payloads at preload/main boundaries. +# CodeThing + +CodeThing is a minimal web GUI for coding agents. Currently Codex-first, with Claude Code support coming soon. + +Run `npx t3` in any project directory to launch the web interface. + +## Architecture + +CodeThing runs as a **Node.js WebSocket server** that wraps `codex app-server` (JSON-RPC over stdio) and serves a React web app. + +``` +┌─────────────────────────────────┐ +│ Browser (React + Vite) │ +│ Connected via WebSocket │ +└──────────┬──────────────────────┘ + │ ws://localhost:3773 +┌──────────▼──────────────────────┐ +│ apps/server (Node.js) │ +│ WebSocket + HTTP static server │ +│ ProviderManager │ +│ CodexAppServerManager │ +└──────────┬──────────────────────┘ + │ JSON-RPC over stdio +┌──────────▼──────────────────────┐ +│ codex app-server │ +└─────────────────────────────────┘ +``` ## Workspace layout -- `/apps/desktop`: Electron main + preload process, includes provider and Codex session managers. -- `/apps/renderer`: React + Vite UI for session control, conversation, and protocol event stream. -- `/packages/contracts`: shared Zod schemas + TypeScript types for IPC and provider events. +- `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built renderer, and opens the browser on start. +- `/apps/renderer`: React + Vite UI. Session control, conversation, and provider event rendering. Connects to the server via WebSocket. +- `/packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. ## Codex prerequisites @@ -18,50 +38,51 @@ CodeThing is a desktop shell for coding agents. This first implementation is: - Authenticate Codex before running CodeThing (for example via API key or ChatGPT auth supported by Codex). - CodeThing starts the server via `codex app-server` per session. -## Security and boundary model +## Quick start -- `nodeIntegration: false` -- `contextIsolation: true` -- `sandbox: true` -- Renderer talks only to `window.nativeApi` exposed by preload. -- Preload and main both validate inputs using shared Zod schemas. +```bash +# Development (with hot reload) +bun run dev -`sandbox: true` above is Electron renderer sandboxing. It is separate from Codex execution sandbox policy (`read-only`, `workspace-write`, `danger-full-access`) used when starting provider sessions. +# Production +bun run build +bun run start -## Runtime modes +# Or from any project directory after publishing: +npx t3 +``` -CodeThing has a global runtime mode switch in the sidebar: +## Scripts -- `Full access` (default): starts new sessions with `approvalPolicy: never` and `sandboxMode: danger-full-access`. -- `Approval required`: starts new sessions with `approvalPolicy: on-request` and `sandboxMode: workspace-write`, then prompts in-app for command/file approvals. +- `bun run dev` — Starts contracts, server, and web dev tasks via Turborepo's parallel task runner. +- `bun run dev:server` — Starts just the WebSocket server (uses tsx for TS execution). +- `bun run dev:web` — Starts just the Vite dev server for the renderer. +- `bun run start` — Runs the production server (serves built renderer as static files). +- `bun run build` — Builds contracts, renderer, and server through Turbo. +- `bun run typecheck` — Strict TypeScript checks for all packages. +- `bun run test` — Runs workspace tests. -Mode changes apply across all threads. Existing live sessions are restarted so old and new threads use the selected mode. +## Runtime modes -## Scripts +CodeThing has a global runtime mode switch in the chat toolbar: -- `bun run dev`: starts contract build/watch, renderer dev server, and Electron process. -- `bun run build`: builds contracts, renderer, and desktop bundles through Turbo. -- `bun run typecheck`: strict TypeScript checks for all packages. -- `bun run test`: runs workspace tests. +- **Full access** (default): starts sessions with `approvalPolicy: never` and `sandboxMode: danger-full-access`. +- **Supervised**: starts sessions with `approvalPolicy: on-request` and `sandboxMode: workspace-write`, then prompts in-app for command/file approvals. -## CI quality gates - -- `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. +## Provider architecture -Optional: +The renderer communicates with the server via WebSocket using a simple JSON-RPC-style protocol: -- `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use. +- **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` +- **Push events**: `{ type: "push", channel, data }` for streaming provider events -## Provider architecture +Methods mirror the `NativeApi` interface defined in `@acme/contracts`: +- `providers.startSession`, `providers.sendTurn`, `providers.interruptTurn` +- `providers.respondToRequest`, `providers.stopSession`, `providers.listSessions` +- `shell.openInEditor`, `server.getConfig` -The renderer now depends on `nativeApi.providers.*`: +Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. -1. `startSession` -2. `sendTurn` -3. `interruptTurn` -4. `respondToRequest` -5. `stopSession` -6. `listSessions` -7. `onEvent` +## CI quality gates -Codex is the only implemented provider right now. `claudeCode` is reserved in contracts/UI but returns a not-implemented error in main-process dispatch. +- `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. diff --git a/apps/desktop/package.json b/apps/desktop/package.json deleted file mode 100644 index b86b9c007f8..00000000000 --- a/apps/desktop/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@acme/desktop", - "version": "0.0.0", - "private": true, - "main": "dist-electron/main.js", - "scripts": { - "dev": "concurrently -k -n BUNDLE,ELECTRON \"bun run dev:bundle\" \"bun run dev:electron\"", - "dev:bundle": "tsup --watch", - "dev:electron": "bun run scripts/dev-electron.mjs", - "build": "tsup", - "start": "electron dist-electron/main.js", - "postinstall": "electron-rebuild", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "smoke-test": "node scripts/smoke-test.mjs" - }, - "dependencies": { - "@acme/contracts": "workspace:*", - "electron": "33.4.11", - "node-pty": "^1.0.0" - }, - "devDependencies": { - "concurrently": "^9.1.2", - "@electron/rebuild": "^3.7.0", - "@types/node": "^22.10.2", - "electronmon": "^2.0.2", - "tsup": "^8.3.5", - "typescript": "^5.7.3", - "wait-on": "^8.0.2" - } -} diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs deleted file mode 100644 index d0522bb4e7b..00000000000 --- a/apps/desktop/scripts/dev-electron.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { spawn } from "node:child_process"; - -import waitOn from "wait-on"; - -const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173); -const devServerUrl = `http://localhost:${port}`; - -await waitOn({ - resources: [ - `tcp:${port}`, - "file:dist-electron/main.js", - "file:dist-electron/preload.js", - ], -}); - -const command = - process.platform === "win32" ? "electronmon.cmd" : "electronmon"; -const child = spawn(command, ["dist-electron/main.js"], { - stdio: "inherit", - env: { - ...process.env, - VITE_DEV_SERVER_URL: devServerUrl, - }, -}); - -child.on("exit", (code) => { - process.exit(code ?? 0); -}); diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs deleted file mode 100644 index 62884bcde3e..00000000000 --- a/apps/desktop/scripts/smoke-test.mjs +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Smoke test: builds desktop + renderer, launches Electron against the - * production bundle, waits for the renderer to confirm it loaded, then exits. - * - * Catches the two categories of regression we've hit: - * 1. Module resolution failures (preload can't find @acme/contracts, etc.) - * 2. CSP / script blocking (React fails to mount) - * - * The test works by injecting a tiny check via ELECTRON_ENABLE_LOGGING — - * Electron forwards renderer console.log to the main process stdout when - * that env var is set. We look for React's "Download the React DevTools" - * message as proof that React successfully mounted (it only fires after - * the first render). For production mode (no React DevTools message), we - * instead check that no fatal errors appeared. - */ -import { spawn, execSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const root = resolve(__dirname, "../../.."); -const desktopDir = resolve(__dirname, ".."); -const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); -const mainJs = resolve(desktopDir, "dist-electron/main.js"); - -// ── Build first ────────────────────────────────────────────────────── -console.log("Building contracts + renderer + desktop..."); -execSync("bun run build", { cwd: root, stdio: "inherit" }); - -// ── Launch Electron (production mode — no VITE_DEV_SERVER_URL) ────── -console.log("\nLaunching Electron (production mode)..."); - -const child = spawn(electronBin, [mainJs], { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - VITE_DEV_SERVER_URL: "", // ensure production path - ELECTRON_ENABLE_LOGGING: "1", - }, -}); - -let output = ""; - -child.stdout.on("data", (d) => { - output += d.toString(); -}); -child.stderr.on("data", (d) => { - output += d.toString(); -}); - -const TIMEOUT_MS = 8_000; - -const timer = setTimeout(() => { - child.kill(); -}, TIMEOUT_MS); - -child.on("exit", () => { - clearTimeout(timer); - - // Fatal patterns that indicate broken builds - const fatalPatterns = [ - "Cannot find module", - "MODULE_NOT_FOUND", - "Refused to execute", // CSP blocking scripts - "can't detect preamble", // @vitejs/plugin-react failure - "Uncaught Error", - "Uncaught TypeError", - "Uncaught ReferenceError", - ]; - - const failures = fatalPatterns.filter((p) => output.includes(p)); - - if (failures.length > 0) { - console.error("\n❌ Smoke test FAILED. Matched fatal patterns:"); - for (const f of failures) { - console.error(` • ${f}`); - } - console.error("\nFull output:\n" + output); - process.exit(1); - } - - console.log("✅ Smoke test passed — no fatal errors detected"); - process.exit(0); -}); diff --git a/apps/desktop/src/ipcHelpers.test.ts b/apps/desktop/src/ipcHelpers.test.ts deleted file mode 100644 index ce3318f0619..00000000000 --- a/apps/desktop/src/ipcHelpers.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { withParsedArgs, withParsedPayload } from "./ipcHelpers"; - -describe("withParsedPayload", () => { - it("parses payload and passes typed input to handler", async () => { - const handler = vi.fn(async (_event: unknown, payload: { value: string }) => - payload.value.toUpperCase(), - ); - const wrapped = withParsedPayload( - { - parse(payload: unknown): { value: string } { - if ( - !payload || - typeof payload !== "object" || - typeof (payload as { value?: unknown }).value !== "string" - ) { - throw new Error("Invalid payload"); - } - - return { value: (payload as { value: string }).value }; - }, - }, - handler, - ); - - const result = await wrapped({}, { value: "hello" }); - expect(result).toBe("HELLO"); - expect(handler).toHaveBeenCalledWith({}, { value: "hello" }); - }); - - it("throws and does not call handler on invalid payload", async () => { - const handler = vi.fn(async () => "ok"); - const wrapped = withParsedPayload( - { - parse(payload: unknown): { value: string } { - if ( - !payload || - typeof payload !== "object" || - typeof (payload as { value?: unknown }).value !== "string" - ) { - throw new Error("Invalid payload"); - } - - return { value: (payload as { value: string }).value }; - }, - }, - handler, - ); - - expect(() => wrapped({}, { value: 123 })).toThrow(); - expect(handler).not.toHaveBeenCalled(); - }); -}); - -describe("withParsedArgs", () => { - it("parses tuple arguments before invoking handler", () => { - const handler = vi.fn((_event: unknown, sessionId: string, data: string) => { - return `${sessionId}:${data}`; - }); - const wrapped = withParsedArgs( - { - parse(args: unknown[]): [string, string] { - const [sessionId, data] = args; - if (typeof sessionId !== "string" || sessionId.length === 0) { - throw new Error("Invalid sessionId"); - } - if (typeof data !== "string") { - throw new Error("Invalid data"); - } - - return [sessionId, data]; - }, - }, - handler, - ); - - expect(wrapped({}, "abc", "input")).toBe("abc:input"); - expect(handler).toHaveBeenCalledWith({}, "abc", "input"); - }); - - it("throws and does not call handler when args are invalid", () => { - const handler = vi.fn(); - const wrapped = withParsedArgs( - { - parse(args: unknown[]): [string, string] { - const [sessionId, data] = args; - if (typeof sessionId !== "string" || sessionId.length === 0) { - throw new Error("Invalid sessionId"); - } - if (typeof data !== "string") { - throw new Error("Invalid data"); - } - - return [sessionId, data]; - }, - }, - handler, - ); - - expect(() => wrapped({}, 123, "input")).toThrow(); - expect(handler).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/desktop/src/ipcHelpers.ts b/apps/desktop/src/ipcHelpers.ts deleted file mode 100644 index 6383cbfef24..00000000000 --- a/apps/desktop/src/ipcHelpers.ts +++ /dev/null @@ -1,24 +0,0 @@ -type MaybePromise = T | Promise; -type Parser = { - parse: (value: unknown) => T; -}; -type ArgsParser = { - parse: (value: unknown[]) => T; -}; - -export function withParsedPayload( - schema: Parser, - handler: (event: unknown, payload: TPayload) => MaybePromise, -): (event: unknown, payload: unknown) => MaybePromise { - return (event, payload) => handler(event, schema.parse(payload)); -} - -export function withParsedArgs( - schema: ArgsParser, - handler: (event: unknown, ...args: TArgs) => MaybePromise, -): (event: unknown, ...args: unknown[]) => MaybePromise { - return (event, ...args) => { - const parsedArgs = schema.parse(args); - return handler(event, ...parsedArgs); - }; -} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts deleted file mode 100644 index 454cbe65f17..00000000000 --- a/apps/desktop/src/main.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { fixPath } from "./fixPath"; -fixPath(); - -import { spawn } from "node:child_process"; -import path from "node:path"; -import { BrowserWindow, app, dialog, ipcMain, session, shell } from "electron"; - -import { - EDITORS, - IPC_CHANNELS, - type TerminalCommandInput, - type TerminalCommandResult, - agentConfigSchema, - agentSessionIdSchema, - newTodoInputSchema, - providerInterruptTurnInputSchema, - providerRespondToRequestInputSchema, - providerSendTurnInputSchema, - providerSessionStartInputSchema, - providerStopSessionInputSchema, - terminalCommandInputSchema, - todoIdSchema, -} from "@acme/contracts"; -import { withParsedArgs, withParsedPayload } from "./ipcHelpers"; -import { ProcessManager } from "./processManager"; -import { ProviderManager } from "./providerManager"; -import { TodoStore } from "./todoStore"; - -const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); - -let todoStore: TodoStore; -const processManager = new ProcessManager(); -const providerManager = new ProviderManager(); -const agentWriteArgsParser = { - parse(args: unknown[]): [string, string] { - const [sessionId, data] = args; - if (typeof data !== "string") { - throw new Error("agent:write data must be a string"); - } - - return [agentSessionIdSchema.parse(sessionId), data]; - }, -}; - -function createWindow(): BrowserWindow { - const window = new BrowserWindow({ - width: 1100, - height: 780, - minWidth: 840, - minHeight: 620, - show: false, - autoHideMenuBar: true, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, - webPreferences: { - preload: path.join(__dirname, "preload.js"), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); - - window.once("ready-to-show", () => { - window.show(); - }); - - setupEventForwarding(window); - - if (isDevelopment) { - void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); - return window; - } - - void window.loadFile(path.join(__dirname, "../../renderer/dist/index.html")); - return window; -} - -function registerIpcHandlers(): void { - // Todo handlers - ipcMain.handle(IPC_CHANNELS.todosList, async () => { - return todoStore.list(); - }); - - ipcMain.handle( - IPC_CHANNELS.todosAdd, - withParsedPayload(newTodoInputSchema, async (_event, payload) => { - return todoStore.add(payload); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.todosToggle, - withParsedPayload(todoIdSchema, async (_event, id) => { - return todoStore.toggle(id); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.todosRemove, - withParsedPayload(todoIdSchema, async (_event, id) => { - return todoStore.remove(id); - }), - ); - - ipcMain.handle(IPC_CHANNELS.dialogPickFolder, async () => { - const owner = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; - const result = owner - ? await dialog.showOpenDialog(owner, { - properties: ["openDirectory", "createDirectory"], - }) - : await dialog.showOpenDialog({ - properties: ["openDirectory", "createDirectory"], - }); - - if (result.canceled) return null; - return result.filePaths[0] ?? null; - }); - - // Terminal handlers - ipcMain.handle( - IPC_CHANNELS.terminalRun, - withParsedPayload(terminalCommandInputSchema, async (_event, payload) => { - return runTerminalCommand(payload); - }), - ); - - // Shell handlers - ipcMain.handle( - IPC_CHANNELS.shellOpenInEditor, - async (_event, cwd: string, editor: string) => { - if (!cwd) throw new Error("cwd is required"); - const editorDef = EDITORS.find((e) => e.id === editor); - if (!editorDef) throw new Error(`Unknown editor: ${editor}`); - if (!editorDef.command) { - const error = await shell.openPath(cwd); - if (error) throw new Error(error); - return; - } - const child = spawn(editorDef.command, [cwd], { - detached: true, - stdio: "ignore", - }); - child.on("error", () => { - /* ignore spawn failures for detached editors */ - }); - child.unref(); - }, - ); - - // Agent handlers - ipcMain.handle( - IPC_CHANNELS.agentSpawn, - withParsedPayload(agentConfigSchema, async (_event, config) => { - return processManager.spawn(config); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.agentKill, - withParsedPayload(agentSessionIdSchema, async (_event, sessionId) => { - processManager.kill(sessionId); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.agentWrite, - withParsedArgs(agentWriteArgsParser, async (_event, sessionId, data) => { - processManager.write(sessionId, data); - }), - ); - - // Provider handlers - ipcMain.handle( - IPC_CHANNELS.providerSessionStart, - withParsedPayload(providerSessionStartInputSchema, async (_event, payload) => { - return providerManager.startSession(payload); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.providerTurnStart, - withParsedPayload(providerSendTurnInputSchema, async (_event, payload) => { - return providerManager.sendTurn(payload); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.providerTurnInterrupt, - withParsedPayload(providerInterruptTurnInputSchema, async (_event, payload) => { - await providerManager.interruptTurn(payload); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.providerRequestRespond, - withParsedPayload( - providerRespondToRequestInputSchema, - async (_event, payload) => { - await providerManager.respondToRequest(payload); - }, - ), - ); - - ipcMain.handle( - IPC_CHANNELS.providerSessionStop, - withParsedPayload(providerStopSessionInputSchema, async (_event, payload) => { - providerManager.stopSession(payload); - }), - ); - - ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { - return providerManager.listSessions(); - }); -} - -async function runTerminalCommand(input: TerminalCommandInput): Promise { - const shellPath = - process.platform === "win32" - ? (process.env.ComSpec ?? "cmd.exe") - : (process.env.SHELL ?? "/bin/sh"); - - const args = - process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; - - return new Promise((resolve, reject) => { - const child = spawn(shellPath, args, { - cwd: input.cwd, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let timedOut = false; - - const timeout = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - setTimeout(() => { - if (!child.killed) { - child.kill("SIGKILL"); - } - }, 1_000).unref(); - }, input.timeoutMs ?? 30_000); - - child.stdout?.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - - child.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - - child.on("error", (error) => { - clearTimeout(timeout); - reject(error); - }); - - child.on("close", (code, signal) => { - clearTimeout(timeout); - resolve({ - stdout, - stderr, - code: code ?? null, - signal: signal ?? null, - timedOut, - }); - }); - }); -} - -function setupEventForwarding(window: BrowserWindow): void { - const onOutput = (chunk: unknown) => { - if (!window.isDestroyed()) { - window.webContents.send(IPC_CHANNELS.agentOutput, chunk); - } - }; - - const onExit = (exit: unknown) => { - if (!window.isDestroyed()) { - window.webContents.send(IPC_CHANNELS.agentExit, exit); - } - }; - - const onProviderEvent = (event: unknown) => { - if (!window.isDestroyed()) { - window.webContents.send(IPC_CHANNELS.providerEvent, event); - } - }; - - processManager.on("output", onOutput); - processManager.on("exit", onExit); - providerManager.on("event", onProviderEvent); - - window.on("closed", () => { - processManager.off("output", onOutput); - processManager.off("exit", onExit); - providerManager.off("event", onProviderEvent); - }); -} - -function setupCSP(): void { - session.defaultSession.webRequest.onHeadersReceived((details, callback) => { - const csp = isDevelopment - ? "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* http://localhost:*" - : "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"; - - callback({ - responseHeaders: { - ...details.responseHeaders, - "Content-Security-Policy": [csp], - }, - }); - }); -} - -async function bootstrap(): Promise { - setupCSP(); - - todoStore = new TodoStore(path.join(app.getPath("userData"), "todos.json")); - await todoStore.init(); - - registerIpcHandlers(); - createWindow(); - - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } - }); -} - -app.on("before-quit", () => { - processManager.killAll(); - providerManager.stopAll(); - providerManager.dispose(); -}); - -app.whenReady().then(() => { - void bootstrap(); -}); - -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } -}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts deleted file mode 100644 index 11fdfe4e77f..00000000000 --- a/apps/desktop/src/preload.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { contextBridge, ipcRenderer } from "electron"; - -import { IPC_CHANNELS, type NativeApi } from "@acme/contracts"; - -const nativeApi: NativeApi = { - todos: { - list: () => ipcRenderer.invoke(IPC_CHANNELS.todosList), - add: (input) => ipcRenderer.invoke(IPC_CHANNELS.todosAdd, input), - toggle: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosToggle, id), - remove: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosRemove, id), - }, - dialogs: { - pickFolder: () => ipcRenderer.invoke(IPC_CHANNELS.dialogPickFolder), - }, - terminal: { - run: (input) => ipcRenderer.invoke(IPC_CHANNELS.terminalRun, input), - }, - agent: { - spawn: (config) => ipcRenderer.invoke(IPC_CHANNELS.agentSpawn, config), - kill: (sessionId) => ipcRenderer.invoke(IPC_CHANNELS.agentKill, sessionId), - write: (sessionId, data) => ipcRenderer.invoke(IPC_CHANNELS.agentWrite, sessionId, data), - onOutput: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, chunk: unknown) => - callback(chunk as Parameters[0]); - ipcRenderer.on(IPC_CHANNELS.agentOutput, listener); - return () => ipcRenderer.removeListener(IPC_CHANNELS.agentOutput, listener); - }, - onExit: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, exit: unknown) => - callback(exit as Parameters[0]); - ipcRenderer.on(IPC_CHANNELS.agentExit, listener); - return () => ipcRenderer.removeListener(IPC_CHANNELS.agentExit, listener); - }, - }, - providers: { - startSession: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerSessionStart, input), - sendTurn: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerTurnStart, input), - interruptTurn: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerTurnInterrupt, input), - respondToRequest: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerRequestRespond, input), - stopSession: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), - listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), - onEvent: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => - callback(payload as Parameters[0]); - ipcRenderer.on(IPC_CHANNELS.providerEvent, listener); - return () => ipcRenderer.removeListener(IPC_CHANNELS.providerEvent, listener); - }, - }, - shell: { - openInEditor: (cwd: string, editor: string) => - ipcRenderer.invoke(IPC_CHANNELS.shellOpenInEditor, cwd, editor), - }, -}; - -contextBridge.exposeInMainWorld("nativeApi", nativeApi); diff --git a/apps/desktop/src/processManager.test.ts b/apps/desktop/src/processManager.test.ts deleted file mode 100644 index ad231ef849a..00000000000 --- a/apps/desktop/src/processManager.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { ProcessManager } from "./processManager"; - -describe("ProcessManager", () => { - it("can be instantiated", () => { - const pm = new ProcessManager(); - expect(pm).toBeInstanceOf(ProcessManager); - }); - - it("spawns a process and receives output", async () => { - const pm = new ProcessManager(); - const chunks: string[] = []; - - const done = new Promise((resolve) => { - pm.on("output", (chunk) => { - chunks.push(chunk.data); - }); - pm.on("exit", () => { - resolve(); - }); - }); - - const sessionId = pm.spawn({ command: "echo", args: ["hello"] }); - expect(typeof sessionId).toBe("string"); - expect(sessionId.length).toBeGreaterThan(0); - - await done; - - expect(chunks.join("").trim()).toBe("hello"); - }); - - it("can kill a spawned process", async () => { - const pm = new ProcessManager(); - - const exited = new Promise((resolve) => { - pm.on("exit", (exit) => { - resolve(exit.code); - }); - }); - - const sessionId = pm.spawn({ command: "sleep", args: ["10"] }); - pm.kill(sessionId); - - const code = await exited; - expect(code).not.toBe(0); - }); -}); diff --git a/apps/desktop/src/processManager.ts b/apps/desktop/src/processManager.ts deleted file mode 100644 index 7297615a900..00000000000 --- a/apps/desktop/src/processManager.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { type ChildProcess, spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { EventEmitter } from "node:events"; - -import { - type AgentConfig, - type AgentExit, - type OutputChunk, - agentConfigSchema, -} from "@acme/contracts"; -import type { IPty } from "node-pty"; - -export interface ProcessManagerEvents { - output: [chunk: OutputChunk]; - exit: [exit: AgentExit]; -} - -export class ProcessManager extends EventEmitter { - private sessions = new Map(); - private ptySessions = new Map(); - - spawn(raw: AgentConfig): string { - const config = agentConfigSchema.parse(raw); - const sessionId = randomUUID(); - - if (config.usePty) { - return this.spawnPty(sessionId, config); - } - - return this.spawnProcess(sessionId, config); - } - - private spawnProcess(sessionId: string, config: AgentConfig): string { - const child = spawn(config.command, config.args, { - cwd: config.cwd, - env: config.env ? { ...process.env, ...config.env } : process.env, - stdio: ["pipe", "pipe", "pipe"], - }); - - this.sessions.set(sessionId, child); - - child.stdout?.on("data", (data: Buffer) => { - this.emit("output", { - sessionId, - stream: "stdout", - data: data.toString(), - }); - }); - - child.stderr?.on("data", (data: Buffer) => { - this.emit("output", { - sessionId, - stream: "stderr", - data: data.toString(), - }); - }); - - child.on("exit", (code, signal) => { - this.sessions.delete(sessionId); - this.emit("exit", { - sessionId, - code: code ?? null, - signal: signal ?? null, - }); - }); - - return sessionId; - } - - private spawnPty(sessionId: string, config: AgentConfig): string { - const pty = require("node-pty") as typeof import("node-pty"); - - const ptyProcess = pty.spawn(config.command, config.args, { - name: "xterm-256color", - cols: 120, - rows: 30, - cwd: config.cwd ?? process.cwd(), - env: (config.env ? { ...process.env, ...config.env } : process.env) as Record, - }); - - this.ptySessions.set(sessionId, ptyProcess); - - ptyProcess.onData((data) => { - this.emit("output", { - sessionId, - stream: "stdout", - data, - }); - }); - - ptyProcess.onExit(({ exitCode, signal }) => { - this.ptySessions.delete(sessionId); - this.emit("exit", { - sessionId, - code: exitCode, - signal: signal !== undefined ? String(signal) : null, - }); - }); - - return sessionId; - } - - write(sessionId: string, data: string): void { - const child = this.sessions.get(sessionId); - if (child) { - child.stdin?.write(data); - return; - } - - const pty = this.ptySessions.get(sessionId); - if (pty) { - pty.write(data); - return; - } - - throw new Error(`No session: ${sessionId}`); - } - - kill(sessionId: string): void { - const child = this.sessions.get(sessionId); - if (child) { - child.kill(); - return; - } - - const pty = this.ptySessions.get(sessionId); - if (pty) { - pty.kill(); - return; - } - } - - killAll(): void { - for (const child of this.sessions.values()) { - child.kill(); - } - this.sessions.clear(); - - for (const pty of this.ptySessions.values()) { - pty.kill(); - } - this.ptySessions.clear(); - } -} diff --git a/apps/desktop/src/todoStore.ts b/apps/desktop/src/todoStore.ts deleted file mode 100644 index a27ec060208..00000000000 --- a/apps/desktop/src/todoStore.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { promises as fs } from "node:fs"; -import path from "node:path"; - -import { - type NewTodoInput, - type Todo, - newTodoInputSchema, - todoIdSchema, - todoListSchema, -} from "@acme/contracts"; - -export class TodoStore { - private todos: Todo[] = []; - private queue: Promise = Promise.resolve(); - - public constructor(private readonly filePath: string) {} - - public async init(): Promise { - return this.runExclusive(async () => { - await fs.mkdir(path.dirname(this.filePath), { recursive: true }); - - try { - const raw = await fs.readFile(this.filePath, "utf8"); - const parsed = todoListSchema.safeParse(JSON.parse(raw)); - - if (parsed.success) { - this.todos = parsed.data; - return; - } - } catch (error) { - if (!isNotFoundError(error)) { - throw error; - } - } - - this.todos = []; - await this.persist(); - }); - } - - public async list(): Promise { - return this.runExclusive(async () => [...this.todos]); - } - - public async add(input: NewTodoInput): Promise { - return this.runExclusive(async () => { - const { title } = newTodoInputSchema.parse(input); - const todo: Todo = { - id: randomUUID(), - title, - completed: false, - createdAt: new Date().toISOString(), - }; - - this.todos = [todo, ...this.todos]; - await this.persist(); - return [...this.todos]; - }); - } - - public async toggle(id: string): Promise { - return this.runExclusive(async () => { - const parsedId = todoIdSchema.parse(id); - this.todos = this.todos.map((todo) => - todo.id === parsedId ? { ...todo, completed: !todo.completed } : todo, - ); - - await this.persist(); - return [...this.todos]; - }); - } - - public async remove(id: string): Promise { - return this.runExclusive(async () => { - const parsedId = todoIdSchema.parse(id); - this.todos = this.todos.filter((todo) => todo.id !== parsedId); - - await this.persist(); - return [...this.todos]; - }); - } - - private async persist(): Promise { - await fs.writeFile(this.filePath, JSON.stringify(this.todos, null, 2), "utf8"); - } - - private async runExclusive(operation: () => Promise): Promise { - const next = this.queue.then(operation, operation); - this.queue = next.then( - () => undefined, - () => undefined, - ); - - return next; - } -} - -function isNotFoundError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - (error as { code?: string }).code === "ENOENT" - ); -} diff --git a/apps/renderer/index.html b/apps/renderer/index.html index c2c8793a7c0..d8fd8a14ea6 100644 --- a/apps/renderer/index.html +++ b/apps/renderer/index.html @@ -3,7 +3,7 @@ - Electron Todo + CodeThing
diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index a525c7caebe..b7a85b35592 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -3,8 +3,11 @@ import { useEffect, useMemo, useRef } from "react"; import ChatView from "./components/ChatView"; import DiffPanel from "./components/DiffPanel"; import Sidebar from "./components/Sidebar"; +import { isElectron } from "./env"; +import { DEFAULT_MODEL } from "./model-logic"; import { readNativeApi } from "./session-logic"; import { StoreProvider, useStore } from "./store"; +import { onServerWelcome } from "./wsNativeApi"; function EventRouter() { const api = useMemo(() => readNativeApi(), []); @@ -25,6 +28,69 @@ function EventRouter() { return null; } +function AutoProjectBootstrap() { + const { state, dispatch } = useStore(); + const bootstrappedRef = useRef(false); + + useEffect(() => { + // Only relevant in browser mode — Electron doesn't send server welcome + if (isElectron) return; + + return onServerWelcome((payload) => { + if (bootstrappedRef.current) return; + + // Don't create duplicate projects for the same cwd + const existing = state.projects.find((p) => p.cwd === payload.cwd); + if (existing) { + bootstrappedRef.current = true; + // Ensure a thread is active + const existingThread = state.threads.find( + (t) => t.projectId === existing.id, + ); + if (existingThread && !state.activeThreadId) { + dispatch({ + type: "SET_ACTIVE_THREAD", + threadId: existingThread.id, + }); + } + return; + } + + bootstrappedRef.current = true; + + // Create project + thread from server cwd + const projectId = crypto.randomUUID(); + dispatch({ + type: "ADD_PROJECT", + project: { + id: projectId, + name: payload.projectName, + cwd: payload.cwd, + model: DEFAULT_MODEL, + expanded: true, + }, + }); + dispatch({ + type: "ADD_THREAD", + thread: { + id: crypto.randomUUID(), + codexThreadId: null, + projectId, + title: "New thread", + model: DEFAULT_MODEL, + session: null, + messages: [], + events: [], + error: null, + createdAt: new Date().toISOString(), + }, + }); + }); + }, [state.projects, state.threads, state.activeThreadId, dispatch]); + + return null; +} + function Layout() { const api = useMemo(() => readNativeApi(), []); const { state } = useStore(); @@ -32,10 +98,9 @@ function Layout() { if (!api) { return (
-

- Native bridge unavailable. Launch through Electron. + Connecting to CodeThing server...

@@ -45,6 +110,7 @@ function Layout() { return (
+ {state.diffOpen && } diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx index f26662e77a7..8adbc2fc502 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -14,6 +14,7 @@ import { } from "react"; import { EDITORS, type EditorId } from "@acme/contracts"; +import { isElectron } from "../env"; import { buildBootstrapInput } from "../historyBootstrap"; import { DEFAULT_MODEL, @@ -563,7 +564,7 @@ export default function ChatView() { if (!activeThread) { return (
-
+ {isElectron &&
}

Select a thread or create a new one to get started.

@@ -576,7 +577,7 @@ export default function ChatView() { return (
{/* Top bar */} -
+

{activeThread.title} diff --git a/apps/renderer/src/components/DiffPanel.tsx b/apps/renderer/src/components/DiffPanel.tsx index 344691d5c9a..6a546c848fa 100644 --- a/apps/renderer/src/components/DiffPanel.tsx +++ b/apps/renderer/src/components/DiffPanel.tsx @@ -1,3 +1,4 @@ +import { isElectron } from "../env"; import { useStore } from "../store"; export default function DiffPanel() { @@ -6,7 +7,7 @@ export default function DiffPanel() { return (