diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a797f0da310bdd3e6f827e3f30ff3d762680563..5e2cc265ccdab73c8b806fd18fe45f053ee62803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: quality: - name: Lint, Typecheck, Test + name: Lint, Typecheck, Test, Build runs-on: ubuntu-latest steps: - name: Checkout @@ -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 @@ -40,3 +40,11 @@ jobs: - name: Test run: bun run test + + - name: Build desktop pipeline + run: bun run build:desktop + + - name: Verify preload bundle output + run: | + test -f apps/desktop/dist-electron/preload.js + grep -nE "desktopBridge|getWsUrl|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js diff --git a/.gitignore b/.gitignore index 701ad4089f55fdb0b000940f882b50036edd2fc6..3ac5a88fdfab3c0e19a87842855744a7b7640c3f 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 7c84e207ce5c28d2bdcf18d43bdc9285f9bf7bb7..9a41abaa16b23ca59b789ce30ae46c8691be3847 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 567c2659a0b84c950587e7bfb5010f1490078eb4..dfb178da96d413e341e19cc3ad8e0f0b38b2b796 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,38 @@ -# CodeThing (Electron + Vite + Bun) +# CodeThing -CodeThing is a desktop shell for coding agents. This first implementation is: +CodeThing is a minimal web GUI for coding agents. Currently Codex-first, with Claude Code support coming soon. -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. +Run `npx t3` in any project directory to launch the web interface. +Run `bun run dev:desktop` to launch the Electron desktop app in this monorepo. + +## 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. +- `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared renderer. +- `/packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. ## Codex prerequisites @@ -18,50 +40,55 @@ 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. +# Desktop development +bun run dev:desktop -## Runtime modes +# Production +bun run build +bun run start -CodeThing has a global runtime mode switch in the sidebar: - -- `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. - -Mode changes apply across all threads. Existing live sessions are restarted so old and new threads use the selected mode. +# Or from any project directory after publishing: +npx t3 +``` ## Scripts -- `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. +- `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. + +## Runtime modes + +CodeThing has a global runtime mode switch in the chat toolbar: + +- **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. + +## Provider architecture + +The renderer communicates with the server via WebSocket using a simple JSON-RPC-style protocol: + +- **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` +- **Push events**: `{ type: "push", channel, data }` for streaming provider events + +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` + +Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. ## 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`. - -Optional: - -- `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use. - -## Provider architecture - -The renderer now depends on `nativeApi.providers.*`: - -1. `startSession` -2. `sendTurn` -3. `interruptTurn` -4. `respondToRequest` -5. `stopSession` -6. `listSessions` -7. `onEvent` - -Codex is the only implemented provider right now. `claudeCode` is reserved in contracts/UI but returns a not-implemented error in main-process dispatch. diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..acb4535b60e5107bc2fac12705d43e40b23c2d18 --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1 @@ +dist-electron/ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b86b9c007f8e4e466b5309c3a03b7d5b657b69a0..6553189210056e012a1350552fd38bd6dca7b571 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -4,28 +4,25 @@ "private": true, "main": "dist-electron/main.js", "scripts": { - "dev": "concurrently -k -n BUNDLE,ELECTRON \"bun run dev:bundle\" \"bun run dev:electron\"", + "dev": "bun run --cwd ../server build && concurrently -k -n BUNDLE,RENDERER,ELECTRON \"bun run dev:bundle\" \"bun run --cwd ../renderer dev\" \"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", + "test": "vitest run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" }, "dependencies": { - "@acme/contracts": "workspace:*", - "electron": "33.4.11", - "node-pty": "^1.0.0" + "electron": "33.4.11" }, "devDependencies": { - "concurrently": "^9.1.2", - "@electron/rebuild": "^3.7.0", "@types/node": "^22.10.2", + "concurrently": "^9.1.2", "electronmon": "^2.0.2", "tsup": "^8.3.5", "typescript": "^5.7.3", + "vitest": "^3.0.0", "wait-on": "^8.0.2" } } diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index d0522bb4e7bf9adb0d35df8bbf86e7e1233ed8bd..0c49d1e58681b3e2a666a4d44d4495032897e33b 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -10,11 +10,11 @@ await waitOn({ `tcp:${port}`, "file:dist-electron/main.js", "file:dist-electron/preload.js", + "file:../server/dist/index.js", ], }); -const command = - process.platform === "win32" ? "electronmon.cmd" : "electronmon"; +const command = process.platform === "win32" ? "electronmon.cmd" : "electronmon"; const child = spawn(command, ["dist-electron/main.js"], { stdio: "inherit", env: { diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 62884bcde3eed6134d9edfdb845671a2028b5a86..cc13d469315277e9cc1f60f1fac85835eadb4158 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -1,84 +1,61 @@ -/** - * 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 { execSync, spawn } 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 rootDir = 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" }); +console.log("Building contracts + renderer + server + desktop..."); +execSync("bun run build", { cwd: rootDir, stdio: "inherit" }); -// ── Launch Electron (production mode — no VITE_DEV_SERVER_URL) ────── -console.log("\nLaunching Electron (production mode)..."); +console.log("\nLaunching Electron smoke test..."); const child = spawn(electronBin, [mainJs], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, - VITE_DEV_SERVER_URL: "", // ensure production path + VITE_DEV_SERVER_URL: "", ELECTRON_ENABLE_LOGGING: "1", }, }); let output = ""; - -child.stdout.on("data", (d) => { - output += d.toString(); +child.stdout.on("data", (chunk) => { + output += chunk.toString(); }); -child.stderr.on("data", (d) => { - output += d.toString(); +child.stderr.on("data", (chunk) => { + output += chunk.toString(); }); -const TIMEOUT_MS = 8_000; - -const timer = setTimeout(() => { +const timeout = setTimeout(() => { child.kill(); -}, TIMEOUT_MS); +}, 8_000); child.on("exit", () => { - clearTimeout(timer); + clearTimeout(timeout); - // 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 + "Refused to execute", "Uncaught Error", "Uncaught TypeError", "Uncaught ReferenceError", ]; - - const failures = fatalPatterns.filter((p) => output.includes(p)); + const failures = fatalPatterns.filter((pattern) => output.includes(pattern)); if (failures.length > 0) { - console.error("\n❌ Smoke test FAILED. Matched fatal patterns:"); - for (const f of failures) { - console.error(` • ${f}`); + console.error("\nDesktop smoke test failed:"); + for (const failure of failures) { + console.error(` - ${failure}`); } console.error("\nFull output:\n" + output); process.exit(1); } - console.log("✅ Smoke test passed — no fatal errors detected"); + console.log("Desktop smoke test passed."); process.exit(0); }); diff --git a/apps/desktop/src/codexAppServerManager.test.ts b/apps/desktop/src/codexAppServerManager.test.ts deleted file mode 100644 index b611e4d4de4d62389902dd6bd7450becc468749f..0000000000000000000000000000000000000000 --- a/apps/desktop/src/codexAppServerManager.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - classifyCodexStderrLine, - isRecoverableThreadResumeError, - normalizeCodexModelSlug, -} from "./codexAppServerManager"; - -describe("classifyCodexStderrLine", () => { - it("ignores empty lines", () => { - expect(classifyCodexStderrLine(" ")).toBeNull(); - }); - - it("ignores non-error structured codex logs", () => { - const line = - "2026-02-08T04:24:19.241256Z WARN codex_core::features: unknown feature key in config: skills"; - expect(classifyCodexStderrLine(line)).toBeNull(); - }); - - it("ignores known benign rollout path errors", () => { - const line = - "\u001b[2m2026-02-08T04:24:20.085687Z\u001b[0m \u001b[31mERROR\u001b[0m \u001b[2mcodex_core::rollout::list\u001b[0m: state db missing rollout path for thread 019c3b6c-46b8-7b70-ad23-82f824d161fb"; - expect(classifyCodexStderrLine(line)).toBeNull(); - }); - - it("keeps unknown structured errors", () => { - const line = "2026-02-08T04:24:20.085687Z ERROR codex_core::runtime: unrecoverable failure"; - expect(classifyCodexStderrLine(line)).toEqual({ - message: line, - }); - }); - - it("keeps plain stderr messages", () => { - const line = "fatal: permission denied"; - expect(classifyCodexStderrLine(line)).toEqual({ - message: line, - }); - }); -}); - -describe("normalizeCodexModelSlug", () => { - it("maps 5.3 aliases to gpt-5.3-codex", () => { - expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex"); - expect(normalizeCodexModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); - }); - - it("prefers codex id when model differs", () => { - expect(normalizeCodexModelSlug("gpt-5.3", "gpt-5.3-codex")).toBe("gpt-5.3-codex"); - }); - - it("keeps non-aliased models as-is", () => { - expect(normalizeCodexModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); - expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2"); - }); -}); - -describe("isRecoverableThreadResumeError", () => { - it("matches not-found resume errors", () => { - expect( - isRecoverableThreadResumeError( - new Error("thread/resume failed: thread not found"), - ), - ).toBe(true); - }); - - it("ignores non-resume errors", () => { - expect( - isRecoverableThreadResumeError( - new Error("thread/start failed: permission denied"), - ), - ).toBe(false); - }); - - it("ignores non-recoverable resume errors", () => { - expect( - isRecoverableThreadResumeError( - new Error("thread/resume failed: timed out waiting for server"), - ), - ).toBe(false); - }); -}); diff --git a/apps/desktop/src/codexAppServerManager.ts b/apps/desktop/src/codexAppServerManager.ts deleted file mode 100644 index 1564883e6515eb6f39f22e3e1be247318b63a8b0..0000000000000000000000000000000000000000 --- a/apps/desktop/src/codexAppServerManager.ts +++ /dev/null @@ -1,847 +0,0 @@ -import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { EventEmitter } from "node:events"; -import readline from "node:readline"; - -import type { - ProviderApprovalDecision, - ProviderEvent, - ProviderRequestKind, - ProviderSendTurnInput, - ProviderSession, - ProviderSessionStartInput, - ProviderTurnStartResult, -} from "@acme/contracts"; -import { normalizeModelSlug } from "@acme/contracts"; - -type PendingRequestKey = string; - -interface PendingRequest { - method: string; - timeout: ReturnType; - resolve: (value: unknown) => void; - reject: (error: Error) => void; -} - -interface PendingApprovalRequest { - requestId: string; - jsonRpcId: string | number; - method: - | "item/commandExecution/requestApproval" - | "item/fileChange/requestApproval"; - requestKind: ProviderRequestKind; - threadId?: string; - turnId?: string; - itemId?: string; -} - -interface CodexSessionContext { - session: ProviderSession; - child: ChildProcessWithoutNullStreams; - output: readline.Interface; - pending: Map; - pendingApprovals: Map; - nextRequestId: number; - stopping: boolean; -} - -interface JsonRpcError { - code?: number; - message?: string; -} - -interface JsonRpcRequest { - id: string | number; - method: string; - params?: unknown; -} - -interface JsonRpcResponse { - id: string | number; - result?: unknown; - error?: JsonRpcError; -} - -interface JsonRpcNotification { - method: string; - params?: unknown; -} - -const ANSI_ESCAPE_CHAR = String.fromCharCode(27); -const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); -const CODEX_STDERR_LOG_REGEX = - /^\d{4}-\d{2}-\d{2}T\S+\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+\S+:\s+(.*)$/; -const BENIGN_ERROR_LOG_SNIPPETS = [ - "state db missing rollout path for thread", - "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", -]; -const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ - "not found", - "missing thread", - "no such thread", - "unknown thread", - "does not exist", -]; - -export function normalizeCodexModelSlug( - model: string | undefined | null, - preferredId?: string, -): string | undefined { - const normalized = normalizeModelSlug(model); - if (!normalized) { - return undefined; - } - - if (preferredId?.endsWith("-codex") && preferredId !== normalized) { - return preferredId; - } - - return normalized; -} - -export function classifyCodexStderrLine(rawLine: string): { message: string } | null { - const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); - if (!line) { - return null; - } - - const match = line.match(CODEX_STDERR_LOG_REGEX); - if (match) { - const level = match[1]; - if (level && level !== "ERROR") { - return null; - } - - const isBenignError = BENIGN_ERROR_LOG_SNIPPETS.some((snippet) => line.includes(snippet)); - if (isBenignError) { - return null; - } - } - - return { message: line }; -} - -export function isRecoverableThreadResumeError(error: unknown): boolean { - const message = ( - error instanceof Error ? error.message : String(error) - ).toLowerCase(); - if (!message.includes("thread/resume")) { - return false; - } - - return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => - message.includes(snippet), - ); -} - -export interface CodexAppServerManagerEvents { - event: [event: ProviderEvent]; -} - -export class CodexAppServerManager extends EventEmitter { - private readonly sessions = new Map(); - - async startSession(input: ProviderSessionStartInput): Promise { - const sessionId = randomUUID(); - const now = new Date().toISOString(); - - const session: ProviderSession = { - sessionId, - provider: "codex", - status: "connecting", - model: normalizeCodexModelSlug(input.model), - cwd: input.cwd, - createdAt: now, - updatedAt: now, - }; - - const child = spawn("codex", ["app-server"], { - cwd: input.cwd, - env: process.env, - stdio: ["pipe", "pipe", "pipe"], - }); - const output = readline.createInterface({ input: child.stdout }); - - const context: CodexSessionContext = { - session, - child, - output, - pending: new Map(), - pendingApprovals: new Map(), - nextRequestId: 1, - stopping: false, - }; - - this.sessions.set(sessionId, context); - this.attachProcessListeners(context); - - this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); - - try { - await this.sendRequest(context, "initialize", { - clientInfo: { - name: "codething_desktop", - title: "CodeThing Desktop", - version: "0.1.0", - }, - capabilities: { - experimentalApi: false, - }, - }); - - this.writeMessage(context, { method: "initialized" }); - - const normalizedModel = normalizeCodexModelSlug(input.model); - const sessionOverrides = { - model: normalizedModel ?? null, - cwd: input.cwd ?? null, - approvalPolicy: input.approvalPolicy, - sandbox: input.sandboxMode, - }; - - const threadStartParams = { - ...sessionOverrides, - experimentalRawEvents: false, - }; - - let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start"; - let threadOpenResponse: unknown; - if (input.resumeThreadId) { - try { - threadOpenMethod = "thread/resume"; - threadOpenResponse = await this.sendRequest( - context, - "thread/resume", - { - ...sessionOverrides, - threadId: input.resumeThreadId, - }, - ); - } catch (error) { - if (!isRecoverableThreadResumeError(error)) { - throw error; - } - - threadOpenMethod = "thread/start"; - this.emitLifecycleEvent( - context, - "session/threadResumeFallback", - `Could not resume thread ${input.resumeThreadId}; started a new thread instead.`, - ); - threadOpenResponse = await this.sendRequest( - context, - "thread/start", - threadStartParams, - ); - } - } else { - threadOpenMethod = "thread/start"; - threadOpenResponse = await this.sendRequest( - context, - "thread/start", - threadStartParams, - ); - } - - const threadOpenRecord = this.readObject(threadOpenResponse); - const threadId = - this.readString(this.readObject(threadOpenRecord, "thread"), "id") ?? - this.readString(threadOpenRecord, "threadId"); - if (!threadId) { - throw new Error( - `${threadOpenMethod} response did not include a thread id.`, - ); - } - - this.updateSession(context, { - status: "ready", - threadId, - }); - this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`); - return { ...context.session }; - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to start Codex session."; - this.updateSession(context, { - status: "error", - lastError: message, - }); - this.emitErrorEvent(context, "session/startFailed", message); - this.stopSession(sessionId); - throw new Error(message, { cause: error }); - } - } - - async sendTurn(input: ProviderSendTurnInput): Promise { - const context = this.requireSession(input.sessionId); - if (!context.session.threadId) { - throw new Error("Session is missing a thread id."); - } - - const turnStartParams: { - threadId: string; - input: Array<{ type: "text"; text: string; text_elements: [] }>; - model?: string; - effort?: string; - } = { - threadId: context.session.threadId, - input: [ - { - type: "text", - text: input.input, - text_elements: [], - }, - ], - }; - const normalizedModel = normalizeCodexModelSlug(input.model); - if (normalizedModel) { - turnStartParams.model = normalizedModel; - } - if (input.effort) { - turnStartParams.effort = input.effort; - } - - const response = await this.sendRequest(context, "turn/start", turnStartParams); - - const turn = this.readObject(this.readObject(response), "turn"); - const turnId = this.readString(turn, "id"); - if (!turnId) { - throw new Error("turn/start response did not include a turn id."); - } - - this.updateSession(context, { - status: "running", - activeTurnId: turnId, - }); - - return { - threadId: context.session.threadId, - turnId, - }; - } - - async interruptTurn(sessionId: string, turnId?: string): Promise { - const context = this.requireSession(sessionId); - const effectiveTurnId = turnId ?? context.session.activeTurnId; - - if (!effectiveTurnId || !context.session.threadId) { - return; - } - - await this.sendRequest(context, "turn/interrupt", { - threadId: context.session.threadId, - turnId: effectiveTurnId, - }); - } - - async respondToRequest( - sessionId: string, - requestId: string, - decision: ProviderApprovalDecision, - ): Promise { - const context = this.requireSession(sessionId); - const pendingRequest = context.pendingApprovals.get(requestId); - if (!pendingRequest) { - throw new Error(`Unknown pending approval request: ${requestId}`); - } - - context.pendingApprovals.delete(requestId); - this.writeMessage(context, { - id: pendingRequest.jsonRpcId, - result: { - decision, - }, - }); - - this.emitEvent({ - id: randomUUID(), - kind: "notification", - provider: "codex", - sessionId: context.session.sessionId, - createdAt: new Date().toISOString(), - method: "item/requestApproval/decision", - threadId: pendingRequest.threadId, - turnId: pendingRequest.turnId, - itemId: pendingRequest.itemId, - requestId: pendingRequest.requestId, - requestKind: pendingRequest.requestKind, - payload: { - requestId: pendingRequest.requestId, - requestKind: pendingRequest.requestKind, - decision, - }, - }); - } - - stopSession(sessionId: string): void { - const context = this.sessions.get(sessionId); - if (!context) { - return; - } - - context.stopping = true; - - for (const pending of context.pending.values()) { - clearTimeout(pending.timeout); - pending.reject(new Error("Session stopped before request completed.")); - } - context.pending.clear(); - context.pendingApprovals.clear(); - - context.output.close(); - - if (!context.child.killed) { - context.child.kill(); - } - - this.updateSession(context, { - status: "closed", - activeTurnId: undefined, - }); - this.emitLifecycleEvent(context, "session/closed", "Session stopped"); - this.sessions.delete(sessionId); - } - - listSessions(): ProviderSession[] { - return Array.from(this.sessions.values(), ({ session }) => ({ - ...session, - })); - } - - hasSession(sessionId: string): boolean { - return this.sessions.has(sessionId); - } - - stopAll(): void { - for (const sessionId of this.sessions.keys()) { - this.stopSession(sessionId); - } - } - - private requireSession(sessionId: string): CodexSessionContext { - const context = this.sessions.get(sessionId); - if (!context) { - throw new Error(`Unknown session: ${sessionId}`); - } - - if (context.session.status === "closed") { - throw new Error(`Session is closed: ${sessionId}`); - } - - return context; - } - - private attachProcessListeners(context: CodexSessionContext): void { - context.output.on("line", (line) => { - this.handleStdoutLine(context, line); - }); - - context.child.stderr.on("data", (chunk: Buffer) => { - const raw = chunk.toString(); - const lines = raw.split(/\r?\n/g); - for (const rawLine of lines) { - const classified = classifyCodexStderrLine(rawLine); - if (!classified) { - continue; - } - - this.emitErrorEvent(context, "process/stderr", classified.message); - } - }); - - context.child.on("error", (error) => { - const message = error.message || "codex app-server process errored."; - this.updateSession(context, { - status: "error", - lastError: message, - }); - this.emitErrorEvent(context, "process/error", message); - }); - - context.child.on("exit", (code, signal) => { - if (context.stopping) { - return; - } - - const message = `codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`; - this.updateSession(context, { - status: "closed", - activeTurnId: undefined, - lastError: code === 0 ? context.session.lastError : message, - }); - this.emitLifecycleEvent(context, "session/exited", message); - this.sessions.delete(context.session.sessionId); - }); - } - - private handleStdoutLine(context: CodexSessionContext, line: string): void { - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - this.emitErrorEvent( - context, - "protocol/parseError", - "Received invalid JSON from codex app-server.", - ); - return; - } - - if (!parsed || typeof parsed !== "object") { - this.emitErrorEvent( - context, - "protocol/invalidMessage", - "Received non-object protocol message.", - ); - return; - } - - if (this.isServerRequest(parsed)) { - this.handleServerRequest(context, parsed); - return; - } - - if (this.isServerNotification(parsed)) { - this.handleServerNotification(context, parsed); - return; - } - - if (this.isResponse(parsed)) { - this.handleResponse(context, parsed); - return; - } - - this.emitErrorEvent( - context, - "protocol/unrecognizedMessage", - "Received protocol message in an unknown shape.", - ); - } - - private handleServerNotification( - context: CodexSessionContext, - notification: JsonRpcNotification, - ): void { - const route = this.readRouteFields(notification.params); - const textDelta = - notification.method === "item/agentMessage/delta" - ? this.readString(notification.params, "delta") - : undefined; - - this.emitEvent({ - id: randomUUID(), - kind: "notification", - provider: "codex", - sessionId: context.session.sessionId, - createdAt: new Date().toISOString(), - method: notification.method, - threadId: route.threadId, - turnId: route.turnId, - itemId: route.itemId, - textDelta, - payload: notification.params, - }); - - if (notification.method === "thread/started") { - const threadId = this.readString(this.readObject(notification.params)?.thread, "id"); - if (threadId) { - this.updateSession(context, { threadId }); - } - return; - } - - if (notification.method === "turn/started") { - const turnId = this.readString(this.readObject(notification.params)?.turn, "id"); - this.updateSession(context, { - status: "running", - activeTurnId: turnId, - }); - return; - } - - if (notification.method === "turn/completed") { - const turn = this.readObject(notification.params, "turn"); - const status = this.readString(turn, "status"); - const errorMessage = this.readString(this.readObject(turn, "error"), "message"); - this.updateSession(context, { - status: status === "failed" ? "error" : "ready", - activeTurnId: undefined, - lastError: errorMessage ?? context.session.lastError, - }); - return; - } - - if (notification.method === "error") { - const message = this.readString(this.readObject(notification.params)?.error, "message"); - const willRetry = this.readBoolean(notification.params, "willRetry"); - - this.updateSession(context, { - status: willRetry ? "running" : "error", - lastError: message ?? context.session.lastError, - }); - } - } - - private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void { - const route = this.readRouteFields(request.params); - const requestKind = this.requestKindForMethod(request.method); - let requestId: string | undefined; - if (requestKind) { - requestId = randomUUID(); - const pendingRequest: PendingApprovalRequest = { - requestId, - jsonRpcId: request.id, - method: - requestKind === "command" - ? "item/commandExecution/requestApproval" - : "item/fileChange/requestApproval", - requestKind, - ...(route.threadId ? { threadId: route.threadId } : {}), - ...(route.turnId ? { turnId: route.turnId } : {}), - ...(route.itemId ? { itemId: route.itemId } : {}), - }; - context.pendingApprovals.set(requestId, pendingRequest); - } - - this.emitEvent({ - id: randomUUID(), - kind: "request", - provider: "codex", - sessionId: context.session.sessionId, - createdAt: new Date().toISOString(), - method: request.method, - threadId: route.threadId, - turnId: route.turnId, - itemId: route.itemId, - requestId, - requestKind, - payload: request.params, - }); - - if (requestKind) { - return; - } - - if (request.method === "item/tool/requestUserInput") { - this.writeMessage(context, { - id: request.id, - result: { answers: {} }, - }); - return; - } - - this.writeMessage(context, { - id: request.id, - error: { - code: -32601, - message: `Unsupported server request: ${request.method}`, - }, - }); - } - - private handleResponse(context: CodexSessionContext, response: JsonRpcResponse): void { - const key = String(response.id); - const pending = context.pending.get(key); - if (!pending) { - return; - } - - clearTimeout(pending.timeout); - context.pending.delete(key); - - if (response.error?.message) { - pending.reject(new Error(`${pending.method} failed: ${String(response.error.message)}`)); - return; - } - - pending.resolve(response.result); - } - - private async sendRequest( - context: CodexSessionContext, - method: string, - params: unknown, - timeoutMs = 20_000, - ): Promise { - const id = context.nextRequestId; - context.nextRequestId += 1; - - const result = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - context.pending.delete(String(id)); - reject(new Error(`Timed out waiting for ${method}.`)); - }, timeoutMs); - - context.pending.set(String(id), { - method, - timeout, - resolve, - reject, - }); - this.writeMessage(context, { - method, - id, - params, - }); - }); - - return result as TResponse; - } - - private writeMessage(context: CodexSessionContext, message: unknown): void { - const encoded = JSON.stringify(message); - if (!context.child.stdin.writable) { - throw new Error("Cannot write to codex app-server stdin."); - } - - context.child.stdin.write(`${encoded}\n`); - } - - private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void { - this.emitEvent({ - id: randomUUID(), - kind: "session", - provider: "codex", - sessionId: context.session.sessionId, - createdAt: new Date().toISOString(), - method, - message, - }); - } - - private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void { - this.emitEvent({ - id: randomUUID(), - kind: "error", - provider: "codex", - sessionId: context.session.sessionId, - createdAt: new Date().toISOString(), - method, - message, - }); - } - - private emitEvent(event: ProviderEvent): void { - this.emit("event", event); - } - - private updateSession(context: CodexSessionContext, updates: Partial): void { - context.session = { - ...context.session, - ...updates, - updatedAt: new Date().toISOString(), - }; - } - - private requestKindForMethod( - method: string, - ): ProviderRequestKind | undefined { - if (method === "item/commandExecution/requestApproval") { - return "command"; - } - - if (method === "item/fileChange/requestApproval") { - return "file-change"; - } - - return undefined; - } - - private isServerRequest(value: unknown): value is JsonRpcRequest { - if (!value || typeof value !== "object") { - return false; - } - - const candidate = value as Record; - return ( - typeof candidate.method === "string" && - (typeof candidate.id === "string" || typeof candidate.id === "number") - ); - } - - private isServerNotification(value: unknown): value is JsonRpcNotification { - if (!value || typeof value !== "object") { - return false; - } - - const candidate = value as Record; - return typeof candidate.method === "string" && !("id" in candidate); - } - - private isResponse(value: unknown): value is JsonRpcResponse { - if (!value || typeof value !== "object") { - return false; - } - - const candidate = value as Record; - const hasId = typeof candidate.id === "string" || typeof candidate.id === "number"; - const hasMethod = typeof candidate.method === "string"; - return hasId && !hasMethod; - } - - private readRouteFields(params: unknown): { - threadId?: string; - turnId?: string; - itemId?: string; - } { - const route: { - threadId?: string; - turnId?: string; - itemId?: string; - } = {}; - - const threadId = - this.readString(params, "threadId") ?? - this.readString(this.readObject(params, "thread"), "id"); - const turnId = - this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"); - const itemId = - this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"); - - if (threadId) { - route.threadId = threadId; - } - - if (turnId) { - route.turnId = turnId; - } - - if (itemId) { - route.itemId = itemId; - } - - return route; - } - - private readObject(value: unknown, key?: string): Record | undefined { - const target = - key === undefined - ? value - : value && typeof value === "object" - ? (value as Record)[key] - : undefined; - - if (!target || typeof target !== "object") { - return undefined; - } - - return target as Record; - } - - private readString(value: unknown, key: string): string | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - - const candidate = (value as Record)[key]; - return typeof candidate === "string" ? candidate : undefined; - } - - private readBoolean(value: unknown, key: string): boolean | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - - const candidate = (value as Record)[key]; - return typeof candidate === "boolean" ? candidate : undefined; - } -} diff --git a/apps/desktop/src/fixPath.ts b/apps/desktop/src/fixPath.ts index be7ea9447c4ddc9e313e642fd7b89a54cc975628..5f708e3c83c2833ce66dd9f0a587b3cbfe32a4b5 100644 --- a/apps/desktop/src/fixPath.ts +++ b/apps/desktop/src/fixPath.ts @@ -13,6 +13,6 @@ export function fixPath(): void { process.env.PATH = result; } } catch { - // Silently ignore — keep default PATH + // Keep inherited PATH if shell lookup fails. } } diff --git a/apps/desktop/src/ipcHelpers.test.ts b/apps/desktop/src/ipcHelpers.test.ts deleted file mode 100644 index ce3318f0619fd3f1be0ac9631392603883ff8360..0000000000000000000000000000000000000000 --- 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 6383cbfef24bd8529b36394eba9b92c053121634..0000000000000000000000000000000000000000 --- 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 index 454cbe65f17e1ee0f0ade9e81ec7928aa496f035..0e91c9222455df817d85e96bf0ca9bfee540ba89 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,46 +1,145 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { app, BrowserWindow, dialog, ipcMain } from "electron"; + 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 PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +const ROOT_DIR = path.resolve(__dirname, "../../.."); +const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js"); +const RENDERER_ENTRY = path.join(ROOT_DIR, "apps/renderer/dist/index.html"); +const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); 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"); +let mainWindow: BrowserWindow | null = null; +let backendProcess: ChildProcess | null = null; +let backendPort = 0; +let backendAuthToken = ""; +let backendWsUrl = ""; +let restartAttempt = 0; +let restartTimer: ReturnType | null = null; +let isQuitting = false; + +async function reserveLoopbackPort(): Promise { + return new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + probe.close(() => { + if (port > 0) { + resolve(port); + return; + } + reject(new Error("Failed to reserve backend port")); + }); + }); + probe.on("error", reject); + }); +} + +function backendEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + CODETHING_MODE: "desktop", + CODETHING_NO_BROWSER: "1", + CODETHING_PORT: String(backendPort), + CODETHING_STATE_DIR: STATE_DIR, + CODETHING_AUTH_TOKEN: backendAuthToken, + }; +} + +function scheduleBackendRestart(reason: string): void { + if (isQuitting || restartTimer) return; + + const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000); + restartAttempt += 1; + console.error(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`); + + restartTimer = setTimeout(() => { + restartTimer = null; + startBackend(); + }, delayMs); +} + +function startBackend(): void { + if (isQuitting || backendProcess) return; + + if (!fs.existsSync(BACKEND_ENTRY)) { + scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`); + return; + } + + const child = spawn(process.execPath, [BACKEND_ENTRY], { + cwd: ROOT_DIR, + env: backendEnv(), + stdio: "inherit", + }); + backendProcess = child; + + child.once("spawn", () => { + restartAttempt = 0; + }); + + child.on("error", (error) => { + if (backendProcess === child) { + backendProcess = null; + } + scheduleBackendRestart(error.message); + }); + + child.on("exit", (code, signal) => { + if (backendProcess === child) { + backendProcess = null; } + if (isQuitting) return; + const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; + scheduleBackendRestart(reason); + }); +} + +function stopBackend(): void { + if (restartTimer) { + clearTimeout(restartTimer); + restartTimer = null; + } + + const child = backendProcess; + backendProcess = null; + if (!child) return; + + if (!child.killed) { + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }, 2_000).unref(); + } +} - return [agentSessionIdSchema.parse(sessionId), data]; - }, -}; +function registerIpcHandlers(): void { + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); + ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { + const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; + 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; + }); +} function createWindow(): BrowserWindow { const window = new BrowserWindow({ @@ -61,285 +160,52 @@ function createWindow(): BrowserWindow { }); 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; + } else { + if (!fs.existsSync(RENDERER_ENTRY)) { + throw new Error(`Renderer bundle missing at ${RENDERER_ENTRY}`); + } + void window.loadFile(RENDERER_ENTRY); } - 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); + if (mainWindow === window) { + mainWindow = null; + } }); -} -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], - }, - }); - }); + return window; } async function bootstrap(): Promise { - setupCSP(); - - todoStore = new TodoStore(path.join(app.getPath("userData"), "todos.json")); - await todoStore.init(); + backendPort = await reserveLoopbackPort(); + backendAuthToken = randomBytes(24).toString("hex"); + backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; + process.env.CODETHING_DESKTOP_WS_URL = backendWsUrl; registerIpcHandlers(); - createWindow(); - - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } - }); + startBackend(); + mainWindow = createWindow(); } app.on("before-quit", () => { - processManager.killAll(); - providerManager.stopAll(); - providerManager.dispose(); + isQuitting = true; + stopBackend(); }); app.whenReady().then(() => { void bootstrap(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + mainWindow = createWindow(); + } + }); }); app.on("window-all-closed", () => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 11fdfe4e77fd26557ab351e6ff188df651331285..b7a6f93a2d537e4c4a88188934fdaffbe8527b48 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,55 +1,9 @@ import { contextBridge, ipcRenderer } from "electron"; -import { IPC_CHANNELS, type NativeApi } from "@acme/contracts"; +const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +const wsUrl = process.env.CODETHING_DESKTOP_WS_URL ?? null; -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); +contextBridge.exposeInMainWorld("desktopBridge", { + getWsUrl: () => wsUrl, + pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise, +}); diff --git a/apps/desktop/src/processManager.test.ts b/apps/desktop/src/processManager.test.ts deleted file mode 100644 index ad231ef849abd2a64cc01c1ca3b37595cc5d3e74..0000000000000000000000000000000000000000 --- 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 7297615a9000d08875ce7b55f3302a4db3cb3dcd..0000000000000000000000000000000000000000 --- 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/providerManager.test.ts b/apps/desktop/src/providerManager.test.ts deleted file mode 100644 index c159bb296cf6a358d3ce79b616f0debd40ea5a7a..0000000000000000000000000000000000000000 --- a/apps/desktop/src/providerManager.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { ProviderManager } from "./providerManager"; - -describe("ProviderManager", () => { - it("detaches provider event listener and ends thread log streams on dispose", () => { - const manager = new ProviderManager(); - const internals = manager as unknown as { - codex: { listenerCount: (event: string) => number }; - onCodexEvent: (event: { - id: string; - kind: "notification"; - provider: "codex"; - sessionId: string; - createdAt: string; - method: string; - threadId: string; - }) => void; - threadLogStreams: Map< - string, - { writableEnded: boolean; destroyed: boolean } - >; - }; - internals.onCodexEvent({ - id: "evt-1", - kind: "notification", - provider: "codex", - sessionId: "sess-1", - createdAt: new Date().toISOString(), - method: "thread/started", - threadId: "thread-1", - }); - const stream = internals.threadLogStreams.get("thread-1"); - expect(stream).toBeDefined(); - if (!stream) return; - - expect(internals.codex.listenerCount("event")).toBe(1); - manager.dispose(); - - expect(internals.codex.listenerCount("event")).toBe(0); - expect(stream.writableEnded || stream.destroyed).toBe(true); - expect(internals.threadLogStreams.size).toBe(0); - }); - - it("allows multiple dispose calls", () => { - const manager = new ProviderManager(); - - manager.dispose(); - expect(() => manager.dispose()).not.toThrow(); - }); - - it("rejects request responses for unknown sessions", async () => { - const manager = new ProviderManager(); - - await expect( - manager.respondToRequest({ - sessionId: "missing-session", - requestId: "req-1", - decision: "accept", - }), - ).rejects.toThrow("Unknown provider session: missing-session"); - - manager.dispose(); - }); -}); diff --git a/apps/desktop/src/providerManager.ts b/apps/desktop/src/providerManager.ts deleted file mode 100644 index ebba31b20dd9125d26b71f2a8a9af97e26cd3a73..0000000000000000000000000000000000000000 --- a/apps/desktop/src/providerManager.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { EventEmitter } from "node:events"; -import fs from "node:fs"; -import path from "node:path"; - -import { - type ProviderEvent, - type ProviderInterruptTurnInput, - type ProviderRespondToRequestInput, - type ProviderSendTurnInput, - type ProviderSession, - type ProviderSessionStartInput, - type ProviderStopSessionInput, - type ProviderTurnStartResult, - providerInterruptTurnInputSchema, - providerRespondToRequestInputSchema, - providerSendTurnInputSchema, - providerSessionStartInputSchema, - providerStopSessionInputSchema, -} from "@acme/contracts"; -import { CodexAppServerManager } from "./codexAppServerManager"; - -export interface ProviderManagerEvents { - event: [event: ProviderEvent]; -} - -export class ProviderManager extends EventEmitter { - private readonly codex = new CodexAppServerManager(); - private readonly threadLogsDir: string; - private readonly threadLogStreams = new Map(); - private readonly sessionThreadIds = new Map(); - private readonly pendingEventsBySession = new Map(); - private disposed = false; - private readonly onCodexEvent = (event: ProviderEvent) => { - if (this.disposed) { - return; - } - - this.routeEventToThreadLog(event); - this.emit("event", event); - }; - - constructor() { - super(); - - const logsDir = path.resolve(process.cwd(), ".logs"); - this.threadLogsDir = path.join(logsDir, "threads"); - fs.mkdirSync(this.threadLogsDir, { recursive: true }); - - this.codex.on("event", this.onCodexEvent); - } - - async startSession(raw: ProviderSessionStartInput): Promise { - const input = providerSessionStartInputSchema.parse(raw); - if (input.provider !== "codex") { - throw new Error(`Provider '${input.provider}' is not implemented yet.`); - } - - const session = await this.codex.startSession(input); - if (session.threadId) { - this.sessionThreadIds.set(session.sessionId, session.threadId); - this.flushPendingSessionEvents(session.sessionId, session.threadId); - } - return session; - } - - async sendTurn(raw: ProviderSendTurnInput): Promise { - const input = providerSendTurnInputSchema.parse(raw); - if (!this.codex.hasSession(input.sessionId)) { - throw new Error(`Unknown provider session: ${input.sessionId}`); - } - - return this.codex.sendTurn(input); - } - - async interruptTurn(raw: ProviderInterruptTurnInput): Promise { - const input = providerInterruptTurnInputSchema.parse(raw); - if (!this.codex.hasSession(input.sessionId)) { - throw new Error(`Unknown provider session: ${input.sessionId}`); - } - - await this.codex.interruptTurn(input.sessionId, input.turnId); - } - - async respondToRequest(raw: ProviderRespondToRequestInput): Promise { - const input = providerRespondToRequestInputSchema.parse(raw); - if (!this.codex.hasSession(input.sessionId)) { - throw new Error(`Unknown provider session: ${input.sessionId}`); - } - - await this.codex.respondToRequest( - input.sessionId, - input.requestId, - input.decision, - ); - } - - stopSession(raw: ProviderStopSessionInput): void { - const input = providerStopSessionInputSchema.parse(raw); - this.codex.stopSession(input.sessionId); - this.sessionThreadIds.delete(input.sessionId); - this.pendingEventsBySession.delete(input.sessionId); - } - - listSessions(): ProviderSession[] { - return this.codex.listSessions(); - } - - stopAll(): void { - this.codex.stopAll(); - } - - dispose(): void { - if (this.disposed) { - return; - } - - this.disposed = true; - this.codex.off("event", this.onCodexEvent); - for (const stream of this.threadLogStreams.values()) { - stream.end(); - } - this.threadLogStreams.clear(); - this.sessionThreadIds.clear(); - this.pendingEventsBySession.clear(); - } - - private routeEventToThreadLog(event: ProviderEvent): void { - const threadId = this.resolveThreadId(event); - if (!threadId) { - const pending = this.pendingEventsBySession.get(event.sessionId) ?? []; - pending.push(event); - this.pendingEventsBySession.set(event.sessionId, pending.slice(-100)); - return; - } - - this.flushPendingSessionEvents(event.sessionId, threadId); - this.writeThreadEvent(threadId, event); - } - - private flushPendingSessionEvents(sessionId: string, threadId: string): void { - const pending = this.pendingEventsBySession.get(sessionId); - if (!pending || pending.length === 0) { - return; - } - - for (const event of pending) { - this.writeThreadEvent(threadId, event); - } - this.pendingEventsBySession.delete(sessionId); - } - - private resolveThreadId(event: ProviderEvent): string | undefined { - const fromPayload = this.readThreadIdFromPayload(event.payload); - const threadId = - event.threadId ?? - fromPayload ?? - this.sessionThreadIds.get(event.sessionId); - - if (threadId) { - this.sessionThreadIds.set(event.sessionId, threadId); - } - - return threadId; - } - - private readThreadIdFromPayload(payload: unknown): string | undefined { - if (!payload || typeof payload !== "object") { - return undefined; - } - - const record = payload as Record; - const direct = record.threadId; - if (typeof direct === "string" && direct.length > 0) { - return direct; - } - - const thread = record.thread; - if (!thread || typeof thread !== "object") { - return undefined; - } - - const nested = (thread as Record).id; - if (typeof nested === "string" && nested.length > 0) { - return nested; - } - - return undefined; - } - - private writeThreadEvent(threadId: string, event: ProviderEvent): void { - const stream = this.getOrCreateThreadLogStream(threadId); - stream.write(`${JSON.stringify(event)}\n`); - } - - private getOrCreateThreadLogStream(threadId: string): fs.WriteStream { - const cached = this.threadLogStreams.get(threadId); - if (cached) { - return cached; - } - - const safeThreadId = threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); - const stream = fs.createWriteStream( - path.join(this.threadLogsDir, `${safeThreadId}.events.ndjson`), - { flags: "a" }, - ); - this.threadLogStreams.set(threadId, stream); - return stream; - } -} diff --git a/apps/desktop/src/todoStore.ts b/apps/desktop/src/todoStore.ts deleted file mode 100644 index a27ec0602087c97002ce178c967f26fc60d7e643..0000000000000000000000000000000000000000 --- 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 c2c8793a7c00635add4a75de3659a3449480ce04..d8fd8a14ea6cc423d7077f037b58a13fd03c80da 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 a525c7caebec9c3705211e7722daca99c8cecbd1..697ce60da960b8c5ed8cb9c5da52e53db56ef258 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,118 @@ function EventRouter() { return null; } +function AutoProjectBootstrap() { + const { state, dispatch } = useStore(); + const bootstrappedRef = useRef(false); + + useEffect(() => { + // Browser mode bootstraps from server welcome. + // Electron bootstraps from persisted projects via DesktopProjectBootstrap. + 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 DesktopProjectBootstrap() { + const api = useMemo(() => readNativeApi(), []); + const { dispatch } = useStore(); + const bootstrappedRef = useRef(false); + + useEffect(() => { + if (!isElectron || !api || bootstrappedRef.current) return; + + let disposed = false; + let retryDelayMs = 500; + let retryTimer: ReturnType | null = null; + + const attemptBootstrap = async () => { + try { + const projects = await api.projects.list(); + if (disposed) return; + dispatch({ + type: "SYNC_PROJECTS", + projects: projects.map((project) => ({ + id: project.id, + name: project.name, + cwd: project.cwd, + model: DEFAULT_MODEL, + expanded: true, + })), + }); + bootstrappedRef.current = true; + } catch { + if (disposed) return; + retryTimer = setTimeout(() => { + retryTimer = null; + void attemptBootstrap(); + }, retryDelayMs); + retryDelayMs = Math.min(retryDelayMs * 2, 5_000); + } + }; + + void attemptBootstrap(); + + return () => { + disposed = true; + if (retryTimer) { + clearTimeout(retryTimer); + } + }; + }, [api, dispatch]); + + return null; +} + function Layout() { const api = useMemo(() => readNativeApi(), []); const { state } = useStore(); @@ -32,11 +147,8 @@ function Layout() { if (!api) { return (
-
-

- Native bridge unavailable. Launch through Electron. -

+

Connecting to CodeThing server...

); @@ -45,6 +157,8 @@ function Layout() { return (
+ + {state.diffOpen && } diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx index f26662e77a7c1d2bd9353bc9d63b5e68cb4e3d98..8adbc2fc50289cde6cdfdec013d4054f9e8e8c05 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 344691d5c9a7a96ba23123d92e4149fc12509e39..6a546c848fab82d87987c852e64e556dc4442fb1 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 (