| author | |
| committer | |
| log | 8ebd2479cf976a6ef79843b97d477777efb7cb6a |
| tree | 85e8cea2dc57ab2e1c59cc74576ff027c37464d0 |
| parent | e7c4292d271f14ace547c135089245825b82e17b |
| signature | Signed by PGP key B5690EEEBB952194 |
* feat: scaffold apps/server package and WebSocket protocol types
- Create apps/server with package.json, tsconfig, tsup config
- Copy codexAppServerManager, providerManager, fixPath from desktop (no Electron deps)
- Add WebSocket message protocol schemas (WsRequest, WsResponse, WsPush) to @acme/contracts
- Define WS_METHODS and WS_CHANNELS constants for type-safe routing
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
* feat: implement WebSocket + HTTP server and CLI entry point
- wsServer.ts: WebSocket server routes NativeApi methods, serves static files
- Provider events pushed to all connected clients
- SPA fallback for client-side routing
- Server welcome message with cwd/projectName on connect
- index.ts: CLI entry point
- Auto-detects available port (default 3773)
- Resolves renderer dist for static serving
- Opens browser on startup via 'open' package
- Graceful shutdown on SIGINT/SIGTERM
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
* feat: WebSocket transport and NativeApi implementation for browser
- wsTransport.ts: WebSocket client with request/response, push subscriptions,
auto-reconnect with exponential backoff
- wsNativeApi.ts: Full NativeApi implementation over WebSocket transport
- All provider methods routed through WS_METHODS
- Provider events streamed via WS_CHANNELS.providerEvent
- Todos/terminal/agent stubbed (not needed for web mode)
- dialogs.pickFolder returns null (text input fallback in UI)
- session-logic.ts: readNativeApi() now auto-detects environment
- Prefers Electron preload bridge if available (window.nativeApi)
- Falls back to WebSocket transport for browser mode
- onServerWelcome() hook for renderer to receive cwd on connect
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
* feat: adapt renderer for browser mode
- App.tsx: Add AutoProjectBootstrap that listens for server welcome
and auto-creates project + thread from server's cwd
- Sidebar: Conditionalize drag region (Electron only), hide Browse button in browser
- ChatView: Conditionalize drag region, adjust padding for browser
- DiffPanel: Same drag region treatment
- index.html: Update title to 'CodeThing'
- env.ts: isElectron detection utility
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
* feat: monorepo & CLI integration
- Root package.json: Add dev, dev:server, dev:web, start scripts
- dev runs both server and renderer in parallel via turbo
- VITE_WS_URL injected so renderer connects to server in dev mode
- turbo.json: Add WS env vars to globalEnv
- vite.config.ts: Inject VITE_WS_URL at build time for WS server discovery
- wsTransport.ts: Use VITE_WS_URL env var in dev, fall back to page URL in prod
- Server dev script: Uses tsx for direct TypeScript execution with VITE_DEV_SERVER_URL
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
* feat: remove Electron desktop app, clean up contracts and docs
- Delete apps/desktop/ entirely (replaced by apps/server)
- Remove IPC_CHANNELS constant from contracts (no longer needed)
- Keep NativeApi interface (still the renderer's API contract)
- Keep EDITORS constant (used by both server and renderer)
- Update README.md with new architecture diagram and scripts
- Update AGENTS.md with new package roles and architecture
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
* test: add WebSocket server integration tests
- Tests welcome message on connect with cwd/projectName
- Tests server.getConfig method
- Tests providers.listSessions returns empty array
- Tests error response for unknown methods
- Tests graceful handling of invalid JSON
- All 73 tests pass across 12 test files
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
* fix: address lint warnings in new files
- wsTransport.ts: Use addEventListener instead of on-function assignments
- wsServer.ts: Replace filter(Boolean).pop() with direct array access
- wsServer.test.ts: Use addEventListener for WebSocket events
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
* fix: race condition in auto-project bootstrap + cleanup
- wsNativeApi: Cache last welcome payload and replay to late subscribers.
Fixes race where WS connect + welcome arrives before React effect fires.
- App.tsx: Add bootstrappedRef to prevent duplicate project creation,
skip bootstrap in Electron mode
- wsTransport: Use standard import.meta.env for VITE_WS_URL access
- CLAUDE.md: Fix header (was incorrectly '# AGENTS.md')
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
* fix: use tsx command directly in server dev script
Use 'tsx src/index.ts' instead of 'node --import tsx' for simpler dev execution
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
* chore: clean up stale Electron references and config
- Remove smoke-test script (was desktop-only)
- Remove dist-electron from turbo outputs and .gitignore
- Rename ELECTRON_RENDERER_PORT → PORT in vite.config.ts
- Clean up turbo globalEnv (remove ELECTRON_RENDERER_PORT, WS_SERVER_PORT)
- Update CI workflow to use bun 1.3.9 (matches package.json engines)
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
* feat: make t3 package publishable for npx
- Remove private:true so package can be published to npm
- Add files field to limit published files to dist/
- Move @acme/contracts to devDependencies (bundled by tsup)
- Add @acme/renderer devDependency for turbo build ordering
- Add bundle-client.mjs: copies renderer build into dist/client/
- Update resolveStaticDir to check dist/client/ first (npx scenario),
then fall back to monorepo layout (../../renderer/dist)
- Full build pipeline: contracts → renderer → server + bundle client
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
* fix tests
* turborepo and better logging
* bring back electron
* fix
* Promise catching fixes
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>55 files changed, 3679 insertions(+), 2436 deletions(-)
.github/workflows/ci.yml+10-2| ... | ... | @@ -8,7 +8,7 @@ on: |
| 8 | 8 | |
| 9 | 9 | jobs: |
| 10 | 10 | quality: |
| 11 | name: Lint, Typecheck, Test | |
| 11 | name: Lint, Typecheck, Test, Build | |
| 12 | 12 | runs-on: ubuntu-latest |
| 13 | 13 | steps: |
| 14 | 14 | - name: Checkout |
| ... | ... | @@ -17,7 +17,7 @@ jobs: |
| 17 | 17 | - name: Setup Bun |
| 18 | 18 | uses: oven-sh/setup-bun@v2 |
| 19 | 19 | with: |
| 20 | bun-version: 1.1.38 | |
| 20 | bun-version: 1.3.9 | |
| 21 | 21 | |
| 22 | 22 | - name: Cache Bun and Turbo |
| 23 | 23 | uses: actions/cache@v4 |
| ... | ... | @@ -40,3 +40,11 @@ jobs: |
| 40 | 40 | |
| 41 | 41 | - name: Test |
| 42 | 42 | run: bun run test |
| 43 | ||
| 44 | - name: Build desktop pipeline | |
| 45 | run: bun run build:desktop | |
| 46 | ||
| 47 | - name: Verify preload bundle output | |
| 48 | run: | | |
| 49 | test -f apps/desktop/dist-electron/preload.js | |
| 50 | grep -nE "desktopBridge|getWsUrl|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js |
.gitignore-1| ... | ... | @@ -5,7 +5,6 @@ node_modules |
| 5 | 5 | *.log |
| 6 | 6 | *.tsbuildinfo |
| 7 | 7 | apps/*/dist |
| 8 | apps/*/dist-electron | |
| 9 | 8 | packages/*/dist |
| 10 | 9 | .env |
| 11 | 10 | .env.local |
AGENTS.md+10-9| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | # AGENTS.md | |
| 1 | # CLAUDE.md | |
| 2 | 2 | |
| 3 | 3 | ## Project Snapshot |
| 4 | CodeThing is a minimal GUI for using code agents like Codex and Claude Code (coming soon). | |
| 4 | CodeThing is a minimal web GUI for using code agents like Codex and Claude Code (coming soon). | |
| 5 | 5 | |
| 6 | 6 | This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged. |
| 7 | 7 | |
| ... | ... | @@ -13,17 +13,18 @@ This repository is a VERY EARLY WIP. Proposing sweeping changes that improve lon |
| 13 | 13 | If a tradeoff is required, choose correctness and robustness over short-term convenience. |
| 14 | 14 | |
| 15 | 15 | ## Package Roles |
| 16 | - `apps/desktop`: Electron main/preload runtime. Owns provider orchestration, process/session lifecycle, and native IPC boundaries. | |
| 17 | - `apps/renderer`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. | |
| 18 | - `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, IPC payloads, and model/session types. | |
| 16 | - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. | |
| 17 | - `apps/renderer`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. | |
| 18 | - `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. | |
| 19 | 19 | |
| 20 | 20 | ## Codex App Server (Important) |
| 21 | 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. | |
| 21 | 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. | |
| 22 | 22 | |
| 23 | 23 | How we use it in this codebase: |
| 24 | - Session startup/resume and turn lifecycle are brokered in `apps/desktop/src/codexAppServerManager.ts`. | |
| 25 | - Provider dispatch and thread event logging are coordinated in `apps/desktop/src/providerManager.ts`. | |
| 26 | - Renderer consumes provider event streams via `nativeApi.providers.onEvent`. | |
| 24 | - Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`. | |
| 25 | - Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`. | |
| 26 | - WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`. | |
| 27 | - Renderer consumes provider event streams via WebSocket push on channel `providers.event`. | |
| 27 | 28 | |
| 28 | 29 | Docs: |
| 29 | 30 | - Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server |
README.md+68-41| ... | ... | @@ -1,16 +1,38 @@ |
| 1 | # CodeThing (Electron + Vite + Bun) | |
| 2 | ||
| 3 | CodeThing is a desktop shell for coding agents. This first implementation is: | |
| 4 | ||
| 5 | 1. Codex-first: connects to `codex app-server` and streams turn/item events. | |
| 6 | 2. Provider-ready: renderer speaks a provider abstraction so Claude Code can plug in later. | |
| 7 | 3. Typed end-to-end: contracts validate payloads at preload/main boundaries. | |
| 1 | # CodeThing | |
| 2 | ||
| 3 | CodeThing is a minimal web GUI for coding agents. Currently Codex-first, with Claude Code support coming soon. | |
| 4 | ||
| 5 | Run `npx t3` in any project directory to launch the web interface. | |
| 6 | Run `bun run dev:desktop` to launch the Electron desktop app in this monorepo. | |
| 7 | ||
| 8 | ## Architecture | |
| 9 | ||
| 10 | CodeThing runs as a **Node.js WebSocket server** that wraps `codex app-server` (JSON-RPC over stdio) and serves a React web app. | |
| 11 | ||
| 12 | ``` | |
| 13 | ┌─────────────────────────────────┐ | |
| 14 | │ Browser (React + Vite) │ | |
| 15 | │ Connected via WebSocket │ | |
| 16 | └──────────┬──────────────────────┘ | |
| 17 | │ ws://localhost:3773 | |
| 18 | ┌──────────▼──────────────────────┐ | |
| 19 | │ apps/server (Node.js) │ | |
| 20 | │ WebSocket + HTTP static server │ | |
| 21 | │ ProviderManager │ | |
| 22 | │ CodexAppServerManager │ | |
| 23 | └──────────┬──────────────────────┘ | |
| 24 | │ JSON-RPC over stdio | |
| 25 | ┌──────────▼──────────────────────┐ | |
| 26 | │ codex app-server │ | |
| 27 | └─────────────────────────────────┘ | |
| 28 | ``` | |
| 8 | 29 | |
| 9 | 30 | ## Workspace layout |
| 10 | 31 | |
| 11 | - `/apps/desktop`: Electron main + preload process, includes provider and Codex session managers. | |
| 12 | - `/apps/renderer`: React + Vite UI for session control, conversation, and protocol event stream. | |
| 13 | - `/packages/contracts`: shared Zod schemas + TypeScript types for IPC and provider events. | |
| 32 | - `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built renderer, and opens the browser on start. | |
| 33 | - `/apps/renderer`: React + Vite UI. Session control, conversation, and provider event rendering. Connects to the server via WebSocket. | |
| 34 | - `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared renderer. | |
| 35 | - `/packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. | |
| 14 | 36 | |
| 15 | 37 | ## Codex prerequisites |
| 16 | 38 | |
| ... | ... | @@ -18,50 +40,55 @@ CodeThing is a desktop shell for coding agents. This first implementation is: |
| 18 | 40 | - Authenticate Codex before running CodeThing (for example via API key or ChatGPT auth supported by Codex). |
| 19 | 41 | - CodeThing starts the server via `codex app-server` per session. |
| 20 | 42 | |
| 21 | ## Security and boundary model | |
| 43 | ## Quick start | |
| 22 | 44 | |
| 23 | - `nodeIntegration: false` | |
| 24 | - `contextIsolation: true` | |
| 25 | - `sandbox: true` | |
| 26 | - Renderer talks only to `window.nativeApi` exposed by preload. | |
| 27 | - Preload and main both validate inputs using shared Zod schemas. | |
| 45 | ```bash | |
| 46 | # Development (with hot reload) | |
| 47 | bun run dev | |
| 28 | 48 | |
| 29 | `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. | |
| 49 | # Desktop development | |
| 50 | bun run dev:desktop | |
| 30 | 51 | |
| 31 | ## Runtime modes | |
| 52 | # Production | |
| 53 | bun run build | |
| 54 | bun run start | |
| 32 | 55 | |
| 33 | CodeThing has a global runtime mode switch in the sidebar: | |
| 56 | # Or from any project directory after publishing: | |
| 57 | npx t3 | |
| 58 | ``` | |
| 34 | 59 | |
| 35 | - `Full access` (default): starts new sessions with `approvalPolicy: never` and `sandboxMode: danger-full-access`. | |
| 36 | - `Approval required`: starts new sessions with `approvalPolicy: on-request` and `sandboxMode: workspace-write`, then prompts in-app for command/file approvals. | |
| 60 | ## Scripts | |
| 37 | 61 | |
| 38 | Mode changes apply across all threads. Existing live sessions are restarted so old and new threads use the selected mode. | |
| 62 | - `bun run dev` — Starts contracts, server, and web dev tasks via Turborepo's parallel task runner. | |
| 63 | - `bun run dev:server` — Starts just the WebSocket server (uses tsx for TS execution). | |
| 64 | - `bun run dev:web` — Starts just the Vite dev server for the renderer. | |
| 65 | - `bun run start` — Runs the production server (serves built renderer as static files). | |
| 66 | - `bun run build` — Builds contracts, renderer, and server through Turbo. | |
| 67 | - `bun run typecheck` — Strict TypeScript checks for all packages. | |
| 68 | - `bun run test` — Runs workspace tests. | |
| 39 | 69 | |
| 40 | ## Scripts | |
| 70 | ## Runtime modes | |
| 41 | 71 | |
| 42 | - `bun run dev`: starts contract build/watch, renderer dev server, and Electron process. | |
| 43 | - `bun run build`: builds contracts, renderer, and desktop bundles through Turbo. | |
| 44 | - `bun run typecheck`: strict TypeScript checks for all packages. | |
| 45 | - `bun run test`: runs workspace tests. | |
| 72 | CodeThing has a global runtime mode switch in the chat toolbar: | |
| 46 | 73 | |
| 47 | ## CI quality gates | |
| 74 | - **Full access** (default): starts sessions with `approvalPolicy: never` and `sandboxMode: danger-full-access`. | |
| 75 | - **Supervised**: starts sessions with `approvalPolicy: on-request` and `sandboxMode: workspace-write`, then prompts in-app for command/file approvals. | |
| 48 | 76 | |
| 49 | - `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. | |
| 77 | ## Provider architecture | |
| 50 | 78 | |
| 51 | Optional: | |
| 79 | The renderer communicates with the server via WebSocket using a simple JSON-RPC-style protocol: | |
| 52 | 80 | |
| 53 | - `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use. | |
| 81 | - **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` | |
| 82 | - **Push events**: `{ type: "push", channel, data }` for streaming provider events | |
| 54 | 83 | |
| 55 | ## Provider architecture | |
| 84 | Methods mirror the `NativeApi` interface defined in `@acme/contracts`: | |
| 56 | 85 | |
| 57 | The renderer now depends on `nativeApi.providers.*`: | |
| 86 | - `providers.startSession`, `providers.sendTurn`, `providers.interruptTurn` | |
| 87 | - `providers.respondToRequest`, `providers.stopSession`, `providers.listSessions` | |
| 88 | - `shell.openInEditor`, `server.getConfig` | |
| 58 | 89 | |
| 59 | 1. `startSession` | |
| 60 | 2. `sendTurn` | |
| 61 | 3. `interruptTurn` | |
| 62 | 4. `respondToRequest` | |
| 63 | 5. `stopSession` | |
| 64 | 6. `listSessions` | |
| 65 | 7. `onEvent` | |
| 90 | Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. | |
| 66 | 91 | |
| 67 | Codex is the only implemented provider right now. `claudeCode` is reserved in contracts/UI but returns a not-implemented error in main-process dispatch. | |
| 92 | ## CI quality gates | |
| 93 | ||
| 94 | - `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. |
apps/desktop/.gitignore created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | dist-electron/ |
apps/desktop/package.json+5-8| ... | ... | @@ -4,28 +4,25 @@ |
| 4 | 4 | "private": true, |
| 5 | 5 | "main": "dist-electron/main.js", |
| 6 | 6 | "scripts": { |
| 7 | "dev": "concurrently -k -n BUNDLE,ELECTRON \"bun run dev:bundle\" \"bun run dev:electron\"", | |
| 7 | "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\"", | |
| 8 | 8 | "dev:bundle": "tsup --watch", |
| 9 | 9 | "dev:electron": "bun run scripts/dev-electron.mjs", |
| 10 | 10 | "build": "tsup", |
| 11 | 11 | "start": "electron dist-electron/main.js", |
| 12 | "postinstall": "electron-rebuild", | |
| 13 | 12 | "typecheck": "tsc --noEmit", |
| 14 | "test": "vitest run", | |
| 13 | "test": "vitest run --passWithNoTests", | |
| 15 | 14 | "smoke-test": "node scripts/smoke-test.mjs" |
| 16 | 15 | }, |
| 17 | 16 | "dependencies": { |
| 18 | "@acme/contracts": "workspace:*", | |
| 19 | "electron": "33.4.11", | |
| 20 | "node-pty": "^1.0.0" | |
| 17 | "electron": "33.4.11" | |
| 21 | 18 | }, |
| 22 | 19 | "devDependencies": { |
| 23 | "concurrently": "^9.1.2", | |
| 24 | "@electron/rebuild": "^3.7.0", | |
| 25 | 20 | "@types/node": "^22.10.2", |
| 21 | "concurrently": "^9.1.2", | |
| 26 | 22 | "electronmon": "^2.0.2", |
| 27 | 23 | "tsup": "^8.3.5", |
| 28 | 24 | "typescript": "^5.7.3", |
| 25 | "vitest": "^3.0.0", | |
| 29 | 26 | "wait-on": "^8.0.2" |
| 30 | 27 | } |
| 31 | 28 | } |
apps/desktop/scripts/dev-electron.mjs+2-2| ... | ... | @@ -10,11 +10,11 @@ await waitOn({ |
| 10 | 10 | `tcp:${port}`, |
| 11 | 11 | "file:dist-electron/main.js", |
| 12 | 12 | "file:dist-electron/preload.js", |
| 13 | "file:../server/dist/index.js", | |
| 13 | 14 | ], |
| 14 | 15 | }); |
| 15 | 16 | |
| 16 | const command = | |
| 17 | process.platform === "win32" ? "electronmon.cmd" : "electronmon"; | |
| 17 | const command = process.platform === "win32" ? "electronmon.cmd" : "electronmon"; | |
| 18 | 18 | const child = spawn(command, ["dist-electron/main.js"], { |
| 19 | 19 | stdio: "inherit", |
| 20 | 20 | env: { |
apps/desktop/scripts/smoke-test.mjs+19-42| ... | ... | @@ -1,84 +1,61 @@ |
| 1 | /** | |
| 2 | * Smoke test: builds desktop + renderer, launches Electron against the | |
| 3 | * production bundle, waits for the renderer to confirm it loaded, then exits. | |
| 4 | * | |
| 5 | * Catches the two categories of regression we've hit: | |
| 6 | * 1. Module resolution failures (preload can't find @acme/contracts, etc.) | |
| 7 | * 2. CSP / script blocking (React fails to mount) | |
| 8 | * | |
| 9 | * The test works by injecting a tiny check via ELECTRON_ENABLE_LOGGING — | |
| 10 | * Electron forwards renderer console.log to the main process stdout when | |
| 11 | * that env var is set. We look for React's "Download the React DevTools" | |
| 12 | * message as proof that React successfully mounted (it only fires after | |
| 13 | * the first render). For production mode (no React DevTools message), we | |
| 14 | * instead check that no fatal errors appeared. | |
| 15 | */ | |
| 16 | import { spawn, execSync } from "node:child_process"; | |
| 1 | import { execSync, spawn } from "node:child_process"; | |
| 17 | 2 | import { dirname, resolve } from "node:path"; |
| 18 | 3 | import { fileURLToPath } from "node:url"; |
| 19 | 4 | |
| 20 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 21 | const root = resolve(__dirname, "../../.."); | |
| 22 | 6 | const desktopDir = resolve(__dirname, ".."); |
| 7 | const rootDir = resolve(__dirname, "../../.."); | |
| 23 | 8 | const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); |
| 24 | 9 | const mainJs = resolve(desktopDir, "dist-electron/main.js"); |
| 25 | 10 | |
| 26 | // ── Build first ────────────────────────────────────────────────────── | |
| 27 | console.log("Building contracts + renderer + desktop..."); | |
| 28 | execSync("bun run build", { cwd: root, stdio: "inherit" }); | |
| 11 | console.log("Building contracts + renderer + server + desktop..."); | |
| 12 | execSync("bun run build", { cwd: rootDir, stdio: "inherit" }); | |
| 29 | 13 | |
| 30 | // ── Launch Electron (production mode — no VITE_DEV_SERVER_URL) ────── | |
| 31 | console.log("\nLaunching Electron (production mode)..."); | |
| 14 | console.log("\nLaunching Electron smoke test..."); | |
| 32 | 15 | |
| 33 | 16 | const child = spawn(electronBin, [mainJs], { |
| 34 | 17 | stdio: ["pipe", "pipe", "pipe"], |
| 35 | 18 | env: { |
| 36 | 19 | ...process.env, |
| 37 | VITE_DEV_SERVER_URL: "", // ensure production path | |
| 20 | VITE_DEV_SERVER_URL: "", | |
| 38 | 21 | ELECTRON_ENABLE_LOGGING: "1", |
| 39 | 22 | }, |
| 40 | 23 | }); |
| 41 | 24 | |
| 42 | 25 | let output = ""; |
| 43 | ||
| 44 | child.stdout.on("data", (d) => { | |
| 45 | output += d.toString(); | |
| 26 | child.stdout.on("data", (chunk) => { | |
| 27 | output += chunk.toString(); | |
| 46 | 28 | }); |
| 47 | child.stderr.on("data", (d) => { | |
| 48 | output += d.toString(); | |
| 29 | child.stderr.on("data", (chunk) => { | |
| 30 | output += chunk.toString(); | |
| 49 | 31 | }); |
| 50 | 32 | |
| 51 | const TIMEOUT_MS = 8_000; | |
| 52 | ||
| 53 | const timer = setTimeout(() => { | |
| 33 | const timeout = setTimeout(() => { | |
| 54 | 34 | child.kill(); |
| 55 | }, TIMEOUT_MS); | |
| 35 | }, 8_000); | |
| 56 | 36 | |
| 57 | 37 | child.on("exit", () => { |
| 58 | clearTimeout(timer); | |
| 38 | clearTimeout(timeout); | |
| 59 | 39 | |
| 60 | // Fatal patterns that indicate broken builds | |
| 61 | 40 | const fatalPatterns = [ |
| 62 | 41 | "Cannot find module", |
| 63 | 42 | "MODULE_NOT_FOUND", |
| 64 | "Refused to execute", // CSP blocking scripts | |
| 65 | "can't detect preamble", // @vitejs/plugin-react failure | |
| 43 | "Refused to execute", | |
| 66 | 44 | "Uncaught Error", |
| 67 | 45 | "Uncaught TypeError", |
| 68 | 46 | "Uncaught ReferenceError", |
| 69 | 47 | ]; |
| 70 | ||
| 71 | const failures = fatalPatterns.filter((p) => output.includes(p)); | |
| 48 | const failures = fatalPatterns.filter((pattern) => output.includes(pattern)); | |
| 72 | 49 | |
| 73 | 50 | if (failures.length > 0) { |
| 74 | console.error("\n❌ Smoke test FAILED. Matched fatal patterns:"); | |
| 75 | for (const f of failures) { | |
| 76 | console.error(` • ${f}`); | |
| 51 | console.error("\nDesktop smoke test failed:"); | |
| 52 | for (const failure of failures) { | |
| 53 | console.error(` - ${failure}`); | |
| 77 | 54 | } |
| 78 | 55 | console.error("\nFull output:\n" + output); |
| 79 | 56 | process.exit(1); |
| 80 | 57 | } |
| 81 | 58 | |
| 82 | console.log("✅ Smoke test passed — no fatal errors detected"); | |
| 59 | console.log("Desktop smoke test passed."); | |
| 83 | 60 | process.exit(0); |
| 84 | 61 | }); |
apps/desktop/src/codexAppServerManager.test.ts deleted-81| ... | ... | @@ -1,81 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | classifyCodexStderrLine, | |
| 5 | isRecoverableThreadResumeError, | |
| 6 | normalizeCodexModelSlug, | |
| 7 | } from "./codexAppServerManager"; | |
| 8 | ||
| 9 | describe("classifyCodexStderrLine", () => { | |
| 10 | it("ignores empty lines", () => { | |
| 11 | expect(classifyCodexStderrLine(" ")).toBeNull(); | |
| 12 | }); | |
| 13 | ||
| 14 | it("ignores non-error structured codex logs", () => { | |
| 15 | const line = | |
| 16 | "2026-02-08T04:24:19.241256Z WARN codex_core::features: unknown feature key in config: skills"; | |
| 17 | expect(classifyCodexStderrLine(line)).toBeNull(); | |
| 18 | }); | |
| 19 | ||
| 20 | it("ignores known benign rollout path errors", () => { | |
| 21 | const line = | |
| 22 | "\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"; | |
| 23 | expect(classifyCodexStderrLine(line)).toBeNull(); | |
| 24 | }); | |
| 25 | ||
| 26 | it("keeps unknown structured errors", () => { | |
| 27 | const line = "2026-02-08T04:24:20.085687Z ERROR codex_core::runtime: unrecoverable failure"; | |
| 28 | expect(classifyCodexStderrLine(line)).toEqual({ | |
| 29 | message: line, | |
| 30 | }); | |
| 31 | }); | |
| 32 | ||
| 33 | it("keeps plain stderr messages", () => { | |
| 34 | const line = "fatal: permission denied"; | |
| 35 | expect(classifyCodexStderrLine(line)).toEqual({ | |
| 36 | message: line, | |
| 37 | }); | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | describe("normalizeCodexModelSlug", () => { | |
| 42 | it("maps 5.3 aliases to gpt-5.3-codex", () => { | |
| 43 | expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex"); | |
| 44 | expect(normalizeCodexModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); | |
| 45 | }); | |
| 46 | ||
| 47 | it("prefers codex id when model differs", () => { | |
| 48 | expect(normalizeCodexModelSlug("gpt-5.3", "gpt-5.3-codex")).toBe("gpt-5.3-codex"); | |
| 49 | }); | |
| 50 | ||
| 51 | it("keeps non-aliased models as-is", () => { | |
| 52 | expect(normalizeCodexModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | |
| 53 | expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2"); | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | describe("isRecoverableThreadResumeError", () => { | |
| 58 | it("matches not-found resume errors", () => { | |
| 59 | expect( | |
| 60 | isRecoverableThreadResumeError( | |
| 61 | new Error("thread/resume failed: thread not found"), | |
| 62 | ), | |
| 63 | ).toBe(true); | |
| 64 | }); | |
| 65 | ||
| 66 | it("ignores non-resume errors", () => { | |
| 67 | expect( | |
| 68 | isRecoverableThreadResumeError( | |
| 69 | new Error("thread/start failed: permission denied"), | |
| 70 | ), | |
| 71 | ).toBe(false); | |
| 72 | }); | |
| 73 | ||
| 74 | it("ignores non-recoverable resume errors", () => { | |
| 75 | expect( | |
| 76 | isRecoverableThreadResumeError( | |
| 77 | new Error("thread/resume failed: timed out waiting for server"), | |
| 78 | ), | |
| 79 | ).toBe(false); | |
| 80 | }); | |
| 81 | }); |
apps/desktop/src/codexAppServerManager.ts deleted-847| ... | ... | @@ -1,847 +0,0 @@ |
| 1 | import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; | |
| 2 | import { randomUUID } from "node:crypto"; | |
| 3 | import { EventEmitter } from "node:events"; | |
| 4 | import readline from "node:readline"; | |
| 5 | ||
| 6 | import type { | |
| 7 | ProviderApprovalDecision, | |
| 8 | ProviderEvent, | |
| 9 | ProviderRequestKind, | |
| 10 | ProviderSendTurnInput, | |
| 11 | ProviderSession, | |
| 12 | ProviderSessionStartInput, | |
| 13 | ProviderTurnStartResult, | |
| 14 | } from "@acme/contracts"; | |
| 15 | import { normalizeModelSlug } from "@acme/contracts"; | |
| 16 | ||
| 17 | type PendingRequestKey = string; | |
| 18 | ||
| 19 | interface PendingRequest { | |
| 20 | method: string; | |
| 21 | timeout: ReturnType<typeof setTimeout>; | |
| 22 | resolve: (value: unknown) => void; | |
| 23 | reject: (error: Error) => void; | |
| 24 | } | |
| 25 | ||
| 26 | interface PendingApprovalRequest { | |
| 27 | requestId: string; | |
| 28 | jsonRpcId: string | number; | |
| 29 | method: | |
| 30 | | "item/commandExecution/requestApproval" | |
| 31 | | "item/fileChange/requestApproval"; | |
| 32 | requestKind: ProviderRequestKind; | |
| 33 | threadId?: string; | |
| 34 | turnId?: string; | |
| 35 | itemId?: string; | |
| 36 | } | |
| 37 | ||
| 38 | interface CodexSessionContext { | |
| 39 | session: ProviderSession; | |
| 40 | child: ChildProcessWithoutNullStreams; | |
| 41 | output: readline.Interface; | |
| 42 | pending: Map<PendingRequestKey, PendingRequest>; | |
| 43 | pendingApprovals: Map<string, PendingApprovalRequest>; | |
| 44 | nextRequestId: number; | |
| 45 | stopping: boolean; | |
| 46 | } | |
| 47 | ||
| 48 | interface JsonRpcError { | |
| 49 | code?: number; | |
| 50 | message?: string; | |
| 51 | } | |
| 52 | ||
| 53 | interface JsonRpcRequest { | |
| 54 | id: string | number; | |
| 55 | method: string; | |
| 56 | params?: unknown; | |
| 57 | } | |
| 58 | ||
| 59 | interface JsonRpcResponse { | |
| 60 | id: string | number; | |
| 61 | result?: unknown; | |
| 62 | error?: JsonRpcError; | |
| 63 | } | |
| 64 | ||
| 65 | interface JsonRpcNotification { | |
| 66 | method: string; | |
| 67 | params?: unknown; | |
| 68 | } | |
| 69 | ||
| 70 | const ANSI_ESCAPE_CHAR = String.fromCharCode(27); | |
| 71 | const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); | |
| 72 | const CODEX_STDERR_LOG_REGEX = | |
| 73 | /^\d{4}-\d{2}-\d{2}T\S+\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+\S+:\s+(.*)$/; | |
| 74 | const BENIGN_ERROR_LOG_SNIPPETS = [ | |
| 75 | "state db missing rollout path for thread", | |
| 76 | "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", | |
| 77 | ]; | |
| 78 | const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ | |
| 79 | "not found", | |
| 80 | "missing thread", | |
| 81 | "no such thread", | |
| 82 | "unknown thread", | |
| 83 | "does not exist", | |
| 84 | ]; | |
| 85 | ||
| 86 | export function normalizeCodexModelSlug( | |
| 87 | model: string | undefined | null, | |
| 88 | preferredId?: string, | |
| 89 | ): string | undefined { | |
| 90 | const normalized = normalizeModelSlug(model); | |
| 91 | if (!normalized) { | |
| 92 | return undefined; | |
| 93 | } | |
| 94 | ||
| 95 | if (preferredId?.endsWith("-codex") && preferredId !== normalized) { | |
| 96 | return preferredId; | |
| 97 | } | |
| 98 | ||
| 99 | return normalized; | |
| 100 | } | |
| 101 | ||
| 102 | export function classifyCodexStderrLine(rawLine: string): { message: string } | null { | |
| 103 | const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); | |
| 104 | if (!line) { | |
| 105 | return null; | |
| 106 | } | |
| 107 | ||
| 108 | const match = line.match(CODEX_STDERR_LOG_REGEX); | |
| 109 | if (match) { | |
| 110 | const level = match[1]; | |
| 111 | if (level && level !== "ERROR") { | |
| 112 | return null; | |
| 113 | } | |
| 114 | ||
| 115 | const isBenignError = BENIGN_ERROR_LOG_SNIPPETS.some((snippet) => line.includes(snippet)); | |
| 116 | if (isBenignError) { | |
| 117 | return null; | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | return { message: line }; | |
| 122 | } | |
| 123 | ||
| 124 | export function isRecoverableThreadResumeError(error: unknown): boolean { | |
| 125 | const message = ( | |
| 126 | error instanceof Error ? error.message : String(error) | |
| 127 | ).toLowerCase(); | |
| 128 | if (!message.includes("thread/resume")) { | |
| 129 | return false; | |
| 130 | } | |
| 131 | ||
| 132 | return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => | |
| 133 | message.includes(snippet), | |
| 134 | ); | |
| 135 | } | |
| 136 | ||
| 137 | export interface CodexAppServerManagerEvents { | |
| 138 | event: [event: ProviderEvent]; | |
| 139 | } | |
| 140 | ||
| 141 | export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> { | |
| 142 | private readonly sessions = new Map<string, CodexSessionContext>(); | |
| 143 | ||
| 144 | async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { | |
| 145 | const sessionId = randomUUID(); | |
| 146 | const now = new Date().toISOString(); | |
| 147 | ||
| 148 | const session: ProviderSession = { | |
| 149 | sessionId, | |
| 150 | provider: "codex", | |
| 151 | status: "connecting", | |
| 152 | model: normalizeCodexModelSlug(input.model), | |
| 153 | cwd: input.cwd, | |
| 154 | createdAt: now, | |
| 155 | updatedAt: now, | |
| 156 | }; | |
| 157 | ||
| 158 | const child = spawn("codex", ["app-server"], { | |
| 159 | cwd: input.cwd, | |
| 160 | env: process.env, | |
| 161 | stdio: ["pipe", "pipe", "pipe"], | |
| 162 | }); | |
| 163 | const output = readline.createInterface({ input: child.stdout }); | |
| 164 | ||
| 165 | const context: CodexSessionContext = { | |
| 166 | session, | |
| 167 | child, | |
| 168 | output, | |
| 169 | pending: new Map(), | |
| 170 | pendingApprovals: new Map(), | |
| 171 | nextRequestId: 1, | |
| 172 | stopping: false, | |
| 173 | }; | |
| 174 | ||
| 175 | this.sessions.set(sessionId, context); | |
| 176 | this.attachProcessListeners(context); | |
| 177 | ||
| 178 | this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); | |
| 179 | ||
| 180 | try { | |
| 181 | await this.sendRequest(context, "initialize", { | |
| 182 | clientInfo: { | |
| 183 | name: "codething_desktop", | |
| 184 | title: "CodeThing Desktop", | |
| 185 | version: "0.1.0", | |
| 186 | }, | |
| 187 | capabilities: { | |
| 188 | experimentalApi: false, | |
| 189 | }, | |
| 190 | }); | |
| 191 | ||
| 192 | this.writeMessage(context, { method: "initialized" }); | |
| 193 | ||
| 194 | const normalizedModel = normalizeCodexModelSlug(input.model); | |
| 195 | const sessionOverrides = { | |
| 196 | model: normalizedModel ?? null, | |
| 197 | cwd: input.cwd ?? null, | |
| 198 | approvalPolicy: input.approvalPolicy, | |
| 199 | sandbox: input.sandboxMode, | |
| 200 | }; | |
| 201 | ||
| 202 | const threadStartParams = { | |
| 203 | ...sessionOverrides, | |
| 204 | experimentalRawEvents: false, | |
| 205 | }; | |
| 206 | ||
| 207 | let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start"; | |
| 208 | let threadOpenResponse: unknown; | |
| 209 | if (input.resumeThreadId) { | |
| 210 | try { | |
| 211 | threadOpenMethod = "thread/resume"; | |
| 212 | threadOpenResponse = await this.sendRequest( | |
| 213 | context, | |
| 214 | "thread/resume", | |
| 215 | { | |
| 216 | ...sessionOverrides, | |
| 217 | threadId: input.resumeThreadId, | |
| 218 | }, | |
| 219 | ); | |
| 220 | } catch (error) { | |
| 221 | if (!isRecoverableThreadResumeError(error)) { | |
| 222 | throw error; | |
| 223 | } | |
| 224 | ||
| 225 | threadOpenMethod = "thread/start"; | |
| 226 | this.emitLifecycleEvent( | |
| 227 | context, | |
| 228 | "session/threadResumeFallback", | |
| 229 | `Could not resume thread ${input.resumeThreadId}; started a new thread instead.`, | |
| 230 | ); | |
| 231 | threadOpenResponse = await this.sendRequest( | |
| 232 | context, | |
| 233 | "thread/start", | |
| 234 | threadStartParams, | |
| 235 | ); | |
| 236 | } | |
| 237 | } else { | |
| 238 | threadOpenMethod = "thread/start"; | |
| 239 | threadOpenResponse = await this.sendRequest( | |
| 240 | context, | |
| 241 | "thread/start", | |
| 242 | threadStartParams, | |
| 243 | ); | |
| 244 | } | |
| 245 | ||
| 246 | const threadOpenRecord = this.readObject(threadOpenResponse); | |
| 247 | const threadId = | |
| 248 | this.readString(this.readObject(threadOpenRecord, "thread"), "id") ?? | |
| 249 | this.readString(threadOpenRecord, "threadId"); | |
| 250 | if (!threadId) { | |
| 251 | throw new Error( | |
| 252 | `${threadOpenMethod} response did not include a thread id.`, | |
| 253 | ); | |
| 254 | } | |
| 255 | ||
| 256 | this.updateSession(context, { | |
| 257 | status: "ready", | |
| 258 | threadId, | |
| 259 | }); | |
| 260 | this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`); | |
| 261 | return { ...context.session }; | |
| 262 | } catch (error) { | |
| 263 | const message = error instanceof Error ? error.message : "Failed to start Codex session."; | |
| 264 | this.updateSession(context, { | |
| 265 | status: "error", | |
| 266 | lastError: message, | |
| 267 | }); | |
| 268 | this.emitErrorEvent(context, "session/startFailed", message); | |
| 269 | this.stopSession(sessionId); | |
| 270 | throw new Error(message, { cause: error }); | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | async sendTurn(input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> { | |
| 275 | const context = this.requireSession(input.sessionId); | |
| 276 | if (!context.session.threadId) { | |
| 277 | throw new Error("Session is missing a thread id."); | |
| 278 | } | |
| 279 | ||
| 280 | const turnStartParams: { | |
| 281 | threadId: string; | |
| 282 | input: Array<{ type: "text"; text: string; text_elements: [] }>; | |
| 283 | model?: string; | |
| 284 | effort?: string; | |
| 285 | } = { | |
| 286 | threadId: context.session.threadId, | |
| 287 | input: [ | |
| 288 | { | |
| 289 | type: "text", | |
| 290 | text: input.input, | |
| 291 | text_elements: [], | |
| 292 | }, | |
| 293 | ], | |
| 294 | }; | |
| 295 | const normalizedModel = normalizeCodexModelSlug(input.model); | |
| 296 | if (normalizedModel) { | |
| 297 | turnStartParams.model = normalizedModel; | |
| 298 | } | |
| 299 | if (input.effort) { | |
| 300 | turnStartParams.effort = input.effort; | |
| 301 | } | |
| 302 | ||
| 303 | const response = await this.sendRequest(context, "turn/start", turnStartParams); | |
| 304 | ||
| 305 | const turn = this.readObject(this.readObject(response), "turn"); | |
| 306 | const turnId = this.readString(turn, "id"); | |
| 307 | if (!turnId) { | |
| 308 | throw new Error("turn/start response did not include a turn id."); | |
| 309 | } | |
| 310 | ||
| 311 | this.updateSession(context, { | |
| 312 | status: "running", | |
| 313 | activeTurnId: turnId, | |
| 314 | }); | |
| 315 | ||
| 316 | return { | |
| 317 | threadId: context.session.threadId, | |
| 318 | turnId, | |
| 319 | }; | |
| 320 | } | |
| 321 | ||
| 322 | async interruptTurn(sessionId: string, turnId?: string): Promise<void> { | |
| 323 | const context = this.requireSession(sessionId); | |
| 324 | const effectiveTurnId = turnId ?? context.session.activeTurnId; | |
| 325 | ||
| 326 | if (!effectiveTurnId || !context.session.threadId) { | |
| 327 | return; | |
| 328 | } | |
| 329 | ||
| 330 | await this.sendRequest(context, "turn/interrupt", { | |
| 331 | threadId: context.session.threadId, | |
| 332 | turnId: effectiveTurnId, | |
| 333 | }); | |
| 334 | } | |
| 335 | ||
| 336 | async respondToRequest( | |
| 337 | sessionId: string, | |
| 338 | requestId: string, | |
| 339 | decision: ProviderApprovalDecision, | |
| 340 | ): Promise<void> { | |
| 341 | const context = this.requireSession(sessionId); | |
| 342 | const pendingRequest = context.pendingApprovals.get(requestId); | |
| 343 | if (!pendingRequest) { | |
| 344 | throw new Error(`Unknown pending approval request: ${requestId}`); | |
| 345 | } | |
| 346 | ||
| 347 | context.pendingApprovals.delete(requestId); | |
| 348 | this.writeMessage(context, { | |
| 349 | id: pendingRequest.jsonRpcId, | |
| 350 | result: { | |
| 351 | decision, | |
| 352 | }, | |
| 353 | }); | |
| 354 | ||
| 355 | this.emitEvent({ | |
| 356 | id: randomUUID(), | |
| 357 | kind: "notification", | |
| 358 | provider: "codex", | |
| 359 | sessionId: context.session.sessionId, | |
| 360 | createdAt: new Date().toISOString(), | |
| 361 | method: "item/requestApproval/decision", | |
| 362 | threadId: pendingRequest.threadId, | |
| 363 | turnId: pendingRequest.turnId, | |
| 364 | itemId: pendingRequest.itemId, | |
| 365 | requestId: pendingRequest.requestId, | |
| 366 | requestKind: pendingRequest.requestKind, | |
| 367 | payload: { | |
| 368 | requestId: pendingRequest.requestId, | |
| 369 | requestKind: pendingRequest.requestKind, | |
| 370 | decision, | |
| 371 | }, | |
| 372 | }); | |
| 373 | } | |
| 374 | ||
| 375 | stopSession(sessionId: string): void { | |
| 376 | const context = this.sessions.get(sessionId); | |
| 377 | if (!context) { | |
| 378 | return; | |
| 379 | } | |
| 380 | ||
| 381 | context.stopping = true; | |
| 382 | ||
| 383 | for (const pending of context.pending.values()) { | |
| 384 | clearTimeout(pending.timeout); | |
| 385 | pending.reject(new Error("Session stopped before request completed.")); | |
| 386 | } | |
| 387 | context.pending.clear(); | |
| 388 | context.pendingApprovals.clear(); | |
| 389 | ||
| 390 | context.output.close(); | |
| 391 | ||
| 392 | if (!context.child.killed) { | |
| 393 | context.child.kill(); | |
| 394 | } | |
| 395 | ||
| 396 | this.updateSession(context, { | |
| 397 | status: "closed", | |
| 398 | activeTurnId: undefined, | |
| 399 | }); | |
| 400 | this.emitLifecycleEvent(context, "session/closed", "Session stopped"); | |
| 401 | this.sessions.delete(sessionId); | |
| 402 | } | |
| 403 | ||
| 404 | listSessions(): ProviderSession[] { | |
| 405 | return Array.from(this.sessions.values(), ({ session }) => ({ | |
| 406 | ...session, | |
| 407 | })); | |
| 408 | } | |
| 409 | ||
| 410 | hasSession(sessionId: string): boolean { | |
| 411 | return this.sessions.has(sessionId); | |
| 412 | } | |
| 413 | ||
| 414 | stopAll(): void { | |
| 415 | for (const sessionId of this.sessions.keys()) { | |
| 416 | this.stopSession(sessionId); | |
| 417 | } | |
| 418 | } | |
| 419 | ||
| 420 | private requireSession(sessionId: string): CodexSessionContext { | |
| 421 | const context = this.sessions.get(sessionId); | |
| 422 | if (!context) { | |
| 423 | throw new Error(`Unknown session: ${sessionId}`); | |
| 424 | } | |
| 425 | ||
| 426 | if (context.session.status === "closed") { | |
| 427 | throw new Error(`Session is closed: ${sessionId}`); | |
| 428 | } | |
| 429 | ||
| 430 | return context; | |
| 431 | } | |
| 432 | ||
| 433 | private attachProcessListeners(context: CodexSessionContext): void { | |
| 434 | context.output.on("line", (line) => { | |
| 435 | this.handleStdoutLine(context, line); | |
| 436 | }); | |
| 437 | ||
| 438 | context.child.stderr.on("data", (chunk: Buffer) => { | |
| 439 | const raw = chunk.toString(); | |
| 440 | const lines = raw.split(/\r?\n/g); | |
| 441 | for (const rawLine of lines) { | |
| 442 | const classified = classifyCodexStderrLine(rawLine); | |
| 443 | if (!classified) { | |
| 444 | continue; | |
| 445 | } | |
| 446 | ||
| 447 | this.emitErrorEvent(context, "process/stderr", classified.message); | |
| 448 | } | |
| 449 | }); | |
| 450 | ||
| 451 | context.child.on("error", (error) => { | |
| 452 | const message = error.message || "codex app-server process errored."; | |
| 453 | this.updateSession(context, { | |
| 454 | status: "error", | |
| 455 | lastError: message, | |
| 456 | }); | |
| 457 | this.emitErrorEvent(context, "process/error", message); | |
| 458 | }); | |
| 459 | ||
| 460 | context.child.on("exit", (code, signal) => { | |
| 461 | if (context.stopping) { | |
| 462 | return; | |
| 463 | } | |
| 464 | ||
| 465 | const message = `codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`; | |
| 466 | this.updateSession(context, { | |
| 467 | status: "closed", | |
| 468 | activeTurnId: undefined, | |
| 469 | lastError: code === 0 ? context.session.lastError : message, | |
| 470 | }); | |
| 471 | this.emitLifecycleEvent(context, "session/exited", message); | |
| 472 | this.sessions.delete(context.session.sessionId); | |
| 473 | }); | |
| 474 | } | |
| 475 | ||
| 476 | private handleStdoutLine(context: CodexSessionContext, line: string): void { | |
| 477 | let parsed: unknown; | |
| 478 | try { | |
| 479 | parsed = JSON.parse(line); | |
| 480 | } catch { | |
| 481 | this.emitErrorEvent( | |
| 482 | context, | |
| 483 | "protocol/parseError", | |
| 484 | "Received invalid JSON from codex app-server.", | |
| 485 | ); | |
| 486 | return; | |
| 487 | } | |
| 488 | ||
| 489 | if (!parsed || typeof parsed !== "object") { | |
| 490 | this.emitErrorEvent( | |
| 491 | context, | |
| 492 | "protocol/invalidMessage", | |
| 493 | "Received non-object protocol message.", | |
| 494 | ); | |
| 495 | return; | |
| 496 | } | |
| 497 | ||
| 498 | if (this.isServerRequest(parsed)) { | |
| 499 | this.handleServerRequest(context, parsed); | |
| 500 | return; | |
| 501 | } | |
| 502 | ||
| 503 | if (this.isServerNotification(parsed)) { | |
| 504 | this.handleServerNotification(context, parsed); | |
| 505 | return; | |
| 506 | } | |
| 507 | ||
| 508 | if (this.isResponse(parsed)) { | |
| 509 | this.handleResponse(context, parsed); | |
| 510 | return; | |
| 511 | } | |
| 512 | ||
| 513 | this.emitErrorEvent( | |
| 514 | context, | |
| 515 | "protocol/unrecognizedMessage", | |
| 516 | "Received protocol message in an unknown shape.", | |
| 517 | ); | |
| 518 | } | |
| 519 | ||
| 520 | private handleServerNotification( | |
| 521 | context: CodexSessionContext, | |
| 522 | notification: JsonRpcNotification, | |
| 523 | ): void { | |
| 524 | const route = this.readRouteFields(notification.params); | |
| 525 | const textDelta = | |
| 526 | notification.method === "item/agentMessage/delta" | |
| 527 | ? this.readString(notification.params, "delta") | |
| 528 | : undefined; | |
| 529 | ||
| 530 | this.emitEvent({ | |
| 531 | id: randomUUID(), | |
| 532 | kind: "notification", | |
| 533 | provider: "codex", | |
| 534 | sessionId: context.session.sessionId, | |
| 535 | createdAt: new Date().toISOString(), | |
| 536 | method: notification.method, | |
| 537 | threadId: route.threadId, | |
| 538 | turnId: route.turnId, | |
| 539 | itemId: route.itemId, | |
| 540 | textDelta, | |
| 541 | payload: notification.params, | |
| 542 | }); | |
| 543 | ||
| 544 | if (notification.method === "thread/started") { | |
| 545 | const threadId = this.readString(this.readObject(notification.params)?.thread, "id"); | |
| 546 | if (threadId) { | |
| 547 | this.updateSession(context, { threadId }); | |
| 548 | } | |
| 549 | return; | |
| 550 | } | |
| 551 | ||
| 552 | if (notification.method === "turn/started") { | |
| 553 | const turnId = this.readString(this.readObject(notification.params)?.turn, "id"); | |
| 554 | this.updateSession(context, { | |
| 555 | status: "running", | |
| 556 | activeTurnId: turnId, | |
| 557 | }); | |
| 558 | return; | |
| 559 | } | |
| 560 | ||
| 561 | if (notification.method === "turn/completed") { | |
| 562 | const turn = this.readObject(notification.params, "turn"); | |
| 563 | const status = this.readString(turn, "status"); | |
| 564 | const errorMessage = this.readString(this.readObject(turn, "error"), "message"); | |
| 565 | this.updateSession(context, { | |
| 566 | status: status === "failed" ? "error" : "ready", | |
| 567 | activeTurnId: undefined, | |
| 568 | lastError: errorMessage ?? context.session.lastError, | |
| 569 | }); | |
| 570 | return; | |
| 571 | } | |
| 572 | ||
| 573 | if (notification.method === "error") { | |
| 574 | const message = this.readString(this.readObject(notification.params)?.error, "message"); | |
| 575 | const willRetry = this.readBoolean(notification.params, "willRetry"); | |
| 576 | ||
| 577 | this.updateSession(context, { | |
| 578 | status: willRetry ? "running" : "error", | |
| 579 | lastError: message ?? context.session.lastError, | |
| 580 | }); | |
| 581 | } | |
| 582 | } | |
| 583 | ||
| 584 | private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void { | |
| 585 | const route = this.readRouteFields(request.params); | |
| 586 | const requestKind = this.requestKindForMethod(request.method); | |
| 587 | let requestId: string | undefined; | |
| 588 | if (requestKind) { | |
| 589 | requestId = randomUUID(); | |
| 590 | const pendingRequest: PendingApprovalRequest = { | |
| 591 | requestId, | |
| 592 | jsonRpcId: request.id, | |
| 593 | method: | |
| 594 | requestKind === "command" | |
| 595 | ? "item/commandExecution/requestApproval" | |
| 596 | : "item/fileChange/requestApproval", | |
| 597 | requestKind, | |
| 598 | ...(route.threadId ? { threadId: route.threadId } : {}), | |
| 599 | ...(route.turnId ? { turnId: route.turnId } : {}), | |
| 600 | ...(route.itemId ? { itemId: route.itemId } : {}), | |
| 601 | }; | |
| 602 | context.pendingApprovals.set(requestId, pendingRequest); | |
| 603 | } | |
| 604 | ||
| 605 | this.emitEvent({ | |
| 606 | id: randomUUID(), | |
| 607 | kind: "request", | |
| 608 | provider: "codex", | |
| 609 | sessionId: context.session.sessionId, | |
| 610 | createdAt: new Date().toISOString(), | |
| 611 | method: request.method, | |
| 612 | threadId: route.threadId, | |
| 613 | turnId: route.turnId, | |
| 614 | itemId: route.itemId, | |
| 615 | requestId, | |
| 616 | requestKind, | |
| 617 | payload: request.params, | |
| 618 | }); | |
| 619 | ||
| 620 | if (requestKind) { | |
| 621 | return; | |
| 622 | } | |
| 623 | ||
| 624 | if (request.method === "item/tool/requestUserInput") { | |
| 625 | this.writeMessage(context, { | |
| 626 | id: request.id, | |
| 627 | result: { answers: {} }, | |
| 628 | }); | |
| 629 | return; | |
| 630 | } | |
| 631 | ||
| 632 | this.writeMessage(context, { | |
| 633 | id: request.id, | |
| 634 | error: { | |
| 635 | code: -32601, | |
| 636 | message: `Unsupported server request: ${request.method}`, | |
| 637 | }, | |
| 638 | }); | |
| 639 | } | |
| 640 | ||
| 641 | private handleResponse(context: CodexSessionContext, response: JsonRpcResponse): void { | |
| 642 | const key = String(response.id); | |
| 643 | const pending = context.pending.get(key); | |
| 644 | if (!pending) { | |
| 645 | return; | |
| 646 | } | |
| 647 | ||
| 648 | clearTimeout(pending.timeout); | |
| 649 | context.pending.delete(key); | |
| 650 | ||
| 651 | if (response.error?.message) { | |
| 652 | pending.reject(new Error(`${pending.method} failed: ${String(response.error.message)}`)); | |
| 653 | return; | |
| 654 | } | |
| 655 | ||
| 656 | pending.resolve(response.result); | |
| 657 | } | |
| 658 | ||
| 659 | private async sendRequest<TResponse>( | |
| 660 | context: CodexSessionContext, | |
| 661 | method: string, | |
| 662 | params: unknown, | |
| 663 | timeoutMs = 20_000, | |
| 664 | ): Promise<TResponse> { | |
| 665 | const id = context.nextRequestId; | |
| 666 | context.nextRequestId += 1; | |
| 667 | ||
| 668 | const result = await new Promise<unknown>((resolve, reject) => { | |
| 669 | const timeout = setTimeout(() => { | |
| 670 | context.pending.delete(String(id)); | |
| 671 | reject(new Error(`Timed out waiting for ${method}.`)); | |
| 672 | }, timeoutMs); | |
| 673 | ||
| 674 | context.pending.set(String(id), { | |
| 675 | method, | |
| 676 | timeout, | |
| 677 | resolve, | |
| 678 | reject, | |
| 679 | }); | |
| 680 | this.writeMessage(context, { | |
| 681 | method, | |
| 682 | id, | |
| 683 | params, | |
| 684 | }); | |
| 685 | }); | |
| 686 | ||
| 687 | return result as TResponse; | |
| 688 | } | |
| 689 | ||
| 690 | private writeMessage(context: CodexSessionContext, message: unknown): void { | |
| 691 | const encoded = JSON.stringify(message); | |
| 692 | if (!context.child.stdin.writable) { | |
| 693 | throw new Error("Cannot write to codex app-server stdin."); | |
| 694 | } | |
| 695 | ||
| 696 | context.child.stdin.write(`${encoded}\n`); | |
| 697 | } | |
| 698 | ||
| 699 | private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void { | |
| 700 | this.emitEvent({ | |
| 701 | id: randomUUID(), | |
| 702 | kind: "session", | |
| 703 | provider: "codex", | |
| 704 | sessionId: context.session.sessionId, | |
| 705 | createdAt: new Date().toISOString(), | |
| 706 | method, | |
| 707 | message, | |
| 708 | }); | |
| 709 | } | |
| 710 | ||
| 711 | private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void { | |
| 712 | this.emitEvent({ | |
| 713 | id: randomUUID(), | |
| 714 | kind: "error", | |
| 715 | provider: "codex", | |
| 716 | sessionId: context.session.sessionId, | |
| 717 | createdAt: new Date().toISOString(), | |
| 718 | method, | |
| 719 | message, | |
| 720 | }); | |
| 721 | } | |
| 722 | ||
| 723 | private emitEvent(event: ProviderEvent): void { | |
| 724 | this.emit("event", event); | |
| 725 | } | |
| 726 | ||
| 727 | private updateSession(context: CodexSessionContext, updates: Partial<ProviderSession>): void { | |
| 728 | context.session = { | |
| 729 | ...context.session, | |
| 730 | ...updates, | |
| 731 | updatedAt: new Date().toISOString(), | |
| 732 | }; | |
| 733 | } | |
| 734 | ||
| 735 | private requestKindForMethod( | |
| 736 | method: string, | |
| 737 | ): ProviderRequestKind | undefined { | |
| 738 | if (method === "item/commandExecution/requestApproval") { | |
| 739 | return "command"; | |
| 740 | } | |
| 741 | ||
| 742 | if (method === "item/fileChange/requestApproval") { | |
| 743 | return "file-change"; | |
| 744 | } | |
| 745 | ||
| 746 | return undefined; | |
| 747 | } | |
| 748 | ||
| 749 | private isServerRequest(value: unknown): value is JsonRpcRequest { | |
| 750 | if (!value || typeof value !== "object") { | |
| 751 | return false; | |
| 752 | } | |
| 753 | ||
| 754 | const candidate = value as Record<string, unknown>; | |
| 755 | return ( | |
| 756 | typeof candidate.method === "string" && | |
| 757 | (typeof candidate.id === "string" || typeof candidate.id === "number") | |
| 758 | ); | |
| 759 | } | |
| 760 | ||
| 761 | private isServerNotification(value: unknown): value is JsonRpcNotification { | |
| 762 | if (!value || typeof value !== "object") { | |
| 763 | return false; | |
| 764 | } | |
| 765 | ||
| 766 | const candidate = value as Record<string, unknown>; | |
| 767 | return typeof candidate.method === "string" && !("id" in candidate); | |
| 768 | } | |
| 769 | ||
| 770 | private isResponse(value: unknown): value is JsonRpcResponse { | |
| 771 | if (!value || typeof value !== "object") { | |
| 772 | return false; | |
| 773 | } | |
| 774 | ||
| 775 | const candidate = value as Record<string, unknown>; | |
| 776 | const hasId = typeof candidate.id === "string" || typeof candidate.id === "number"; | |
| 777 | const hasMethod = typeof candidate.method === "string"; | |
| 778 | return hasId && !hasMethod; | |
| 779 | } | |
| 780 | ||
| 781 | private readRouteFields(params: unknown): { | |
| 782 | threadId?: string; | |
| 783 | turnId?: string; | |
| 784 | itemId?: string; | |
| 785 | } { | |
| 786 | const route: { | |
| 787 | threadId?: string; | |
| 788 | turnId?: string; | |
| 789 | itemId?: string; | |
| 790 | } = {}; | |
| 791 | ||
| 792 | const threadId = | |
| 793 | this.readString(params, "threadId") ?? | |
| 794 | this.readString(this.readObject(params, "thread"), "id"); | |
| 795 | const turnId = | |
| 796 | this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"); | |
| 797 | const itemId = | |
| 798 | this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"); | |
| 799 | ||
| 800 | if (threadId) { | |
| 801 | route.threadId = threadId; | |
| 802 | } | |
| 803 | ||
| 804 | if (turnId) { | |
| 805 | route.turnId = turnId; | |
| 806 | } | |
| 807 | ||
| 808 | if (itemId) { | |
| 809 | route.itemId = itemId; | |
| 810 | } | |
| 811 | ||
| 812 | return route; | |
| 813 | } | |
| 814 | ||
| 815 | private readObject(value: unknown, key?: string): Record<string, unknown> | undefined { | |
| 816 | const target = | |
| 817 | key === undefined | |
| 818 | ? value | |
| 819 | : value && typeof value === "object" | |
| 820 | ? (value as Record<string, unknown>)[key] | |
| 821 | : undefined; | |
| 822 | ||
| 823 | if (!target || typeof target !== "object") { | |
| 824 | return undefined; | |
| 825 | } | |
| 826 | ||
| 827 | return target as Record<string, unknown>; | |
| 828 | } | |
| 829 | ||
| 830 | private readString(value: unknown, key: string): string | undefined { | |
| 831 | if (!value || typeof value !== "object") { | |
| 832 | return undefined; | |
| 833 | } | |
| 834 | ||
| 835 | const candidate = (value as Record<string, unknown>)[key]; | |
| 836 | return typeof candidate === "string" ? candidate : undefined; | |
| 837 | } | |
| 838 | ||
| 839 | private readBoolean(value: unknown, key: string): boolean | undefined { | |
| 840 | if (!value || typeof value !== "object") { | |
| 841 | return undefined; | |
| 842 | } | |
| 843 | ||
| 844 | const candidate = (value as Record<string, unknown>)[key]; | |
| 845 | return typeof candidate === "boolean" ? candidate : undefined; | |
| 846 | } | |
| 847 | } |
apps/desktop/src/fixPath.ts+1-1| ... | ... | @@ -13,6 +13,6 @@ export function fixPath(): void { |
| 13 | 13 | process.env.PATH = result; |
| 14 | 14 | } |
| 15 | 15 | } catch { |
| 16 | // Silently ignore — keep default PATH | |
| 16 | // Keep inherited PATH if shell lookup fails. | |
| 17 | 17 | } |
| 18 | 18 | } |
apps/desktop/src/ipcHelpers.test.ts deleted-104| ... | ... | @@ -1,104 +0,0 @@ |
| 1 | import { describe, expect, it, vi } from "vitest"; | |
| 2 | ||
| 3 | import { withParsedArgs, withParsedPayload } from "./ipcHelpers"; | |
| 4 | ||
| 5 | describe("withParsedPayload", () => { | |
| 6 | it("parses payload and passes typed input to handler", async () => { | |
| 7 | const handler = vi.fn(async (_event: unknown, payload: { value: string }) => | |
| 8 | payload.value.toUpperCase(), | |
| 9 | ); | |
| 10 | const wrapped = withParsedPayload( | |
| 11 | { | |
| 12 | parse(payload: unknown): { value: string } { | |
| 13 | if ( | |
| 14 | !payload || | |
| 15 | typeof payload !== "object" || | |
| 16 | typeof (payload as { value?: unknown }).value !== "string" | |
| 17 | ) { | |
| 18 | throw new Error("Invalid payload"); | |
| 19 | } | |
| 20 | ||
| 21 | return { value: (payload as { value: string }).value }; | |
| 22 | }, | |
| 23 | }, | |
| 24 | handler, | |
| 25 | ); | |
| 26 | ||
| 27 | const result = await wrapped({}, { value: "hello" }); | |
| 28 | expect(result).toBe("HELLO"); | |
| 29 | expect(handler).toHaveBeenCalledWith({}, { value: "hello" }); | |
| 30 | }); | |
| 31 | ||
| 32 | it("throws and does not call handler on invalid payload", async () => { | |
| 33 | const handler = vi.fn(async () => "ok"); | |
| 34 | const wrapped = withParsedPayload( | |
| 35 | { | |
| 36 | parse(payload: unknown): { value: string } { | |
| 37 | if ( | |
| 38 | !payload || | |
| 39 | typeof payload !== "object" || | |
| 40 | typeof (payload as { value?: unknown }).value !== "string" | |
| 41 | ) { | |
| 42 | throw new Error("Invalid payload"); | |
| 43 | } | |
| 44 | ||
| 45 | return { value: (payload as { value: string }).value }; | |
| 46 | }, | |
| 47 | }, | |
| 48 | handler, | |
| 49 | ); | |
| 50 | ||
| 51 | expect(() => wrapped({}, { value: 123 })).toThrow(); | |
| 52 | expect(handler).not.toHaveBeenCalled(); | |
| 53 | }); | |
| 54 | }); | |
| 55 | ||
| 56 | describe("withParsedArgs", () => { | |
| 57 | it("parses tuple arguments before invoking handler", () => { | |
| 58 | const handler = vi.fn((_event: unknown, sessionId: string, data: string) => { | |
| 59 | return `${sessionId}:${data}`; | |
| 60 | }); | |
| 61 | const wrapped = withParsedArgs( | |
| 62 | { | |
| 63 | parse(args: unknown[]): [string, string] { | |
| 64 | const [sessionId, data] = args; | |
| 65 | if (typeof sessionId !== "string" || sessionId.length === 0) { | |
| 66 | throw new Error("Invalid sessionId"); | |
| 67 | } | |
| 68 | if (typeof data !== "string") { | |
| 69 | throw new Error("Invalid data"); | |
| 70 | } | |
| 71 | ||
| 72 | return [sessionId, data]; | |
| 73 | }, | |
| 74 | }, | |
| 75 | handler, | |
| 76 | ); | |
| 77 | ||
| 78 | expect(wrapped({}, "abc", "input")).toBe("abc:input"); | |
| 79 | expect(handler).toHaveBeenCalledWith({}, "abc", "input"); | |
| 80 | }); | |
| 81 | ||
| 82 | it("throws and does not call handler when args are invalid", () => { | |
| 83 | const handler = vi.fn(); | |
| 84 | const wrapped = withParsedArgs( | |
| 85 | { | |
| 86 | parse(args: unknown[]): [string, string] { | |
| 87 | const [sessionId, data] = args; | |
| 88 | if (typeof sessionId !== "string" || sessionId.length === 0) { | |
| 89 | throw new Error("Invalid sessionId"); | |
| 90 | } | |
| 91 | if (typeof data !== "string") { | |
| 92 | throw new Error("Invalid data"); | |
| 93 | } | |
| 94 | ||
| 95 | return [sessionId, data]; | |
| 96 | }, | |
| 97 | }, | |
| 98 | handler, | |
| 99 | ); | |
| 100 | ||
| 101 | expect(() => wrapped({}, 123, "input")).toThrow(); | |
| 102 | expect(handler).not.toHaveBeenCalled(); | |
| 103 | }); | |
| 104 | }); |
apps/desktop/src/ipcHelpers.ts deleted-24| ... | ... | @@ -1,24 +0,0 @@ |
| 1 | type MaybePromise<T> = T | Promise<T>; | |
| 2 | type Parser<T> = { | |
| 3 | parse: (value: unknown) => T; | |
| 4 | }; | |
| 5 | type ArgsParser<T extends unknown[]> = { | |
| 6 | parse: (value: unknown[]) => T; | |
| 7 | }; | |
| 8 | ||
| 9 | export function withParsedPayload<TPayload, TResult>( | |
| 10 | schema: Parser<TPayload>, | |
| 11 | handler: (event: unknown, payload: TPayload) => MaybePromise<TResult>, | |
| 12 | ): (event: unknown, payload: unknown) => MaybePromise<TResult> { | |
| 13 | return (event, payload) => handler(event, schema.parse(payload)); | |
| 14 | } | |
| 15 | ||
| 16 | export function withParsedArgs<TArgs extends unknown[], TResult>( | |
| 17 | schema: ArgsParser<TArgs>, | |
| 18 | handler: (event: unknown, ...args: TArgs) => MaybePromise<TResult>, | |
| 19 | ): (event: unknown, ...args: unknown[]) => MaybePromise<TResult> { | |
| 20 | return (event, ...args) => { | |
| 21 | const parsedArgs = schema.parse(args); | |
| 22 | return handler(event, ...parsedArgs); | |
| 23 | }; | |
| 24 | } |
apps/desktop/src/main.ts+156-290| ... | ... | @@ -1,111 +1,134 @@ |
| 1 | import { fixPath } from "./fixPath"; | |
| 2 | fixPath(); | |
| 3 | ||
| 4 | import { spawn } from "node:child_process"; | |
| 1 | import { spawn, type ChildProcess } from "node:child_process"; | |
| 2 | import { randomBytes } from "node:crypto"; | |
| 3 | import fs from "node:fs"; | |
| 4 | import net from "node:net"; | |
| 5 | import os from "node:os"; | |
| 5 | 6 | import path from "node:path"; |
| 6 | import { BrowserWindow, app, dialog, ipcMain, session, shell } from "electron"; | |
| 7 | 7 | |
| 8 | import { | |
| 9 | EDITORS, | |
| 10 | IPC_CHANNELS, | |
| 11 | type TerminalCommandInput, | |
| 12 | type TerminalCommandResult, | |
| 13 | agentConfigSchema, | |
| 14 | agentSessionIdSchema, | |
| 15 | newTodoInputSchema, | |
| 16 | providerInterruptTurnInputSchema, | |
| 17 | providerRespondToRequestInputSchema, | |
| 18 | providerSendTurnInputSchema, | |
| 19 | providerSessionStartInputSchema, | |
| 20 | providerStopSessionInputSchema, | |
| 21 | terminalCommandInputSchema, | |
| 22 | todoIdSchema, | |
| 23 | } from "@acme/contracts"; | |
| 24 | import { withParsedArgs, withParsedPayload } from "./ipcHelpers"; | |
| 25 | import { ProcessManager } from "./processManager"; | |
| 26 | import { ProviderManager } from "./providerManager"; | |
| 27 | import { TodoStore } from "./todoStore"; | |
| 8 | import { app, BrowserWindow, dialog, ipcMain } from "electron"; | |
| 28 | 9 | |
| 29 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | |
| 10 | import { fixPath } from "./fixPath"; | |
| 30 | 11 | |
| 31 | let todoStore: TodoStore; | |
| 32 | const processManager = new ProcessManager(); | |
| 33 | const providerManager = new ProviderManager(); | |
| 34 | const agentWriteArgsParser = { | |
| 35 | parse(args: unknown[]): [string, string] { | |
| 36 | const [sessionId, data] = args; | |
| 37 | if (typeof data !== "string") { | |
| 38 | throw new Error("agent:write data must be a string"); | |
| 39 | } | |
| 12 | fixPath(); | |
| 40 | 13 | |
| 41 | return [agentSessionIdSchema.parse(sessionId), data]; | |
| 42 | }, | |
| 43 | }; | |
| 14 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | |
| 15 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | |
| 16 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js"); | |
| 17 | const RENDERER_ENTRY = path.join(ROOT_DIR, "apps/renderer/dist/index.html"); | |
| 18 | const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); | |
| 19 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | |
| 44 | 20 | |
| 45 | function createWindow(): BrowserWindow { | |
| 46 | const window = new BrowserWindow({ | |
| 47 | width: 1100, | |
| 48 | height: 780, | |
| 49 | minWidth: 840, | |
| 50 | minHeight: 620, | |
| 51 | show: false, | |
| 52 | autoHideMenuBar: true, | |
| 53 | titleBarStyle: "hiddenInset", | |
| 54 | trafficLightPosition: { x: 16, y: 18 }, | |
| 55 | webPreferences: { | |
| 56 | preload: path.join(__dirname, "preload.js"), | |
| 57 | contextIsolation: true, | |
| 58 | nodeIntegration: false, | |
| 59 | sandbox: true, | |
| 60 | }, | |
| 21 | let mainWindow: BrowserWindow | null = null; | |
| 22 | let backendProcess: ChildProcess | null = null; | |
| 23 | let backendPort = 0; | |
| 24 | let backendAuthToken = ""; | |
| 25 | let backendWsUrl = ""; | |
| 26 | let restartAttempt = 0; | |
| 27 | let restartTimer: ReturnType<typeof setTimeout> | null = null; | |
| 28 | let isQuitting = false; | |
| 29 | ||
| 30 | async function reserveLoopbackPort(): Promise<number> { | |
| 31 | return new Promise((resolve, reject) => { | |
| 32 | const probe = net.createServer(); | |
| 33 | probe.listen(0, "127.0.0.1", () => { | |
| 34 | const address = probe.address(); | |
| 35 | const port = typeof address === "object" && address !== null ? address.port : 0; | |
| 36 | probe.close(() => { | |
| 37 | if (port > 0) { | |
| 38 | resolve(port); | |
| 39 | return; | |
| 40 | } | |
| 41 | reject(new Error("Failed to reserve backend port")); | |
| 42 | }); | |
| 43 | }); | |
| 44 | probe.on("error", reject); | |
| 61 | 45 | }); |
| 46 | } | |
| 62 | 47 | |
| 63 | window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); | |
| 48 | function backendEnv(): NodeJS.ProcessEnv { | |
| 49 | return { | |
| 50 | ...process.env, | |
| 51 | CODETHING_MODE: "desktop", | |
| 52 | CODETHING_NO_BROWSER: "1", | |
| 53 | CODETHING_PORT: String(backendPort), | |
| 54 | CODETHING_STATE_DIR: STATE_DIR, | |
| 55 | CODETHING_AUTH_TOKEN: backendAuthToken, | |
| 56 | }; | |
| 57 | } | |
| 64 | 58 | |
| 65 | window.once("ready-to-show", () => { | |
| 66 | window.show(); | |
| 67 | }); | |
| 59 | function scheduleBackendRestart(reason: string): void { | |
| 60 | if (isQuitting || restartTimer) return; | |
| 68 | 61 | |
| 69 | setupEventForwarding(window); | |
| 62 | const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000); | |
| 63 | restartAttempt += 1; | |
| 64 | console.error(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`); | |
| 70 | 65 | |
| 71 | if (isDevelopment) { | |
| 72 | void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); | |
| 73 | return window; | |
| 66 | restartTimer = setTimeout(() => { | |
| 67 | restartTimer = null; | |
| 68 | startBackend(); | |
| 69 | }, delayMs); | |
| 70 | } | |
| 71 | ||
| 72 | function startBackend(): void { | |
| 73 | if (isQuitting || backendProcess) return; | |
| 74 | ||
| 75 | if (!fs.existsSync(BACKEND_ENTRY)) { | |
| 76 | scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`); | |
| 77 | return; | |
| 74 | 78 | } |
| 75 | 79 | |
| 76 | void window.loadFile(path.join(__dirname, "../../renderer/dist/index.html")); | |
| 77 | return window; | |
| 78 | } | |
| 80 | const child = spawn(process.execPath, [BACKEND_ENTRY], { | |
| 81 | cwd: ROOT_DIR, | |
| 82 | env: backendEnv(), | |
| 83 | stdio: "inherit", | |
| 84 | }); | |
| 85 | backendProcess = child; | |
| 79 | 86 | |
| 80 | function registerIpcHandlers(): void { | |
| 81 | // Todo handlers | |
| 82 | ipcMain.handle(IPC_CHANNELS.todosList, async () => { | |
| 83 | return todoStore.list(); | |
| 87 | child.once("spawn", () => { | |
| 88 | restartAttempt = 0; | |
| 89 | }); | |
| 90 | ||
| 91 | child.on("error", (error) => { | |
| 92 | if (backendProcess === child) { | |
| 93 | backendProcess = null; | |
| 94 | } | |
| 95 | scheduleBackendRestart(error.message); | |
| 96 | }); | |
| 97 | ||
| 98 | child.on("exit", (code, signal) => { | |
| 99 | if (backendProcess === child) { | |
| 100 | backendProcess = null; | |
| 101 | } | |
| 102 | if (isQuitting) return; | |
| 103 | const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; | |
| 104 | scheduleBackendRestart(reason); | |
| 84 | 105 | }); |
| 106 | } | |
| 85 | 107 | |
| 86 | ipcMain.handle( | |
| 87 | IPC_CHANNELS.todosAdd, | |
| 88 | withParsedPayload(newTodoInputSchema, async (_event, payload) => { | |
| 89 | return todoStore.add(payload); | |
| 90 | }), | |
| 91 | ); | |
| 108 | function stopBackend(): void { | |
| 109 | if (restartTimer) { | |
| 110 | clearTimeout(restartTimer); | |
| 111 | restartTimer = null; | |
| 112 | } | |
| 92 | 113 | |
| 93 | ipcMain.handle( | |
| 94 | IPC_CHANNELS.todosToggle, | |
| 95 | withParsedPayload(todoIdSchema, async (_event, id) => { | |
| 96 | return todoStore.toggle(id); | |
| 97 | }), | |
| 98 | ); | |
| 114 | const child = backendProcess; | |
| 115 | backendProcess = null; | |
| 116 | if (!child) return; | |
| 99 | 117 | |
| 100 | ipcMain.handle( | |
| 101 | IPC_CHANNELS.todosRemove, | |
| 102 | withParsedPayload(todoIdSchema, async (_event, id) => { | |
| 103 | return todoStore.remove(id); | |
| 104 | }), | |
| 105 | ); | |
| 118 | if (!child.killed) { | |
| 119 | child.kill("SIGTERM"); | |
| 120 | setTimeout(() => { | |
| 121 | if (!child.killed) { | |
| 122 | child.kill("SIGKILL"); | |
| 123 | } | |
| 124 | }, 2_000).unref(); | |
| 125 | } | |
| 126 | } | |
| 106 | 127 | |
| 107 | ipcMain.handle(IPC_CHANNELS.dialogPickFolder, async () => { | |
| 108 | const owner = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; | |
| 128 | function registerIpcHandlers(): void { | |
| 129 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); | |
| 130 | ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { | |
| 131 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; | |
| 109 | 132 | const result = owner |
| 110 | 133 | ? await dialog.showOpenDialog(owner, { |
| 111 | 134 | properties: ["openDirectory", "createDirectory"], |
| ... | ... | @@ -113,233 +136,76 @@ function registerIpcHandlers(): void { |
| 113 | 136 | : await dialog.showOpenDialog({ |
| 114 | 137 | properties: ["openDirectory", "createDirectory"], |
| 115 | 138 | }); |
| 116 | ||
| 117 | 139 | if (result.canceled) return null; |
| 118 | 140 | return result.filePaths[0] ?? null; |
| 119 | 141 | }); |
| 142 | } | |
| 120 | 143 | |
| 121 | // Terminal handlers | |
| 122 | ipcMain.handle( | |
| 123 | IPC_CHANNELS.terminalRun, | |
| 124 | withParsedPayload(terminalCommandInputSchema, async (_event, payload) => { | |
| 125 | return runTerminalCommand(payload); | |
| 126 | }), | |
| 127 | ); | |
| 128 | ||
| 129 | // Shell handlers | |
| 130 | ipcMain.handle( | |
| 131 | IPC_CHANNELS.shellOpenInEditor, | |
| 132 | async (_event, cwd: string, editor: string) => { | |
| 133 | if (!cwd) throw new Error("cwd is required"); | |
| 134 | const editorDef = EDITORS.find((e) => e.id === editor); | |
| 135 | if (!editorDef) throw new Error(`Unknown editor: ${editor}`); | |
| 136 | if (!editorDef.command) { | |
| 137 | const error = await shell.openPath(cwd); | |
| 138 | if (error) throw new Error(error); | |
| 139 | return; | |
| 140 | } | |
| 141 | const child = spawn(editorDef.command, [cwd], { | |
| 142 | detached: true, | |
| 143 | stdio: "ignore", | |
| 144 | }); | |
| 145 | child.on("error", () => { | |
| 146 | /* ignore spawn failures for detached editors */ | |
| 147 | }); | |
| 148 | child.unref(); | |
| 144 | function createWindow(): BrowserWindow { | |
| 145 | const window = new BrowserWindow({ | |
| 146 | width: 1100, | |
| 147 | height: 780, | |
| 148 | minWidth: 840, | |
| 149 | minHeight: 620, | |
| 150 | show: false, | |
| 151 | autoHideMenuBar: true, | |
| 152 | titleBarStyle: "hiddenInset", | |
| 153 | trafficLightPosition: { x: 16, y: 18 }, | |
| 154 | webPreferences: { | |
| 155 | preload: path.join(__dirname, "preload.js"), | |
| 156 | contextIsolation: true, | |
| 157 | nodeIntegration: false, | |
| 158 | sandbox: true, | |
| 149 | 159 | }, |
| 150 | ); | |
| 151 | ||
| 152 | // Agent handlers | |
| 153 | ipcMain.handle( | |
| 154 | IPC_CHANNELS.agentSpawn, | |
| 155 | withParsedPayload(agentConfigSchema, async (_event, config) => { | |
| 156 | return processManager.spawn(config); | |
| 157 | }), | |
| 158 | ); | |
| 159 | ||
| 160 | ipcMain.handle( | |
| 161 | IPC_CHANNELS.agentKill, | |
| 162 | withParsedPayload(agentSessionIdSchema, async (_event, sessionId) => { | |
| 163 | processManager.kill(sessionId); | |
| 164 | }), | |
| 165 | ); | |
| 166 | ||
| 167 | ipcMain.handle( | |
| 168 | IPC_CHANNELS.agentWrite, | |
| 169 | withParsedArgs(agentWriteArgsParser, async (_event, sessionId, data) => { | |
| 170 | processManager.write(sessionId, data); | |
| 171 | }), | |
| 172 | ); | |
| 173 | ||
| 174 | // Provider handlers | |
| 175 | ipcMain.handle( | |
| 176 | IPC_CHANNELS.providerSessionStart, | |
| 177 | withParsedPayload(providerSessionStartInputSchema, async (_event, payload) => { | |
| 178 | return providerManager.startSession(payload); | |
| 179 | }), | |
| 180 | ); | |
| 181 | ||
| 182 | ipcMain.handle( | |
| 183 | IPC_CHANNELS.providerTurnStart, | |
| 184 | withParsedPayload(providerSendTurnInputSchema, async (_event, payload) => { | |
| 185 | return providerManager.sendTurn(payload); | |
| 186 | }), | |
| 187 | ); | |
| 188 | ||
| 189 | ipcMain.handle( | |
| 190 | IPC_CHANNELS.providerTurnInterrupt, | |
| 191 | withParsedPayload(providerInterruptTurnInputSchema, async (_event, payload) => { | |
| 192 | await providerManager.interruptTurn(payload); | |
| 193 | }), | |
| 194 | ); | |
| 195 | ||
| 196 | ipcMain.handle( | |
| 197 | IPC_CHANNELS.providerRequestRespond, | |
| 198 | withParsedPayload( | |
| 199 | providerRespondToRequestInputSchema, | |
| 200 | async (_event, payload) => { | |
| 201 | await providerManager.respondToRequest(payload); | |
| 202 | }, | |
| 203 | ), | |
| 204 | ); | |
| 205 | ||
| 206 | ipcMain.handle( | |
| 207 | IPC_CHANNELS.providerSessionStop, | |
| 208 | withParsedPayload(providerStopSessionInputSchema, async (_event, payload) => { | |
| 209 | providerManager.stopSession(payload); | |
| 210 | }), | |
| 211 | ); | |
| 212 | ||
| 213 | ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { | |
| 214 | return providerManager.listSessions(); | |
| 215 | 160 | }); |
| 216 | } | |
| 217 | ||
| 218 | async function runTerminalCommand(input: TerminalCommandInput): Promise<TerminalCommandResult> { | |
| 219 | const shellPath = | |
| 220 | process.platform === "win32" | |
| 221 | ? (process.env.ComSpec ?? "cmd.exe") | |
| 222 | : (process.env.SHELL ?? "/bin/sh"); | |
| 223 | ||
| 224 | const args = | |
| 225 | process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; | |
| 226 | ||
| 227 | return new Promise((resolve, reject) => { | |
| 228 | const child = spawn(shellPath, args, { | |
| 229 | cwd: input.cwd, | |
| 230 | env: process.env, | |
| 231 | stdio: ["ignore", "pipe", "pipe"], | |
| 232 | }); | |
| 233 | ||
| 234 | let stdout = ""; | |
| 235 | let stderr = ""; | |
| 236 | let timedOut = false; | |
| 237 | ||
| 238 | const timeout = setTimeout(() => { | |
| 239 | timedOut = true; | |
| 240 | child.kill("SIGTERM"); | |
| 241 | setTimeout(() => { | |
| 242 | if (!child.killed) { | |
| 243 | child.kill("SIGKILL"); | |
| 244 | } | |
| 245 | }, 1_000).unref(); | |
| 246 | }, input.timeoutMs ?? 30_000); | |
| 247 | ||
| 248 | child.stdout?.on("data", (chunk: Buffer) => { | |
| 249 | stdout += chunk.toString(); | |
| 250 | }); | |
| 251 | ||
| 252 | child.stderr?.on("data", (chunk: Buffer) => { | |
| 253 | stderr += chunk.toString(); | |
| 254 | }); | |
| 255 | ||
| 256 | child.on("error", (error) => { | |
| 257 | clearTimeout(timeout); | |
| 258 | reject(error); | |
| 259 | }); | |
| 260 | 161 | |
| 261 | child.on("close", (code, signal) => { | |
| 262 | clearTimeout(timeout); | |
| 263 | resolve({ | |
| 264 | stdout, | |
| 265 | stderr, | |
| 266 | code: code ?? null, | |
| 267 | signal: signal ?? null, | |
| 268 | timedOut, | |
| 269 | }); | |
| 270 | }); | |
| 162 | window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); | |
| 163 | window.once("ready-to-show", () => { | |
| 164 | window.show(); | |
| 271 | 165 | }); |
| 272 | } | |
| 273 | ||
| 274 | function setupEventForwarding(window: BrowserWindow): void { | |
| 275 | const onOutput = (chunk: unknown) => { | |
| 276 | if (!window.isDestroyed()) { | |
| 277 | window.webContents.send(IPC_CHANNELS.agentOutput, chunk); | |
| 278 | } | |
| 279 | }; | |
| 280 | 166 | |
| 281 | const onExit = (exit: unknown) => { | |
| 282 | if (!window.isDestroyed()) { | |
| 283 | window.webContents.send(IPC_CHANNELS.agentExit, exit); | |
| 284 | } | |
| 285 | }; | |
| 286 | ||
| 287 | const onProviderEvent = (event: unknown) => { | |
| 288 | if (!window.isDestroyed()) { | |
| 289 | window.webContents.send(IPC_CHANNELS.providerEvent, event); | |
| 167 | if (isDevelopment) { | |
| 168 | void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); | |
| 169 | } else { | |
| 170 | if (!fs.existsSync(RENDERER_ENTRY)) { | |
| 171 | throw new Error(`Renderer bundle missing at ${RENDERER_ENTRY}`); | |
| 290 | 172 | } |
| 291 | }; | |
| 292 | ||
| 293 | processManager.on("output", onOutput); | |
| 294 | processManager.on("exit", onExit); | |
| 295 | providerManager.on("event", onProviderEvent); | |
| 173 | void window.loadFile(RENDERER_ENTRY); | |
| 174 | } | |
| 296 | 175 | |
| 297 | 176 | window.on("closed", () => { |
| 298 | processManager.off("output", onOutput); | |
| 299 | processManager.off("exit", onExit); | |
| 300 | providerManager.off("event", onProviderEvent); | |
| 177 | if (mainWindow === window) { | |
| 178 | mainWindow = null; | |
| 179 | } | |
| 301 | 180 | }); |
| 302 | } | |
| 303 | ||
| 304 | function setupCSP(): void { | |
| 305 | session.defaultSession.webRequest.onHeadersReceived((details, callback) => { | |
| 306 | const csp = isDevelopment | |
| 307 | ? "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* http://localhost:*" | |
| 308 | : "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"; | |
| 309 | 181 | |
| 310 | callback({ | |
| 311 | responseHeaders: { | |
| 312 | ...details.responseHeaders, | |
| 313 | "Content-Security-Policy": [csp], | |
| 314 | }, | |
| 315 | }); | |
| 316 | }); | |
| 182 | return window; | |
| 317 | 183 | } |
| 318 | 184 | |
| 319 | 185 | async function bootstrap(): Promise<void> { |
| 320 | setupCSP(); | |
| 321 | ||
| 322 | todoStore = new TodoStore(path.join(app.getPath("userData"), "todos.json")); | |
| 323 | await todoStore.init(); | |
| 186 | backendPort = await reserveLoopbackPort(); | |
| 187 | backendAuthToken = randomBytes(24).toString("hex"); | |
| 188 | backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; | |
| 189 | process.env.CODETHING_DESKTOP_WS_URL = backendWsUrl; | |
| 324 | 190 | |
| 325 | 191 | registerIpcHandlers(); |
| 326 | createWindow(); | |
| 327 | ||
| 328 | app.on("activate", () => { | |
| 329 | if (BrowserWindow.getAllWindows().length === 0) { | |
| 330 | createWindow(); | |
| 331 | } | |
| 332 | }); | |
| 192 | startBackend(); | |
| 193 | mainWindow = createWindow(); | |
| 333 | 194 | } |
| 334 | 195 | |
| 335 | 196 | app.on("before-quit", () => { |
| 336 | processManager.killAll(); | |
| 337 | providerManager.stopAll(); | |
| 338 | providerManager.dispose(); | |
| 197 | isQuitting = true; | |
| 198 | stopBackend(); | |
| 339 | 199 | }); |
| 340 | 200 | |
| 341 | 201 | app.whenReady().then(() => { |
| 342 | 202 | void bootstrap(); |
| 203 | ||
| 204 | app.on("activate", () => { | |
| 205 | if (BrowserWindow.getAllWindows().length === 0) { | |
| 206 | mainWindow = createWindow(); | |
| 207 | } | |
| 208 | }); | |
| 343 | 209 | }); |
| 344 | 210 | |
| 345 | 211 | app.on("window-all-closed", () => { |
apps/desktop/src/preload.ts+6-52| ... | ... | @@ -1,55 +1,9 @@ |
| 1 | 1 | import { contextBridge, ipcRenderer } from "electron"; |
| 2 | 2 | |
| 3 | import { IPC_CHANNELS, type NativeApi } from "@acme/contracts"; | |
| 3 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | |
| 4 | const wsUrl = process.env.CODETHING_DESKTOP_WS_URL ?? null; | |
| 4 | 5 | |
| 5 | const nativeApi: NativeApi = { | |
| 6 | todos: { | |
| 7 | list: () => ipcRenderer.invoke(IPC_CHANNELS.todosList), | |
| 8 | add: (input) => ipcRenderer.invoke(IPC_CHANNELS.todosAdd, input), | |
| 9 | toggle: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosToggle, id), | |
| 10 | remove: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosRemove, id), | |
| 11 | }, | |
| 12 | dialogs: { | |
| 13 | pickFolder: () => ipcRenderer.invoke(IPC_CHANNELS.dialogPickFolder), | |
| 14 | }, | |
| 15 | terminal: { | |
| 16 | run: (input) => ipcRenderer.invoke(IPC_CHANNELS.terminalRun, input), | |
| 17 | }, | |
| 18 | agent: { | |
| 19 | spawn: (config) => ipcRenderer.invoke(IPC_CHANNELS.agentSpawn, config), | |
| 20 | kill: (sessionId) => ipcRenderer.invoke(IPC_CHANNELS.agentKill, sessionId), | |
| 21 | write: (sessionId, data) => ipcRenderer.invoke(IPC_CHANNELS.agentWrite, sessionId, data), | |
| 22 | onOutput: (callback) => { | |
| 23 | const listener = (_event: Electron.IpcRendererEvent, chunk: unknown) => | |
| 24 | callback(chunk as Parameters<typeof callback>[0]); | |
| 25 | ipcRenderer.on(IPC_CHANNELS.agentOutput, listener); | |
| 26 | return () => ipcRenderer.removeListener(IPC_CHANNELS.agentOutput, listener); | |
| 27 | }, | |
| 28 | onExit: (callback) => { | |
| 29 | const listener = (_event: Electron.IpcRendererEvent, exit: unknown) => | |
| 30 | callback(exit as Parameters<typeof callback>[0]); | |
| 31 | ipcRenderer.on(IPC_CHANNELS.agentExit, listener); | |
| 32 | return () => ipcRenderer.removeListener(IPC_CHANNELS.agentExit, listener); | |
| 33 | }, | |
| 34 | }, | |
| 35 | providers: { | |
| 36 | startSession: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerSessionStart, input), | |
| 37 | sendTurn: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerTurnStart, input), | |
| 38 | interruptTurn: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerTurnInterrupt, input), | |
| 39 | respondToRequest: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerRequestRespond, input), | |
| 40 | stopSession: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), | |
| 41 | listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), | |
| 42 | onEvent: (callback) => { | |
| 43 | const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => | |
| 44 | callback(payload as Parameters<typeof callback>[0]); | |
| 45 | ipcRenderer.on(IPC_CHANNELS.providerEvent, listener); | |
| 46 | return () => ipcRenderer.removeListener(IPC_CHANNELS.providerEvent, listener); | |
| 47 | }, | |
| 48 | }, | |
| 49 | shell: { | |
| 50 | openInEditor: (cwd: string, editor: string) => | |
| 51 | ipcRenderer.invoke(IPC_CHANNELS.shellOpenInEditor, cwd, editor), | |
| 52 | }, | |
| 53 | }; | |
| 54 | ||
| 55 | contextBridge.exposeInMainWorld("nativeApi", nativeApi); | |
| 6 | contextBridge.exposeInMainWorld("desktopBridge", { | |
| 7 | getWsUrl: () => wsUrl, | |
| 8 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>, | |
| 9 | }); |
apps/desktop/src/processManager.test.ts deleted-48| ... | ... | @@ -1,48 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { ProcessManager } from "./processManager"; | |
| 4 | ||
| 5 | describe("ProcessManager", () => { | |
| 6 | it("can be instantiated", () => { | |
| 7 | const pm = new ProcessManager(); | |
| 8 | expect(pm).toBeInstanceOf(ProcessManager); | |
| 9 | }); | |
| 10 | ||
| 11 | it("spawns a process and receives output", async () => { | |
| 12 | const pm = new ProcessManager(); | |
| 13 | const chunks: string[] = []; | |
| 14 | ||
| 15 | const done = new Promise<void>((resolve) => { | |
| 16 | pm.on("output", (chunk) => { | |
| 17 | chunks.push(chunk.data); | |
| 18 | }); | |
| 19 | pm.on("exit", () => { | |
| 20 | resolve(); | |
| 21 | }); | |
| 22 | }); | |
| 23 | ||
| 24 | const sessionId = pm.spawn({ command: "echo", args: ["hello"] }); | |
| 25 | expect(typeof sessionId).toBe("string"); | |
| 26 | expect(sessionId.length).toBeGreaterThan(0); | |
| 27 | ||
| 28 | await done; | |
| 29 | ||
| 30 | expect(chunks.join("").trim()).toBe("hello"); | |
| 31 | }); | |
| 32 | ||
| 33 | it("can kill a spawned process", async () => { | |
| 34 | const pm = new ProcessManager(); | |
| 35 | ||
| 36 | const exited = new Promise<number | null>((resolve) => { | |
| 37 | pm.on("exit", (exit) => { | |
| 38 | resolve(exit.code); | |
| 39 | }); | |
| 40 | }); | |
| 41 | ||
| 42 | const sessionId = pm.spawn({ command: "sleep", args: ["10"] }); | |
| 43 | pm.kill(sessionId); | |
| 44 | ||
| 45 | const code = await exited; | |
| 46 | expect(code).not.toBe(0); | |
| 47 | }); | |
| 48 | }); |
apps/desktop/src/processManager.ts deleted-144| ... | ... | @@ -1,144 +0,0 @@ |
| 1 | import { type ChildProcess, spawn } from "node:child_process"; | |
| 2 | import { randomUUID } from "node:crypto"; | |
| 3 | import { EventEmitter } from "node:events"; | |
| 4 | ||
| 5 | import { | |
| 6 | type AgentConfig, | |
| 7 | type AgentExit, | |
| 8 | type OutputChunk, | |
| 9 | agentConfigSchema, | |
| 10 | } from "@acme/contracts"; | |
| 11 | import type { IPty } from "node-pty"; | |
| 12 | ||
| 13 | export interface ProcessManagerEvents { | |
| 14 | output: [chunk: OutputChunk]; | |
| 15 | exit: [exit: AgentExit]; | |
| 16 | } | |
| 17 | ||
| 18 | export class ProcessManager extends EventEmitter<ProcessManagerEvents> { | |
| 19 | private sessions = new Map<string, ChildProcess>(); | |
| 20 | private ptySessions = new Map<string, IPty>(); | |
| 21 | ||
| 22 | spawn(raw: AgentConfig): string { | |
| 23 | const config = agentConfigSchema.parse(raw); | |
| 24 | const sessionId = randomUUID(); | |
| 25 | ||
| 26 | if (config.usePty) { | |
| 27 | return this.spawnPty(sessionId, config); | |
| 28 | } | |
| 29 | ||
| 30 | return this.spawnProcess(sessionId, config); | |
| 31 | } | |
| 32 | ||
| 33 | private spawnProcess(sessionId: string, config: AgentConfig): string { | |
| 34 | const child = spawn(config.command, config.args, { | |
| 35 | cwd: config.cwd, | |
| 36 | env: config.env ? { ...process.env, ...config.env } : process.env, | |
| 37 | stdio: ["pipe", "pipe", "pipe"], | |
| 38 | }); | |
| 39 | ||
| 40 | this.sessions.set(sessionId, child); | |
| 41 | ||
| 42 | child.stdout?.on("data", (data: Buffer) => { | |
| 43 | this.emit("output", { | |
| 44 | sessionId, | |
| 45 | stream: "stdout", | |
| 46 | data: data.toString(), | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | child.stderr?.on("data", (data: Buffer) => { | |
| 51 | this.emit("output", { | |
| 52 | sessionId, | |
| 53 | stream: "stderr", | |
| 54 | data: data.toString(), | |
| 55 | }); | |
| 56 | }); | |
| 57 | ||
| 58 | child.on("exit", (code, signal) => { | |
| 59 | this.sessions.delete(sessionId); | |
| 60 | this.emit("exit", { | |
| 61 | sessionId, | |
| 62 | code: code ?? null, | |
| 63 | signal: signal ?? null, | |
| 64 | }); | |
| 65 | }); | |
| 66 | ||
| 67 | return sessionId; | |
| 68 | } | |
| 69 | ||
| 70 | private spawnPty(sessionId: string, config: AgentConfig): string { | |
| 71 | const pty = require("node-pty") as typeof import("node-pty"); | |
| 72 | ||
| 73 | const ptyProcess = pty.spawn(config.command, config.args, { | |
| 74 | name: "xterm-256color", | |
| 75 | cols: 120, | |
| 76 | rows: 30, | |
| 77 | cwd: config.cwd ?? process.cwd(), | |
| 78 | env: (config.env ? { ...process.env, ...config.env } : process.env) as Record<string, string>, | |
| 79 | }); | |
| 80 | ||
| 81 | this.ptySessions.set(sessionId, ptyProcess); | |
| 82 | ||
| 83 | ptyProcess.onData((data) => { | |
| 84 | this.emit("output", { | |
| 85 | sessionId, | |
| 86 | stream: "stdout", | |
| 87 | data, | |
| 88 | }); | |
| 89 | }); | |
| 90 | ||
| 91 | ptyProcess.onExit(({ exitCode, signal }) => { | |
| 92 | this.ptySessions.delete(sessionId); | |
| 93 | this.emit("exit", { | |
| 94 | sessionId, | |
| 95 | code: exitCode, | |
| 96 | signal: signal !== undefined ? String(signal) : null, | |
| 97 | }); | |
| 98 | }); | |
| 99 | ||
| 100 | return sessionId; | |
| 101 | } | |
| 102 | ||
| 103 | write(sessionId: string, data: string): void { | |
| 104 | const child = this.sessions.get(sessionId); | |
| 105 | if (child) { | |
| 106 | child.stdin?.write(data); | |
| 107 | return; | |
| 108 | } | |
| 109 | ||
| 110 | const pty = this.ptySessions.get(sessionId); | |
| 111 | if (pty) { | |
| 112 | pty.write(data); | |
| 113 | return; | |
| 114 | } | |
| 115 | ||
| 116 | throw new Error(`No session: ${sessionId}`); | |
| 117 | } | |
| 118 | ||
| 119 | kill(sessionId: string): void { | |
| 120 | const child = this.sessions.get(sessionId); | |
| 121 | if (child) { | |
| 122 | child.kill(); | |
| 123 | return; | |
| 124 | } | |
| 125 | ||
| 126 | const pty = this.ptySessions.get(sessionId); | |
| 127 | if (pty) { | |
| 128 | pty.kill(); | |
| 129 | return; | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | killAll(): void { | |
| 134 | for (const child of this.sessions.values()) { | |
| 135 | child.kill(); | |
| 136 | } | |
| 137 | this.sessions.clear(); | |
| 138 | ||
| 139 | for (const pty of this.ptySessions.values()) { | |
| 140 | pty.kill(); | |
| 141 | } | |
| 142 | this.ptySessions.clear(); | |
| 143 | } | |
| 144 | } |
apps/desktop/src/providerManager.test.ts deleted-65| ... | ... | @@ -1,65 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { ProviderManager } from "./providerManager"; | |
| 4 | ||
| 5 | describe("ProviderManager", () => { | |
| 6 | it("detaches provider event listener and ends thread log streams on dispose", () => { | |
| 7 | const manager = new ProviderManager(); | |
| 8 | const internals = manager as unknown as { | |
| 9 | codex: { listenerCount: (event: string) => number }; | |
| 10 | onCodexEvent: (event: { | |
| 11 | id: string; | |
| 12 | kind: "notification"; | |
| 13 | provider: "codex"; | |
| 14 | sessionId: string; | |
| 15 | createdAt: string; | |
| 16 | method: string; | |
| 17 | threadId: string; | |
| 18 | }) => void; | |
| 19 | threadLogStreams: Map< | |
| 20 | string, | |
| 21 | { writableEnded: boolean; destroyed: boolean } | |
| 22 | >; | |
| 23 | }; | |
| 24 | internals.onCodexEvent({ | |
| 25 | id: "evt-1", | |
| 26 | kind: "notification", | |
| 27 | provider: "codex", | |
| 28 | sessionId: "sess-1", | |
| 29 | createdAt: new Date().toISOString(), | |
| 30 | method: "thread/started", | |
| 31 | threadId: "thread-1", | |
| 32 | }); | |
| 33 | const stream = internals.threadLogStreams.get("thread-1"); | |
| 34 | expect(stream).toBeDefined(); | |
| 35 | if (!stream) return; | |
| 36 | ||
| 37 | expect(internals.codex.listenerCount("event")).toBe(1); | |
| 38 | manager.dispose(); | |
| 39 | ||
| 40 | expect(internals.codex.listenerCount("event")).toBe(0); | |
| 41 | expect(stream.writableEnded || stream.destroyed).toBe(true); | |
| 42 | expect(internals.threadLogStreams.size).toBe(0); | |
| 43 | }); | |
| 44 | ||
| 45 | it("allows multiple dispose calls", () => { | |
| 46 | const manager = new ProviderManager(); | |
| 47 | ||
| 48 | manager.dispose(); | |
| 49 | expect(() => manager.dispose()).not.toThrow(); | |
| 50 | }); | |
| 51 | ||
| 52 | it("rejects request responses for unknown sessions", async () => { | |
| 53 | const manager = new ProviderManager(); | |
| 54 | ||
| 55 | await expect( | |
| 56 | manager.respondToRequest({ | |
| 57 | sessionId: "missing-session", | |
| 58 | requestId: "req-1", | |
| 59 | decision: "accept", | |
| 60 | }), | |
| 61 | ).rejects.toThrow("Unknown provider session: missing-session"); | |
| 62 | ||
| 63 | manager.dispose(); | |
| 64 | }); | |
| 65 | }); |
apps/desktop/src/providerManager.ts deleted-209| ... | ... | @@ -1,209 +0,0 @@ |
| 1 | import { EventEmitter } from "node:events"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | type ProviderEvent, | |
| 7 | type ProviderInterruptTurnInput, | |
| 8 | type ProviderRespondToRequestInput, | |
| 9 | type ProviderSendTurnInput, | |
| 10 | type ProviderSession, | |
| 11 | type ProviderSessionStartInput, | |
| 12 | type ProviderStopSessionInput, | |
| 13 | type ProviderTurnStartResult, | |
| 14 | providerInterruptTurnInputSchema, | |
| 15 | providerRespondToRequestInputSchema, | |
| 16 | providerSendTurnInputSchema, | |
| 17 | providerSessionStartInputSchema, | |
| 18 | providerStopSessionInputSchema, | |
| 19 | } from "@acme/contracts"; | |
| 20 | import { CodexAppServerManager } from "./codexAppServerManager"; | |
| 21 | ||
| 22 | export interface ProviderManagerEvents { | |
| 23 | event: [event: ProviderEvent]; | |
| 24 | } | |
| 25 | ||
| 26 | export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | |
| 27 | private readonly codex = new CodexAppServerManager(); | |
| 28 | private readonly threadLogsDir: string; | |
| 29 | private readonly threadLogStreams = new Map<string, fs.WriteStream>(); | |
| 30 | private readonly sessionThreadIds = new Map<string, string>(); | |
| 31 | private readonly pendingEventsBySession = new Map<string, ProviderEvent[]>(); | |
| 32 | private disposed = false; | |
| 33 | private readonly onCodexEvent = (event: ProviderEvent) => { | |
| 34 | if (this.disposed) { | |
| 35 | return; | |
| 36 | } | |
| 37 | ||
| 38 | this.routeEventToThreadLog(event); | |
| 39 | this.emit("event", event); | |
| 40 | }; | |
| 41 | ||
| 42 | constructor() { | |
| 43 | super(); | |
| 44 | ||
| 45 | const logsDir = path.resolve(process.cwd(), ".logs"); | |
| 46 | this.threadLogsDir = path.join(logsDir, "threads"); | |
| 47 | fs.mkdirSync(this.threadLogsDir, { recursive: true }); | |
| 48 | ||
| 49 | this.codex.on("event", this.onCodexEvent); | |
| 50 | } | |
| 51 | ||
| 52 | async startSession(raw: ProviderSessionStartInput): Promise<ProviderSession> { | |
| 53 | const input = providerSessionStartInputSchema.parse(raw); | |
| 54 | if (input.provider !== "codex") { | |
| 55 | throw new Error(`Provider '${input.provider}' is not implemented yet.`); | |
| 56 | } | |
| 57 | ||
| 58 | const session = await this.codex.startSession(input); | |
| 59 | if (session.threadId) { | |
| 60 | this.sessionThreadIds.set(session.sessionId, session.threadId); | |
| 61 | this.flushPendingSessionEvents(session.sessionId, session.threadId); | |
| 62 | } | |
| 63 | return session; | |
| 64 | } | |
| 65 | ||
| 66 | async sendTurn(raw: ProviderSendTurnInput): Promise<ProviderTurnStartResult> { | |
| 67 | const input = providerSendTurnInputSchema.parse(raw); | |
| 68 | if (!this.codex.hasSession(input.sessionId)) { | |
| 69 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 70 | } | |
| 71 | ||
| 72 | return this.codex.sendTurn(input); | |
| 73 | } | |
| 74 | ||
| 75 | async interruptTurn(raw: ProviderInterruptTurnInput): Promise<void> { | |
| 76 | const input = providerInterruptTurnInputSchema.parse(raw); | |
| 77 | if (!this.codex.hasSession(input.sessionId)) { | |
| 78 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 79 | } | |
| 80 | ||
| 81 | await this.codex.interruptTurn(input.sessionId, input.turnId); | |
| 82 | } | |
| 83 | ||
| 84 | async respondToRequest(raw: ProviderRespondToRequestInput): Promise<void> { | |
| 85 | const input = providerRespondToRequestInputSchema.parse(raw); | |
| 86 | if (!this.codex.hasSession(input.sessionId)) { | |
| 87 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 88 | } | |
| 89 | ||
| 90 | await this.codex.respondToRequest( | |
| 91 | input.sessionId, | |
| 92 | input.requestId, | |
| 93 | input.decision, | |
| 94 | ); | |
| 95 | } | |
| 96 | ||
| 97 | stopSession(raw: ProviderStopSessionInput): void { | |
| 98 | const input = providerStopSessionInputSchema.parse(raw); | |
| 99 | this.codex.stopSession(input.sessionId); | |
| 100 | this.sessionThreadIds.delete(input.sessionId); | |
| 101 | this.pendingEventsBySession.delete(input.sessionId); | |
| 102 | } | |
| 103 | ||
| 104 | listSessions(): ProviderSession[] { | |
| 105 | return this.codex.listSessions(); | |
| 106 | } | |
| 107 | ||
| 108 | stopAll(): void { | |
| 109 | this.codex.stopAll(); | |
| 110 | } | |
| 111 | ||
| 112 | dispose(): void { | |
| 113 | if (this.disposed) { | |
| 114 | return; | |
| 115 | } | |
| 116 | ||
| 117 | this.disposed = true; | |
| 118 | this.codex.off("event", this.onCodexEvent); | |
| 119 | for (const stream of this.threadLogStreams.values()) { | |
| 120 | stream.end(); | |
| 121 | } | |
| 122 | this.threadLogStreams.clear(); | |
| 123 | this.sessionThreadIds.clear(); | |
| 124 | this.pendingEventsBySession.clear(); | |
| 125 | } | |
| 126 | ||
| 127 | private routeEventToThreadLog(event: ProviderEvent): void { | |
| 128 | const threadId = this.resolveThreadId(event); | |
| 129 | if (!threadId) { | |
| 130 | const pending = this.pendingEventsBySession.get(event.sessionId) ?? []; | |
| 131 | pending.push(event); | |
| 132 | this.pendingEventsBySession.set(event.sessionId, pending.slice(-100)); | |
| 133 | return; | |
| 134 | } | |
| 135 | ||
| 136 | this.flushPendingSessionEvents(event.sessionId, threadId); | |
| 137 | this.writeThreadEvent(threadId, event); | |
| 138 | } | |
| 139 | ||
| 140 | private flushPendingSessionEvents(sessionId: string, threadId: string): void { | |
| 141 | const pending = this.pendingEventsBySession.get(sessionId); | |
| 142 | if (!pending || pending.length === 0) { | |
| 143 | return; | |
| 144 | } | |
| 145 | ||
| 146 | for (const event of pending) { | |
| 147 | this.writeThreadEvent(threadId, event); | |
| 148 | } | |
| 149 | this.pendingEventsBySession.delete(sessionId); | |
| 150 | } | |
| 151 | ||
| 152 | private resolveThreadId(event: ProviderEvent): string | undefined { | |
| 153 | const fromPayload = this.readThreadIdFromPayload(event.payload); | |
| 154 | const threadId = | |
| 155 | event.threadId ?? | |
| 156 | fromPayload ?? | |
| 157 | this.sessionThreadIds.get(event.sessionId); | |
| 158 | ||
| 159 | if (threadId) { | |
| 160 | this.sessionThreadIds.set(event.sessionId, threadId); | |
| 161 | } | |
| 162 | ||
| 163 | return threadId; | |
| 164 | } | |
| 165 | ||
| 166 | private readThreadIdFromPayload(payload: unknown): string | undefined { | |
| 167 | if (!payload || typeof payload !== "object") { | |
| 168 | return undefined; | |
| 169 | } | |
| 170 | ||
| 171 | const record = payload as Record<string, unknown>; | |
| 172 | const direct = record.threadId; | |
| 173 | if (typeof direct === "string" && direct.length > 0) { | |
| 174 | return direct; | |
| 175 | } | |
| 176 | ||
| 177 | const thread = record.thread; | |
| 178 | if (!thread || typeof thread !== "object") { | |
| 179 | return undefined; | |
| 180 | } | |
| 181 | ||
| 182 | const nested = (thread as Record<string, unknown>).id; | |
| 183 | if (typeof nested === "string" && nested.length > 0) { | |
| 184 | return nested; | |
| 185 | } | |
| 186 | ||
| 187 | return undefined; | |
| 188 | } | |
| 189 | ||
| 190 | private writeThreadEvent(threadId: string, event: ProviderEvent): void { | |
| 191 | const stream = this.getOrCreateThreadLogStream(threadId); | |
| 192 | stream.write(`${JSON.stringify(event)}\n`); | |
| 193 | } | |
| 194 | ||
| 195 | private getOrCreateThreadLogStream(threadId: string): fs.WriteStream { | |
| 196 | const cached = this.threadLogStreams.get(threadId); | |
| 197 | if (cached) { | |
| 198 | return cached; | |
| 199 | } | |
| 200 | ||
| 201 | const safeThreadId = threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); | |
| 202 | const stream = fs.createWriteStream( | |
| 203 | path.join(this.threadLogsDir, `${safeThreadId}.events.ndjson`), | |
| 204 | { flags: "a" }, | |
| 205 | ); | |
| 206 | this.threadLogStreams.set(threadId, stream); | |
| 207 | return stream; | |
| 208 | } | |
| 209 | } |
apps/desktop/src/todoStore.ts deleted-106| ... | ... | @@ -1,106 +0,0 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | import { promises as fs } from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | type NewTodoInput, | |
| 7 | type Todo, | |
| 8 | newTodoInputSchema, | |
| 9 | todoIdSchema, | |
| 10 | todoListSchema, | |
| 11 | } from "@acme/contracts"; | |
| 12 | ||
| 13 | export class TodoStore { | |
| 14 | private todos: Todo[] = []; | |
| 15 | private queue: Promise<void> = Promise.resolve(); | |
| 16 | ||
| 17 | public constructor(private readonly filePath: string) {} | |
| 18 | ||
| 19 | public async init(): Promise<void> { | |
| 20 | return this.runExclusive(async () => { | |
| 21 | await fs.mkdir(path.dirname(this.filePath), { recursive: true }); | |
| 22 | ||
| 23 | try { | |
| 24 | const raw = await fs.readFile(this.filePath, "utf8"); | |
| 25 | const parsed = todoListSchema.safeParse(JSON.parse(raw)); | |
| 26 | ||
| 27 | if (parsed.success) { | |
| 28 | this.todos = parsed.data; | |
| 29 | return; | |
| 30 | } | |
| 31 | } catch (error) { | |
| 32 | if (!isNotFoundError(error)) { | |
| 33 | throw error; | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | this.todos = []; | |
| 38 | await this.persist(); | |
| 39 | }); | |
| 40 | } | |
| 41 | ||
| 42 | public async list(): Promise<Todo[]> { | |
| 43 | return this.runExclusive(async () => [...this.todos]); | |
| 44 | } | |
| 45 | ||
| 46 | public async add(input: NewTodoInput): Promise<Todo[]> { | |
| 47 | return this.runExclusive(async () => { | |
| 48 | const { title } = newTodoInputSchema.parse(input); | |
| 49 | const todo: Todo = { | |
| 50 | id: randomUUID(), | |
| 51 | title, | |
| 52 | completed: false, | |
| 53 | createdAt: new Date().toISOString(), | |
| 54 | }; | |
| 55 | ||
| 56 | this.todos = [todo, ...this.todos]; | |
| 57 | await this.persist(); | |
| 58 | return [...this.todos]; | |
| 59 | }); | |
| 60 | } | |
| 61 | ||
| 62 | public async toggle(id: string): Promise<Todo[]> { | |
| 63 | return this.runExclusive(async () => { | |
| 64 | const parsedId = todoIdSchema.parse(id); | |
| 65 | this.todos = this.todos.map((todo) => | |
| 66 | todo.id === parsedId ? { ...todo, completed: !todo.completed } : todo, | |
| 67 | ); | |
| 68 | ||
| 69 | await this.persist(); | |
| 70 | return [...this.todos]; | |
| 71 | }); | |
| 72 | } | |
| 73 | ||
| 74 | public async remove(id: string): Promise<Todo[]> { | |
| 75 | return this.runExclusive(async () => { | |
| 76 | const parsedId = todoIdSchema.parse(id); | |
| 77 | this.todos = this.todos.filter((todo) => todo.id !== parsedId); | |
| 78 | ||
| 79 | await this.persist(); | |
| 80 | return [...this.todos]; | |
| 81 | }); | |
| 82 | } | |
| 83 | ||
| 84 | private async persist(): Promise<void> { | |
| 85 | await fs.writeFile(this.filePath, JSON.stringify(this.todos, null, 2), "utf8"); | |
| 86 | } | |
| 87 | ||
| 88 | private async runExclusive<T>(operation: () => Promise<T>): Promise<T> { | |
| 89 | const next = this.queue.then(operation, operation); | |
| 90 | this.queue = next.then( | |
| 91 | () => undefined, | |
| 92 | () => undefined, | |
| 93 | ); | |
| 94 | ||
| 95 | return next; | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | function isNotFoundError(error: unknown): boolean { | |
| 100 | return ( | |
| 101 | typeof error === "object" && | |
| 102 | error !== null && | |
| 103 | "code" in error && | |
| 104 | (error as { code?: string }).code === "ENOENT" | |
| 105 | ); | |
| 106 | } |
apps/renderer/index.html+1-1| ... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="UTF-8" /> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | <title>Electron Todo</title> | |
| 6 | <title>CodeThing</title> | |
| 7 | 7 | </head> |
| 8 | 8 | <body> |
| 9 | 9 | <div id="root"></div> |
apps/renderer/src/App.tsx+118-4| ... | ... | @@ -3,8 +3,11 @@ import { useEffect, useMemo, useRef } from "react"; |
| 3 | 3 | import ChatView from "./components/ChatView"; |
| 4 | 4 | import DiffPanel from "./components/DiffPanel"; |
| 5 | 5 | import Sidebar from "./components/Sidebar"; |
| 6 | import { isElectron } from "./env"; | |
| 7 | import { DEFAULT_MODEL } from "./model-logic"; | |
| 6 | 8 | import { readNativeApi } from "./session-logic"; |
| 7 | 9 | import { StoreProvider, useStore } from "./store"; |
| 10 | import { onServerWelcome } from "./wsNativeApi"; | |
| 8 | 11 | |
| 9 | 12 | function EventRouter() { |
| 10 | 13 | const api = useMemo(() => readNativeApi(), []); |
| ... | ... | @@ -25,6 +28,118 @@ function EventRouter() { |
| 25 | 28 | return null; |
| 26 | 29 | } |
| 27 | 30 | |
| 31 | function AutoProjectBootstrap() { | |
| 32 | const { state, dispatch } = useStore(); | |
| 33 | const bootstrappedRef = useRef(false); | |
| 34 | ||
| 35 | useEffect(() => { | |
| 36 | // Browser mode bootstraps from server welcome. | |
| 37 | // Electron bootstraps from persisted projects via DesktopProjectBootstrap. | |
| 38 | if (isElectron) return; | |
| 39 | ||
| 40 | return onServerWelcome((payload) => { | |
| 41 | if (bootstrappedRef.current) return; | |
| 42 | ||
| 43 | // Don't create duplicate projects for the same cwd | |
| 44 | const existing = state.projects.find((p) => p.cwd === payload.cwd); | |
| 45 | if (existing) { | |
| 46 | bootstrappedRef.current = true; | |
| 47 | // Ensure a thread is active | |
| 48 | const existingThread = state.threads.find((t) => t.projectId === existing.id); | |
| 49 | if (existingThread && !state.activeThreadId) { | |
| 50 | dispatch({ | |
| 51 | type: "SET_ACTIVE_THREAD", | |
| 52 | threadId: existingThread.id, | |
| 53 | }); | |
| 54 | } | |
| 55 | return; | |
| 56 | } | |
| 57 | ||
| 58 | bootstrappedRef.current = true; | |
| 59 | ||
| 60 | // Create project + thread from server cwd | |
| 61 | const projectId = crypto.randomUUID(); | |
| 62 | dispatch({ | |
| 63 | type: "ADD_PROJECT", | |
| 64 | project: { | |
| 65 | id: projectId, | |
| 66 | name: payload.projectName, | |
| 67 | cwd: payload.cwd, | |
| 68 | model: DEFAULT_MODEL, | |
| 69 | expanded: true, | |
| 70 | }, | |
| 71 | }); | |
| 72 | dispatch({ | |
| 73 | type: "ADD_THREAD", | |
| 74 | thread: { | |
| 75 | id: crypto.randomUUID(), | |
| 76 | codexThreadId: null, | |
| 77 | projectId, | |
| 78 | title: "New thread", | |
| 79 | model: DEFAULT_MODEL, | |
| 80 | session: null, | |
| 81 | messages: [], | |
| 82 | events: [], | |
| 83 | error: null, | |
| 84 | createdAt: new Date().toISOString(), | |
| 85 | }, | |
| 86 | }); | |
| 87 | }); | |
| 88 | }, [state.projects, state.threads, state.activeThreadId, dispatch]); | |
| 89 | ||
| 90 | return null; | |
| 91 | } | |
| 92 | ||
| 93 | function DesktopProjectBootstrap() { | |
| 94 | const api = useMemo(() => readNativeApi(), []); | |
| 95 | const { dispatch } = useStore(); | |
| 96 | const bootstrappedRef = useRef(false); | |
| 97 | ||
| 98 | useEffect(() => { | |
| 99 | if (!isElectron || !api || bootstrappedRef.current) return; | |
| 100 | ||
| 101 | let disposed = false; | |
| 102 | let retryDelayMs = 500; | |
| 103 | let retryTimer: ReturnType<typeof setTimeout> | null = null; | |
| 104 | ||
| 105 | const attemptBootstrap = async () => { | |
| 106 | try { | |
| 107 | const projects = await api.projects.list(); | |
| 108 | if (disposed) return; | |
| 109 | dispatch({ | |
| 110 | type: "SYNC_PROJECTS", | |
| 111 | projects: projects.map((project) => ({ | |
| 112 | id: project.id, | |
| 113 | name: project.name, | |
| 114 | cwd: project.cwd, | |
| 115 | model: DEFAULT_MODEL, | |
| 116 | expanded: true, | |
| 117 | })), | |
| 118 | }); | |
| 119 | bootstrappedRef.current = true; | |
| 120 | } catch { | |
| 121 | if (disposed) return; | |
| 122 | retryTimer = setTimeout(() => { | |
| 123 | retryTimer = null; | |
| 124 | void attemptBootstrap(); | |
| 125 | }, retryDelayMs); | |
| 126 | retryDelayMs = Math.min(retryDelayMs * 2, 5_000); | |
| 127 | } | |
| 128 | }; | |
| 129 | ||
| 130 | void attemptBootstrap(); | |
| 131 | ||
| 132 | return () => { | |
| 133 | disposed = true; | |
| 134 | if (retryTimer) { | |
| 135 | clearTimeout(retryTimer); | |
| 136 | } | |
| 137 | }; | |
| 138 | }, [api, dispatch]); | |
| 139 | ||
| 140 | return null; | |
| 141 | } | |
| 142 | ||
| 28 | 143 | function Layout() { |
| 29 | 144 | const api = useMemo(() => readNativeApi(), []); |
| 30 | 145 | const { state } = useStore(); |
| ... | ... | @@ -32,11 +147,8 @@ function Layout() { |
| 32 | 147 | if (!api) { |
| 33 | 148 | return ( |
| 34 | 149 | <div className="flex h-screen flex-col bg-background text-foreground"> |
| 35 | <div className="drag-region h-[52px] shrink-0" /> | |
| 36 | 150 | <div className="flex flex-1 items-center justify-center"> |
| 37 | <p className="text-sm text-muted-foreground"> | |
| 38 | Native bridge unavailable. Launch through Electron. | |
| 39 | </p> | |
| 151 | <p className="text-sm text-muted-foreground">Connecting to CodeThing server...</p> | |
| 40 | 152 | </div> |
| 41 | 153 | </div> |
| 42 | 154 | ); |
| ... | ... | @@ -45,6 +157,8 @@ function Layout() { |
| 45 | 157 | return ( |
| 46 | 158 | <div className="flex h-screen overflow-hidden bg-background text-foreground"> |
| 47 | 159 | <EventRouter /> |
| 160 | <AutoProjectBootstrap /> | |
| 161 | <DesktopProjectBootstrap /> | |
| 48 | 162 | <Sidebar /> |
| 49 | 163 | <ChatView /> |
| 50 | 164 | {state.diffOpen && <DiffPanel />} |
apps/renderer/src/components/ChatView.tsx+3-2| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | } from "react"; |
| 15 | 15 | |
| 16 | 16 | import { EDITORS, type EditorId } from "@acme/contracts"; |
| 17 | import { isElectron } from "../env"; | |
| 17 | 18 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 18 | 19 | import { |
| 19 | 20 | DEFAULT_MODEL, |
| ... | ... | @@ -563,7 +564,7 @@ export default function ChatView() { |
| 563 | 564 | if (!activeThread) { |
| 564 | 565 | return ( |
| 565 | 566 | <div className="flex flex-1 flex-col bg-background text-muted-foreground/40"> |
| 566 | <div className="drag-region h-[52px] shrink-0" /> | |
| 567 | {isElectron && <div className="drag-region h-[52px] shrink-0" />} | |
| 567 | 568 | <div className="flex flex-1 items-center justify-center"> |
| 568 | 569 | <div className="text-center"> |
| 569 | 570 | <p className="text-sm">Select a thread or create a new one to get started.</p> |
| ... | ... | @@ -576,7 +577,7 @@ export default function ChatView() { |
| 576 | 577 | return ( |
| 577 | 578 | <div className="flex flex-1 flex-col bg-background"> |
| 578 | 579 | {/* Top bar */} |
| 579 | <header className="drag-region flex items-center justify-between border-b border-border px-5 pt-[28px] pb-3"> | |
| 580 | <header className={`flex items-center justify-between border-b border-border px-5 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}> | |
| 580 | 581 | <div className="flex items-center gap-3"> |
| 581 | 582 | <h2 className="text-sm font-medium text-foreground"> |
| 582 | 583 | {activeThread.title} |
apps/renderer/src/components/DiffPanel.tsx+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { isElectron } from "../env"; | |
| 1 | 2 | import { useStore } from "../store"; |
| 2 | 3 | |
| 3 | 4 | export default function DiffPanel() { |
| ... | ... | @@ -6,7 +7,7 @@ export default function DiffPanel() { |
| 6 | 7 | return ( |
| 7 | 8 | <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card"> |
| 8 | 9 | {/* Header */} |
| 9 | <div className="drag-region flex items-center justify-between border-b border-border px-4 pt-[28px] pb-3"> | |
| 10 | <div className={`flex items-center justify-between border-b border-border px-4 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}> | |
| 10 | 11 | <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3> |
| 11 | 12 | <button |
| 12 | 13 | type="button" |
apps/renderer/src/components/Sidebar.tsx+112-43| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; |
| 2 | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import { isElectron } from "../env"; | |
| 3 | 4 | import { useTheme } from "../hooks/useTheme"; |
| 4 | 5 | import { DEFAULT_MODEL } from "../model-logic"; |
| 5 | 6 | import { readNativeApi } from "../session-logic"; |
| ... | ... | @@ -17,6 +18,13 @@ function formatRelativeTime(iso: string): string { |
| 17 | 18 | return `${Math.floor(hours / 24)}d ago`; |
| 18 | 19 | } |
| 19 | 20 | |
| 21 | function inferProjectName(cwd: string): string { | |
| 22 | const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); | |
| 23 | const parts = normalized.split("/"); | |
| 24 | const last = parts[parts.length - 1]; | |
| 25 | return last && last.length > 0 ? last : "project"; | |
| 26 | } | |
| 27 | ||
| 20 | 28 | function threadStatusLabel( |
| 21 | 29 | status: "connecting" | "ready" | "running" | "error" | "closed" | undefined, |
| 22 | 30 | ): "Working" | "Connecting" | null { |
| ... | ... | @@ -32,22 +40,7 @@ export default function Sidebar() { |
| 32 | 40 | const [addingProject, setAddingProject] = useState(false); |
| 33 | 41 | const [newCwd, setNewCwd] = useState(""); |
| 34 | 42 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| 35 | ||
| 36 | const handleAddProject = () => { | |
| 37 | const cwd = newCwd.trim(); | |
| 38 | if (!cwd) return; | |
| 39 | const name = cwd.split("/").filter(Boolean).pop() ?? "project"; | |
| 40 | const project: Project = { | |
| 41 | id: crypto.randomUUID(), | |
| 42 | name, | |
| 43 | cwd, | |
| 44 | model: DEFAULT_MODEL, | |
| 45 | expanded: true, | |
| 46 | }; | |
| 47 | dispatch({ type: "ADD_PROJECT", project }); | |
| 48 | setNewCwd(""); | |
| 49 | setAddingProject(false); | |
| 50 | }; | |
| 43 | const [isAddingProject, setIsAddingProject] = useState(false); | |
| 51 | 44 | |
| 52 | 45 | const handleNewThread = useCallback( |
| 53 | 46 | (projectId: string) => { |
| ... | ... | @@ -58,9 +51,7 @@ export default function Sidebar() { |
| 58 | 51 | codexThreadId: null, |
| 59 | 52 | projectId, |
| 60 | 53 | title: "New thread", |
| 61 | model: | |
| 62 | state.projects.find((p) => p.id === projectId)?.model ?? | |
| 63 | DEFAULT_MODEL, | |
| 54 | model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL, | |
| 64 | 55 | session: null, |
| 65 | 56 | messages: [], |
| 66 | 57 | events: [], |
| ... | ... | @@ -72,13 +63,98 @@ export default function Sidebar() { |
| 72 | 63 | [dispatch, state.projects], |
| 73 | 64 | ); |
| 74 | 65 | |
| 66 | const focusMostRecentThreadForProject = useCallback( | |
| 67 | (projectId: string) => { | |
| 68 | const latestThread = state.threads | |
| 69 | .filter((thread) => thread.projectId === projectId) | |
| 70 | .toSorted((a, b) => { | |
| 71 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); | |
| 72 | if (byDate !== 0) return byDate; | |
| 73 | return b.id.localeCompare(a.id); | |
| 74 | })[0]; | |
| 75 | if (!latestThread) return; | |
| 76 | ||
| 77 | dispatch({ | |
| 78 | type: "SET_ACTIVE_THREAD", | |
| 79 | threadId: latestThread.id, | |
| 80 | }); | |
| 81 | }, | |
| 82 | [dispatch, state.threads], | |
| 83 | ); | |
| 84 | ||
| 85 | const addProjectFromPath = useCallback( | |
| 86 | async (rawCwd: string) => { | |
| 87 | const cwd = rawCwd.trim(); | |
| 88 | if (!cwd || isAddingProject) return; | |
| 89 | ||
| 90 | setIsAddingProject(true); | |
| 91 | try { | |
| 92 | if (isElectron && api) { | |
| 93 | const result = await api.projects.add({ cwd }); | |
| 94 | const project: Project = { | |
| 95 | id: result.project.id, | |
| 96 | name: result.project.name, | |
| 97 | cwd: result.project.cwd, | |
| 98 | model: DEFAULT_MODEL, | |
| 99 | expanded: true, | |
| 100 | }; | |
| 101 | const existingById = state.projects.find((p) => p.id === project.id); | |
| 102 | const existingByCwd = state.projects.find((p) => p.cwd === project.cwd); | |
| 103 | if (!existingById && !existingByCwd) { | |
| 104 | dispatch({ type: "ADD_PROJECT", project }); | |
| 105 | } | |
| 106 | const resolvedProjectId = existingByCwd?.id ?? project.id; | |
| 107 | ||
| 108 | if (result.created) { | |
| 109 | handleNewThread(resolvedProjectId); | |
| 110 | } else { | |
| 111 | focusMostRecentThreadForProject(resolvedProjectId); | |
| 112 | } | |
| 113 | } else { | |
| 114 | const existing = state.projects.find((project) => project.cwd === cwd); | |
| 115 | if (existing) { | |
| 116 | focusMostRecentThreadForProject(existing.id); | |
| 117 | return; | |
| 118 | } | |
| 119 | ||
| 120 | const name = inferProjectName(cwd); | |
| 121 | const project: Project = { | |
| 122 | id: crypto.randomUUID(), | |
| 123 | name, | |
| 124 | cwd, | |
| 125 | model: DEFAULT_MODEL, | |
| 126 | expanded: true, | |
| 127 | }; | |
| 128 | dispatch({ type: "ADD_PROJECT", project }); | |
| 129 | handleNewThread(project.id); | |
| 130 | } | |
| 131 | } finally { | |
| 132 | setIsAddingProject(false); | |
| 133 | setNewCwd(""); | |
| 134 | setAddingProject(false); | |
| 135 | } | |
| 136 | }, | |
| 137 | [ | |
| 138 | api, | |
| 139 | dispatch, | |
| 140 | focusMostRecentThreadForProject, | |
| 141 | handleNewThread, | |
| 142 | isAddingProject, | |
| 143 | state.projects, | |
| 144 | ], | |
| 145 | ); | |
| 146 | ||
| 147 | const handleAddProject = () => { | |
| 148 | void addProjectFromPath(newCwd); | |
| 149 | }; | |
| 150 | ||
| 75 | 151 | const handlePickFolder = async () => { |
| 76 | 152 | if (!api || isPickingFolder) return; |
| 77 | 153 | setIsPickingFolder(true); |
| 78 | 154 | try { |
| 79 | 155 | const pickedPath = await api.dialogs.pickFolder(); |
| 80 | 156 | if (!pickedPath) return; |
| 81 | setNewCwd(pickedPath); | |
| 157 | await addProjectFromPath(pickedPath); | |
| 82 | 158 | } finally { |
| 83 | 159 | setIsPickingFolder(false); |
| 84 | 160 | } |
| ... | ... | @@ -107,9 +183,7 @@ export default function Sidebar() { |
| 107 | 183 | } |
| 108 | 184 | } |
| 109 | 185 | |
| 110 | const activeThread = state.threads.find( | |
| 111 | (t) => t.id === state.activeThreadId, | |
| 112 | ); | |
| 186 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | |
| 113 | 187 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; |
| 114 | 188 | if (!projectId) return; |
| 115 | 189 | |
| ... | ... | @@ -125,10 +199,10 @@ export default function Sidebar() { |
| 125 | 199 | |
| 126 | 200 | return ( |
| 127 | 201 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> |
| 128 | {/* Drag region / traffic light space */} | |
| 129 | <div className="drag-region h-[52px] shrink-0" /> | |
| 202 | {/* Drag region / traffic light space (Electron only) */} | |
| 203 | {isElectron && <div className="drag-region h-[52px] shrink-0" />} | |
| 130 | 204 | {/* Branding */} |
| 131 | <div className="flex items-center gap-2 px-4 pb-4"> | |
| 205 | <div className={`flex items-center gap-2 px-4 pb-4 ${isElectron ? "" : "pt-4"}`}> | |
| 132 | 206 | <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-xs font-bold text-primary-foreground"> |
| 133 | 207 | CT |
| 134 | 208 | </div> |
| ... | ... | @@ -176,10 +250,8 @@ export default function Sidebar() { |
| 176 | 250 | {state.projects.map((project) => { |
| 177 | 251 | const threads = state.threads |
| 178 | 252 | .filter((t) => t.projectId === project.id) |
| 179 | .sort((a, b) => { | |
| 180 | const byDate = | |
| 181 | new Date(b.createdAt).getTime() - | |
| 182 | new Date(a.createdAt).getTime(); | |
| 253 | .toSorted((a, b) => { | |
| 254 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); | |
| 183 | 255 | if (byDate !== 0) return byDate; |
| 184 | 256 | return b.id.localeCompare(a.id); |
| 185 | 257 | }); |
| ... | ... | @@ -205,15 +277,15 @@ export default function Sidebar() { |
| 205 | 277 | <div className="ml-2 border-l border-border/80 pl-2"> |
| 206 | 278 | {threads.map((thread) => { |
| 207 | 279 | const isActive = state.activeThreadId === thread.id; |
| 208 | const threadStatus = threadStatusLabel( | |
| 209 | thread.session?.status, | |
| 210 | ); | |
| 280 | const threadStatus = threadStatusLabel(thread.session?.status); | |
| 211 | 281 | return ( |
| 212 | 282 | <button |
| 213 | 283 | key={thread.id} |
| 214 | 284 | type="button" |
| 215 | 285 | className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 ${ |
| 216 | isActive ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-secondary" | |
| 286 | isActive | |
| 287 | ? "bg-accent text-foreground" | |
| 288 | : "text-muted-foreground hover:bg-secondary" | |
| 217 | 289 | }`} |
| 218 | 290 | onClick={() => |
| 219 | 291 | dispatch({ |
| ... | ... | @@ -226,14 +298,10 @@ export default function Sidebar() { |
| 226 | 298 | {threadStatus && ( |
| 227 | 299 | <span className="inline-flex items-center gap-1 text-[10px] text-sky-600 dark:text-sky-300/80"> |
| 228 | 300 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-500 dark:bg-sky-300/80" /> |
| 229 | <span className="hidden xl:inline"> | |
| 230 | {threadStatus} | |
| 231 | </span> | |
| 301 | <span className="hidden xl:inline">{threadStatus}</span> | |
| 232 | 302 | </span> |
| 233 | 303 | )} |
| 234 | <span className="min-w-0 flex-1 truncate text-xs"> | |
| 235 | {thread.title} | |
| 236 | </span> | |
| 304 | <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> | |
| 237 | 305 | </div> |
| 238 | 306 | <span className="shrink-0 text-[10px] text-muted-foreground/40"> |
| 239 | 307 | {formatRelativeTime(thread.createdAt)} |
| ... | ... | @@ -281,12 +349,12 @@ export default function Sidebar() { |
| 281 | 349 | if (e.key === "Escape") setAddingProject(false); |
| 282 | 350 | }} |
| 283 | 351 | /> |
| 284 | {api && ( | |
| 352 | {isElectron && api && ( | |
| 285 | 353 | <button |
| 286 | 354 | type="button" |
| 287 | 355 | className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" |
| 288 | 356 | onClick={() => void handlePickFolder()} |
| 289 | disabled={isPickingFolder} | |
| 357 | disabled={isPickingFolder || isAddingProject} | |
| 290 | 358 | > |
| 291 | 359 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} |
| 292 | 360 | </button> |
| ... | ... | @@ -296,8 +364,9 @@ export default function Sidebar() { |
| 296 | 364 | type="button" |
| 297 | 365 | className="flex-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90" |
| 298 | 366 | onClick={handleAddProject} |
| 367 | disabled={isAddingProject} | |
| 299 | 368 | > |
| 300 | Add | |
| 369 | {isAddingProject ? "Adding..." : "Add"} | |
| 301 | 370 | </button> |
| 302 | 371 | <button |
| 303 | 372 | type="button" |
apps/renderer/src/env.ts created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | /** | |
| 2 | * True when running inside the Electron preload bridge, false in a regular browser. | |
| 3 | * The preload script sets window.nativeApi via contextBridge before any renderer | |
| 4 | * code executes, so this is reliable at module load time. | |
| 5 | */ | |
| 6 | export const isElectron = | |
| 7 | typeof window !== "undefined" && | |
| 8 | (window.desktopBridge !== undefined || window.nativeApi !== undefined); |
apps/renderer/src/session-logic.ts+16-3| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { ProviderEvent, ProviderKind, ProviderSession } from "@acme/contracts"; | |
| 1 | import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@acme/contracts"; | |
| 2 | 2 | import type { ChatMessage, SessionPhase } from "./types"; |
| 3 | import { createWsNativeApi } from "./wsNativeApi"; | |
| 3 | 4 | |
| 4 | 5 | export const PROVIDER_OPTIONS: Array<{ |
| 5 | 6 | value: ProviderKind; |
| ... | ... | @@ -10,9 +11,21 @@ export const PROVIDER_OPTIONS: Array<{ |
| 10 | 11 | { value: "claudeCode", label: "Claude Code (soon)", available: false }, |
| 11 | 12 | ]; |
| 12 | 13 | |
| 13 | export function readNativeApi() { | |
| 14 | let cachedApi: NativeApi | undefined; | |
| 15 | ||
| 16 | export function readNativeApi(): NativeApi | undefined { | |
| 14 | 17 | if (typeof window === "undefined") return undefined; |
| 15 | return window.nativeApi; | |
| 18 | if (cachedApi) return cachedApi; | |
| 19 | ||
| 20 | // Prefer Electron preload bridge if available | |
| 21 | if (window.nativeApi) { | |
| 22 | cachedApi = window.nativeApi; | |
| 23 | return cachedApi; | |
| 24 | } | |
| 25 | ||
| 26 | // Fall back to WebSocket transport | |
| 27 | cachedApi = createWsNativeApi(); | |
| 28 | return cachedApi; | |
| 16 | 29 | } |
| 17 | 30 | |
| 18 | 31 | export function asObject(value: unknown): Record<string, unknown> | undefined { |
apps/renderer/src/store.test.ts+57-3| ... | ... | @@ -4,9 +4,7 @@ import { describe, expect, it } from "vitest"; |
| 4 | 4 | import { type AppState, reducer } from "./store"; |
| 5 | 5 | import type { Thread } from "./types"; |
| 6 | 6 | |
| 7 | function makeSession( | |
| 8 | overrides: Partial<ProviderSession> = {}, | |
| 9 | ): ProviderSession { | |
| 7 | function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession { | |
| 10 | 8 | return { |
| 11 | 9 | sessionId: "sess-1", |
| 12 | 10 | provider: "codex", |
| ... | ... | @@ -131,4 +129,60 @@ describe("store reducer thread continuity", () => { |
| 131 | 129 | expect(next.threads[0]?.codexThreadId).toBe("thr_new"); |
| 132 | 130 | expect(next.threads[0]?.session?.threadId).toBe("thr_new"); |
| 133 | 131 | }); |
| 132 | ||
| 133 | it("reconciles project ids by cwd when syncing backend projects", () => { | |
| 134 | const state: AppState = { | |
| 135 | projects: [ | |
| 136 | { | |
| 137 | id: "project-old-a", | |
| 138 | name: "A", | |
| 139 | cwd: "/tmp/a", | |
| 140 | model: "gpt-5.3-codex", | |
| 141 | expanded: false, | |
| 142 | }, | |
| 143 | { | |
| 144 | id: "project-old-b", | |
| 145 | name: "B", | |
| 146 | cwd: "/tmp/b", | |
| 147 | model: "gpt-5.3-codex", | |
| 148 | expanded: true, | |
| 149 | }, | |
| 150 | ], | |
| 151 | threads: [ | |
| 152 | makeThread({ | |
| 153 | id: "thread-a", | |
| 154 | projectId: "project-old-a", | |
| 155 | }), | |
| 156 | makeThread({ | |
| 157 | id: "thread-b", | |
| 158 | projectId: "project-old-b", | |
| 159 | }), | |
| 160 | ], | |
| 161 | activeThreadId: "thread-b", | |
| 162 | runtimeMode: "full-access", | |
| 163 | diffOpen: false, | |
| 164 | }; | |
| 165 | ||
| 166 | const next = reducer(state, { | |
| 167 | type: "SYNC_PROJECTS", | |
| 168 | projects: [ | |
| 169 | { | |
| 170 | id: "project-new-a", | |
| 171 | name: "A", | |
| 172 | cwd: "/tmp/a", | |
| 173 | model: "gpt-5.3-codex", | |
| 174 | expanded: true, | |
| 175 | }, | |
| 176 | ], | |
| 177 | }); | |
| 178 | ||
| 179 | expect(next.projects).toHaveLength(1); | |
| 180 | expect(next.projects[0]?.id).toBe("project-new-a"); | |
| 181 | // Preserve existing project UI preferences by cwd | |
| 182 | expect(next.projects[0]?.expanded).toBe(false); | |
| 183 | expect(next.threads).toHaveLength(1); | |
| 184 | expect(next.threads[0]?.id).toBe("thread-a"); | |
| 185 | expect(next.threads[0]?.projectId).toBe("project-new-a"); | |
| 186 | expect(next.activeThreadId).toBe("thread-a"); | |
| 187 | }); | |
| 134 | 188 | }); |
apps/renderer/src/store.ts+57-40| ... | ... | @@ -11,23 +11,14 @@ import { |
| 11 | 11 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; |
| 12 | 12 | import { resolveModelSlug } from "./model-logic"; |
| 13 | 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; |
| 14 | import { | |
| 15 | applyEventToMessages, | |
| 16 | asObject, | |
| 17 | asString, | |
| 18 | evolveSession, | |
| 19 | } from "./session-logic"; | |
| 20 | import { | |
| 21 | DEFAULT_RUNTIME_MODE, | |
| 22 | type Project, | |
| 23 | type RuntimeMode, | |
| 24 | type Thread, | |
| 25 | } from "./types"; | |
| 14 | import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; | |
| 15 | import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types"; | |
| 26 | 16 | |
| 27 | 17 | // ── Actions ────────────────────────────────────────────────────────── |
| 28 | 18 | |
| 29 | 19 | type Action = |
| 30 | 20 | | { type: "ADD_PROJECT"; project: Project } |
| 21 | | { type: "SYNC_PROJECTS"; projects: Project[] } | |
| 31 | 22 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 32 | 23 | | { type: "ADD_THREAD"; thread: Thread } |
| 33 | 24 | | { type: "SET_ACTIVE_THREAD"; threadId: string } |
| ... | ... | @@ -98,10 +89,7 @@ function persistState(state: AppState): void { |
| 98 | 89 | if (typeof window === "undefined") return; |
| 99 | 90 | |
| 100 | 91 | try { |
| 101 | window.localStorage.setItem( | |
| 102 | PERSISTED_STATE_KEY, | |
| 103 | JSON.stringify(toPersistedState(state)), | |
| 104 | ); | |
| 92 | window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state))); | |
| 105 | 93 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { |
| 106 | 94 | window.localStorage.removeItem(legacyKey); |
| 107 | 95 | } |
| ... | ... | @@ -143,10 +131,7 @@ function getEventThreadId(event: ProviderEvent): string | undefined { |
| 143 | 131 | ); |
| 144 | 132 | } |
| 145 | 133 | |
| 146 | function shouldIgnoreForeignThreadEvent( | |
| 147 | thread: Thread, | |
| 148 | event: ProviderEvent, | |
| 149 | ): boolean { | |
| 134 | function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean { | |
| 150 | 135 | const eventThreadId = getEventThreadId(event); |
| 151 | 136 | if (!eventThreadId) { |
| 152 | 137 | return false; |
| ... | ... | @@ -175,10 +160,7 @@ function durationMs(startIso: string, endIso: string): number | undefined { |
| 175 | 160 | return end - start; |
| 176 | 161 | } |
| 177 | 162 | |
| 178 | function updateTurnFields( | |
| 179 | thread: Thread, | |
| 180 | event: ProviderEvent, | |
| 181 | ): Partial<Thread> { | |
| 163 | function updateTurnFields(thread: Thread, event: ProviderEvent): Partial<Thread> { | |
| 182 | 164 | if (event.method === "turn/started") { |
| 183 | 165 | return { |
| 184 | 166 | latestTurnId: getEventTurnId(event) ?? thread.latestTurnId, |
| ... | ... | @@ -195,9 +177,7 @@ function updateTurnFields( |
| 195 | 177 | ? thread.latestTurnStartedAt |
| 196 | 178 | : undefined; |
| 197 | 179 | const elapsed = |
| 198 | startedAt && startedAt.length > 0 | |
| 199 | ? durationMs(startedAt, event.createdAt) | |
| 200 | : undefined; | |
| 180 | startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined; | |
| 201 | 181 | |
| 202 | 182 | return { |
| 203 | 183 | latestTurnId: completedTurnId ?? thread.latestTurnId, |
| ... | ... | @@ -214,6 +194,9 @@ function updateTurnFields( |
| 214 | 194 | export function reducer(state: AppState, action: Action): AppState { |
| 215 | 195 | switch (action.type) { |
| 216 | 196 | case "ADD_PROJECT": |
| 197 | if (state.projects.some((project) => project.cwd === action.project.cwd)) { | |
| 198 | return state; | |
| 199 | } | |
| 217 | 200 | return { |
| 218 | 201 | ...state, |
| 219 | 202 | projects: [ |
| ... | ... | @@ -225,6 +208,48 @@ export function reducer(state: AppState, action: Action): AppState { |
| 225 | 208 | ], |
| 226 | 209 | }; |
| 227 | 210 | |
| 211 | case "SYNC_PROJECTS": { | |
| 212 | const previousByCwd = new Map( | |
| 213 | state.projects.map((project) => [project.cwd, project] as const), | |
| 214 | ); | |
| 215 | const nextProjects = action.projects.map((project) => { | |
| 216 | const previous = previousByCwd.get(project.cwd); | |
| 217 | return { | |
| 218 | ...project, | |
| 219 | model: resolveModelSlug(previous?.model ?? project.model), | |
| 220 | expanded: previous?.expanded ?? project.expanded, | |
| 221 | }; | |
| 222 | }); | |
| 223 | const previousProjectById = new Map( | |
| 224 | state.projects.map((project) => [project.id, project] as const), | |
| 225 | ); | |
| 226 | const nextProjectIdByCwd = new Map( | |
| 227 | nextProjects.map((project) => [project.cwd, project.id] as const), | |
| 228 | ); | |
| 229 | const nextThreads = state.threads | |
| 230 | .map((thread) => { | |
| 231 | const previousProject = previousProjectById.get(thread.projectId); | |
| 232 | if (!previousProject) return null; | |
| 233 | const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd); | |
| 234 | if (!mappedProjectId) return null; | |
| 235 | return { | |
| 236 | ...thread, | |
| 237 | projectId: mappedProjectId, | |
| 238 | }; | |
| 239 | }) | |
| 240 | .filter((thread): thread is Thread => thread !== null); | |
| 241 | const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId) | |
| 242 | ? state.activeThreadId | |
| 243 | : (nextThreads[0]?.id ?? null); | |
| 244 | ||
| 245 | return { | |
| 246 | ...state, | |
| 247 | projects: nextProjects, | |
| 248 | threads: nextThreads, | |
| 249 | activeThreadId, | |
| 250 | }; | |
| 251 | } | |
| 252 | ||
| 228 | 253 | case "TOGGLE_PROJECT": |
| 229 | 254 | return { |
| 230 | 255 | ...state, |
| ... | ... | @@ -265,24 +290,16 @@ export function reducer(state: AppState, action: Action): AppState { |
| 265 | 290 | ...(() => { |
| 266 | 291 | const eventThreadId = getEventThreadId(event); |
| 267 | 292 | const shouldRebindIdentity = |
| 268 | event.method === "thread/started" && | |
| 269 | t.session?.status === "connecting"; | |
| 293 | event.method === "thread/started" && t.session?.status === "connecting"; | |
| 270 | 294 | return { |
| 271 | 295 | codexThreadId: shouldRebindIdentity |
| 272 | ? eventThreadId ?? t.codexThreadId | |
| 273 | : t.codexThreadId ?? eventThreadId ?? null, | |
| 274 | error: | |
| 275 | (event.kind === "error" && event.message | |
| 276 | ? event.message | |
| 277 | : t.error), | |
| 296 | ? (eventThreadId ?? t.codexThreadId) | |
| 297 | : (t.codexThreadId ?? eventThreadId ?? null), | |
| 298 | error: event.kind === "error" && event.message ? event.message : t.error, | |
| 278 | 299 | }; |
| 279 | 300 | })(), |
| 280 | 301 | session: t.session ? evolveSession(t.session, event) : t.session, |
| 281 | messages: applyEventToMessages( | |
| 282 | t.messages, | |
| 283 | event, | |
| 284 | activeAssistantItemRef, | |
| 285 | ), | |
| 302 | messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), | |
| 286 | 303 | events: [event, ...t.events], |
| 287 | 304 | ...updateTurnFields(t, event), |
| 288 | 305 | })), |
apps/renderer/src/vite-env.d.ts+6| ... | ... | @@ -2,8 +2,14 @@ |
| 2 | 2 | |
| 3 | 3 | import type { NativeApi } from "@acme/contracts"; |
| 4 | 4 | |
| 5 | interface DesktopBridge { | |
| 6 | getWsUrl: () => string | null; | |
| 7 | pickFolder: () => Promise<string | null>; | |
| 8 | } | |
| 9 | ||
| 5 | 10 | declare global { |
| 6 | 11 | interface Window { |
| 7 | 12 | nativeApi?: NativeApi; |
| 13 | desktopBridge?: DesktopBridge; | |
| 8 | 14 | } |
| 9 | 15 | } |
apps/renderer/src/wsNativeApi.ts created+102| ... | ... | @@ -0,0 +1,102 @@ |
| 1 | import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@acme/contracts"; | |
| 2 | ||
| 3 | import { WsTransport } from "./wsTransport"; | |
| 4 | ||
| 5 | let instance: { api: NativeApi; transport: WsTransport } | null = null; | |
| 6 | const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); | |
| 7 | let lastWelcome: WsWelcomePayload | null = null; | |
| 8 | ||
| 9 | /** | |
| 10 | * Subscribe to the server welcome message. If a welcome was already received | |
| 11 | * before this call, the listener fires synchronously with the cached payload. | |
| 12 | * This avoids the race between WebSocket connect and React effect registration. | |
| 13 | */ | |
| 14 | export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void { | |
| 15 | welcomeListeners.add(listener); | |
| 16 | ||
| 17 | // Replay cached welcome for late subscribers | |
| 18 | if (lastWelcome) { | |
| 19 | try { | |
| 20 | listener(lastWelcome); | |
| 21 | } catch { | |
| 22 | // Swallow listener errors | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | return () => { | |
| 27 | welcomeListeners.delete(listener); | |
| 28 | }; | |
| 29 | } | |
| 30 | ||
| 31 | export function createWsNativeApi(): NativeApi { | |
| 32 | if (instance) return instance.api; | |
| 33 | ||
| 34 | const transport = new WsTransport(); | |
| 35 | ||
| 36 | // Listen for server welcome and forward to registered listeners. | |
| 37 | // Also cache it so late subscribers (React effects) get it immediately. | |
| 38 | transport.subscribe(WS_CHANNELS.serverWelcome, (data) => { | |
| 39 | const payload = data as WsWelcomePayload; | |
| 40 | lastWelcome = payload; | |
| 41 | for (const listener of welcomeListeners) { | |
| 42 | try { | |
| 43 | listener(payload); | |
| 44 | } catch { | |
| 45 | // Swallow listener errors | |
| 46 | } | |
| 47 | } | |
| 48 | }); | |
| 49 | ||
| 50 | const api: NativeApi = { | |
| 51 | todos: { | |
| 52 | list: async () => [], | |
| 53 | add: async () => [], | |
| 54 | toggle: async () => [], | |
| 55 | remove: async () => [], | |
| 56 | }, | |
| 57 | dialogs: { | |
| 58 | pickFolder: async () => { | |
| 59 | if (!window.desktopBridge) return null; | |
| 60 | return window.desktopBridge.pickFolder(); | |
| 61 | }, | |
| 62 | }, | |
| 63 | terminal: { | |
| 64 | run: async () => ({ | |
| 65 | stdout: "", | |
| 66 | stderr: "Terminal not available in web mode", | |
| 67 | code: 1, | |
| 68 | signal: null, | |
| 69 | timedOut: false, | |
| 70 | }), | |
| 71 | }, | |
| 72 | agent: { | |
| 73 | spawn: async () => "", | |
| 74 | kill: async () => {}, | |
| 75 | write: async () => {}, | |
| 76 | onOutput: () => () => {}, | |
| 77 | onExit: () => () => {}, | |
| 78 | }, | |
| 79 | providers: { | |
| 80 | startSession: (input) => transport.request(WS_METHODS.providersStartSession, input), | |
| 81 | sendTurn: (input) => transport.request(WS_METHODS.providersSendTurn, input), | |
| 82 | interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input), | |
| 83 | respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), | |
| 84 | stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), | |
| 85 | listSessions: () => transport.request(WS_METHODS.providersListSessions), | |
| 86 | onEvent: (callback) => | |
| 87 | transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void), | |
| 88 | }, | |
| 89 | projects: { | |
| 90 | list: () => transport.request(WS_METHODS.projectsList), | |
| 91 | add: (input) => transport.request(WS_METHODS.projectsAdd, input), | |
| 92 | remove: (input) => transport.request(WS_METHODS.projectsRemove, input), | |
| 93 | }, | |
| 94 | shell: { | |
| 95 | openInEditor: (cwd, editor) => | |
| 96 | transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), | |
| 97 | }, | |
| 98 | }; | |
| 99 | ||
| 100 | instance = { api, transport }; | |
| 101 | return api; | |
| 102 | } |
apps/renderer/src/wsTransport.ts created+197| ... | ... | @@ -0,0 +1,197 @@ |
| 1 | import type { WsPush, WsRequest, WsResponse } from "@acme/contracts"; | |
| 2 | ||
| 3 | type PushListener = (data: unknown) => void; | |
| 4 | ||
| 5 | interface PendingRequest { | |
| 6 | resolve: (result: unknown) => void; | |
| 7 | reject: (error: Error) => void; | |
| 8 | timeout: ReturnType<typeof setTimeout>; | |
| 9 | } | |
| 10 | ||
| 11 | const REQUEST_TIMEOUT_MS = 60_000; | |
| 12 | const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; | |
| 13 | ||
| 14 | export class WsTransport { | |
| 15 | private ws: WebSocket | null = null; | |
| 16 | private nextId = 1; | |
| 17 | private readonly pending = new Map<string, PendingRequest>(); | |
| 18 | private readonly listeners = new Map<string, Set<PushListener>>(); | |
| 19 | private reconnectAttempt = 0; | |
| 20 | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; | |
| 21 | private disposed = false; | |
| 22 | private readonly url: string; | |
| 23 | ||
| 24 | constructor(url?: string) { | |
| 25 | const bridgeUrl = window.desktopBridge?.getWsUrl(); | |
| 26 | // In dev mode, VITE_WS_URL points to the server's WebSocket endpoint. | |
| 27 | // In production, the page is served by the WS server on the same host:port. | |
| 28 | const envUrl = import.meta.env.VITE_WS_URL as string | undefined; | |
| 29 | this.url = | |
| 30 | url ?? | |
| 31 | (bridgeUrl && bridgeUrl.length > 0 | |
| 32 | ? bridgeUrl | |
| 33 | : envUrl && envUrl.length > 0 | |
| 34 | ? envUrl | |
| 35 | : `ws://${window.location.hostname}:${window.location.port}`); | |
| 36 | this.connect(); | |
| 37 | } | |
| 38 | ||
| 39 | async request<T = unknown>(method: string, params?: unknown): Promise<T> { | |
| 40 | const id = String(this.nextId++); | |
| 41 | const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) }; | |
| 42 | ||
| 43 | return new Promise<T>((resolve, reject) => { | |
| 44 | const timeout = setTimeout(() => { | |
| 45 | this.pending.delete(id); | |
| 46 | reject(new Error(`Request timed out: ${method}`)); | |
| 47 | }, REQUEST_TIMEOUT_MS); | |
| 48 | ||
| 49 | this.pending.set(id, { | |
| 50 | resolve: resolve as (result: unknown) => void, | |
| 51 | reject, | |
| 52 | timeout, | |
| 53 | }); | |
| 54 | ||
| 55 | this.send(message); | |
| 56 | }); | |
| 57 | } | |
| 58 | ||
| 59 | subscribe(channel: string, listener: PushListener): () => void { | |
| 60 | let channelListeners = this.listeners.get(channel); | |
| 61 | if (!channelListeners) { | |
| 62 | channelListeners = new Set(); | |
| 63 | this.listeners.set(channel, channelListeners); | |
| 64 | } | |
| 65 | channelListeners.add(listener); | |
| 66 | ||
| 67 | return () => { | |
| 68 | channelListeners!.delete(listener); | |
| 69 | if (channelListeners!.size === 0) { | |
| 70 | this.listeners.delete(channel); | |
| 71 | } | |
| 72 | }; | |
| 73 | } | |
| 74 | ||
| 75 | dispose() { | |
| 76 | this.disposed = true; | |
| 77 | if (this.reconnectTimer !== null) { | |
| 78 | clearTimeout(this.reconnectTimer); | |
| 79 | this.reconnectTimer = null; | |
| 80 | } | |
| 81 | for (const pending of this.pending.values()) { | |
| 82 | clearTimeout(pending.timeout); | |
| 83 | pending.reject(new Error("Transport disposed")); | |
| 84 | } | |
| 85 | this.pending.clear(); | |
| 86 | this.ws?.close(); | |
| 87 | this.ws = null; | |
| 88 | } | |
| 89 | ||
| 90 | private connect() { | |
| 91 | if (this.disposed) return; | |
| 92 | ||
| 93 | const ws = new WebSocket(this.url); | |
| 94 | ||
| 95 | ws.addEventListener("open", () => { | |
| 96 | this.ws = ws; | |
| 97 | this.reconnectAttempt = 0; | |
| 98 | }); | |
| 99 | ||
| 100 | ws.addEventListener("message", (event) => { | |
| 101 | this.handleMessage(event.data); | |
| 102 | }); | |
| 103 | ||
| 104 | ws.addEventListener("close", () => { | |
| 105 | this.ws = null; | |
| 106 | this.scheduleReconnect(); | |
| 107 | }); | |
| 108 | ||
| 109 | ws.addEventListener("error", () => { | |
| 110 | // close event will fire after error | |
| 111 | }); | |
| 112 | } | |
| 113 | ||
| 114 | private handleMessage(raw: unknown) { | |
| 115 | let parsed: unknown; | |
| 116 | try { | |
| 117 | parsed = JSON.parse(String(raw)); | |
| 118 | } catch { | |
| 119 | return; | |
| 120 | } | |
| 121 | ||
| 122 | if (!parsed || typeof parsed !== "object") return; | |
| 123 | ||
| 124 | const message = parsed as Record<string, unknown>; | |
| 125 | ||
| 126 | // Push event | |
| 127 | if (message.type === "push") { | |
| 128 | const push = message as unknown as WsPush; | |
| 129 | const channelListeners = this.listeners.get(push.channel); | |
| 130 | if (channelListeners) { | |
| 131 | for (const listener of channelListeners) { | |
| 132 | try { | |
| 133 | listener(push.data); | |
| 134 | } catch { | |
| 135 | // Swallow listener errors | |
| 136 | } | |
| 137 | } | |
| 138 | } | |
| 139 | return; | |
| 140 | } | |
| 141 | ||
| 142 | // Response to a request | |
| 143 | if (typeof message.id === "string") { | |
| 144 | const response = message as unknown as WsResponse; | |
| 145 | const pending = this.pending.get(response.id); | |
| 146 | if (!pending) return; | |
| 147 | ||
| 148 | clearTimeout(pending.timeout); | |
| 149 | this.pending.delete(response.id); | |
| 150 | ||
| 151 | if (response.error) { | |
| 152 | pending.reject(new Error(response.error.message)); | |
| 153 | } else { | |
| 154 | pending.resolve(response.result); | |
| 155 | } | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | private send(message: WsRequest) { | |
| 160 | if (this.ws?.readyState === WebSocket.OPEN) { | |
| 161 | this.ws.send(JSON.stringify(message)); | |
| 162 | return; | |
| 163 | } | |
| 164 | ||
| 165 | // If not connected, wait for connection | |
| 166 | const waitForOpen = () => { | |
| 167 | const check = setInterval(() => { | |
| 168 | if (this.disposed) { | |
| 169 | clearInterval(check); | |
| 170 | return; | |
| 171 | } | |
| 172 | if (this.ws?.readyState === WebSocket.OPEN) { | |
| 173 | clearInterval(check); | |
| 174 | this.ws.send(JSON.stringify(message)); | |
| 175 | } | |
| 176 | }, 50); | |
| 177 | ||
| 178 | // Give up after timeout (the pending request will time out on its own) | |
| 179 | setTimeout(() => clearInterval(check), REQUEST_TIMEOUT_MS); | |
| 180 | }; | |
| 181 | waitForOpen(); | |
| 182 | } | |
| 183 | ||
| 184 | private scheduleReconnect() { | |
| 185 | if (this.disposed) return; | |
| 186 | ||
| 187 | const delay = | |
| 188 | RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? | |
| 189 | RECONNECT_DELAYS_MS[0]!; | |
| 190 | ||
| 191 | this.reconnectAttempt++; | |
| 192 | this.reconnectTimer = setTimeout(() => { | |
| 193 | this.reconnectTimer = null; | |
| 194 | this.connect(); | |
| 195 | }, delay); | |
| 196 | } | |
| 197 | } |
apps/renderer/vite.config.ts+7-1| ... | ... | @@ -11,8 +11,14 @@ export default defineConfig({ |
| 11 | 11 | }), |
| 12 | 12 | tailwindcss(), |
| 13 | 13 | ], |
| 14 | define: { | |
| 15 | // In dev mode, tell the renderer where the WebSocket server lives | |
| 16 | "import.meta.env.VITE_WS_URL": JSON.stringify( | |
| 17 | process.env.VITE_WS_URL ?? "", | |
| 18 | ), | |
| 19 | }, | |
| 14 | 20 | server: { |
| 15 | port: Number(process.env.ELECTRON_RENDERER_PORT ?? 5173), | |
| 21 | port: Number(process.env.PORT ?? 5173), | |
| 16 | 22 | strictPort: true, |
| 17 | 23 | }, |
| 18 | 24 | build: { |
apps/server/package.json created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | { | |
| 2 | "name": "t3", | |
| 3 | "version": "0.1.0", | |
| 4 | "bin": { | |
| 5 | "t3": "./dist/index.js" | |
| 6 | }, | |
| 7 | "files": [ | |
| 8 | "dist" | |
| 9 | ], | |
| 10 | "type": "module", | |
| 11 | "main": "./dist/index.js", | |
| 12 | "scripts": { | |
| 13 | "dev": "VITE_DEV_SERVER_URL=http://localhost:5173 tsx src/index.ts", | |
| 14 | "dev:desktop": "CODETHING_MODE=desktop CODETHING_NO_BROWSER=1 tsx src/index.ts", | |
| 15 | "build": "tsup && node scripts/bundle-client.mjs", | |
| 16 | "start": "node dist/index.js", | |
| 17 | "start:desktop": "CODETHING_MODE=desktop CODETHING_NO_BROWSER=1 node dist/index.js", | |
| 18 | "typecheck": "tsc --noEmit", | |
| 19 | "test": "vitest run" | |
| 20 | }, | |
| 21 | "dependencies": { | |
| 22 | "open": "^10.1.0", | |
| 23 | "ws": "^8.18.0" | |
| 24 | }, | |
| 25 | "devDependencies": { | |
| 26 | "@acme/contracts": "workspace:*", | |
| 27 | "@acme/renderer": "workspace:*", | |
| 28 | "@types/node": "^22.10.2", | |
| 29 | "@types/ws": "^8.5.13", | |
| 30 | "tsup": "^8.3.5", | |
| 31 | "tsx": "^4.19.0", | |
| 32 | "typescript": "^5.7.3" | |
| 33 | } | |
| 34 | } |
apps/server/scripts/bundle-client.mjs created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | /** | |
| 2 | * Copies the built renderer into dist/client/ so the published npm package | |
| 3 | * includes the web UI. This runs as a post-build step. | |
| 4 | */ | |
| 5 | import fs from "node:fs"; | |
| 6 | import path from "node:path"; | |
| 7 | import { fileURLToPath } from "node:url"; | |
| 8 | ||
| 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | const rendererDist = path.resolve(__dirname, "../../renderer/dist"); | |
| 11 | const target = path.resolve(__dirname, "../dist/client"); | |
| 12 | ||
| 13 | if (!fs.existsSync(rendererDist)) { | |
| 14 | console.log( | |
| 15 | "⚠ Renderer dist not found — skipping client bundle. Run `bun run --cwd apps/renderer build` first.", | |
| 16 | ); | |
| 17 | process.exit(0); | |
| 18 | } | |
| 19 | ||
| 20 | fs.cpSync(rendererDist, target, { recursive: true }); | |
| 21 | console.log("✓ Bundled renderer into dist/client"); |
apps/server/src/codexAppServerManager.test.ts created+81| ... | ... | @@ -0,0 +1,81 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | classifyCodexStderrLine, | |
| 5 | isRecoverableThreadResumeError, | |
| 6 | normalizeCodexModelSlug, | |
| 7 | } from "./codexAppServerManager"; | |
| 8 | ||
| 9 | describe("classifyCodexStderrLine", () => { | |
| 10 | it("ignores empty lines", () => { | |
| 11 | expect(classifyCodexStderrLine(" ")).toBeNull(); | |
| 12 | }); | |
| 13 | ||
| 14 | it("ignores non-error structured codex logs", () => { | |
| 15 | const line = | |
| 16 | "2026-02-08T04:24:19.241256Z WARN codex_core::features: unknown feature key in config: skills"; | |
| 17 | expect(classifyCodexStderrLine(line)).toBeNull(); | |
| 18 | }); | |
| 19 | ||
| 20 | it("ignores known benign rollout path errors", () => { | |
| 21 | const line = | |
| 22 | "\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"; | |
| 23 | expect(classifyCodexStderrLine(line)).toBeNull(); | |
| 24 | }); | |
| 25 | ||
| 26 | it("keeps unknown structured errors", () => { | |
| 27 | const line = "2026-02-08T04:24:20.085687Z ERROR codex_core::runtime: unrecoverable failure"; | |
| 28 | expect(classifyCodexStderrLine(line)).toEqual({ | |
| 29 | message: line, | |
| 30 | }); | |
| 31 | }); | |
| 32 | ||
| 33 | it("keeps plain stderr messages", () => { | |
| 34 | const line = "fatal: permission denied"; | |
| 35 | expect(classifyCodexStderrLine(line)).toEqual({ | |
| 36 | message: line, | |
| 37 | }); | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | describe("normalizeCodexModelSlug", () => { | |
| 42 | it("maps 5.3 aliases to gpt-5.3-codex", () => { | |
| 43 | expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex"); | |
| 44 | expect(normalizeCodexModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); | |
| 45 | }); | |
| 46 | ||
| 47 | it("prefers codex id when model differs", () => { | |
| 48 | expect(normalizeCodexModelSlug("gpt-5.3", "gpt-5.3-codex")).toBe("gpt-5.3-codex"); | |
| 49 | }); | |
| 50 | ||
| 51 | it("keeps non-aliased models as-is", () => { | |
| 52 | expect(normalizeCodexModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | |
| 53 | expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2"); | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | describe("isRecoverableThreadResumeError", () => { | |
| 58 | it("matches not-found resume errors", () => { | |
| 59 | expect( | |
| 60 | isRecoverableThreadResumeError( | |
| 61 | new Error("thread/resume failed: thread not found"), | |
| 62 | ), | |
| 63 | ).toBe(true); | |
| 64 | }); | |
| 65 | ||
| 66 | it("ignores non-resume errors", () => { | |
| 67 | expect( | |
| 68 | isRecoverableThreadResumeError( | |
| 69 | new Error("thread/start failed: permission denied"), | |
| 70 | ), | |
| 71 | ).toBe(false); | |
| 72 | }); | |
| 73 | ||
| 74 | it("ignores non-recoverable resume errors", () => { | |
| 75 | expect( | |
| 76 | isRecoverableThreadResumeError( | |
| 77 | new Error("thread/resume failed: timed out waiting for server"), | |
| 78 | ), | |
| 79 | ).toBe(false); | |
| 80 | }); | |
| 81 | }); |
apps/server/src/codexAppServerManager.ts created+847| ... | ... | @@ -0,0 +1,847 @@ |
| 1 | import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; | |
| 2 | import { randomUUID } from "node:crypto"; | |
| 3 | import { EventEmitter } from "node:events"; | |
| 4 | import readline from "node:readline"; | |
| 5 | ||
| 6 | import type { | |
| 7 | ProviderApprovalDecision, | |
| 8 | ProviderEvent, | |
| 9 | ProviderRequestKind, | |
| 10 | ProviderSendTurnInput, | |
| 11 | ProviderSession, | |
| 12 | ProviderSessionStartInput, | |
| 13 | ProviderTurnStartResult, | |
| 14 | } from "@acme/contracts"; | |
| 15 | import { normalizeModelSlug } from "@acme/contracts"; | |
| 16 | ||
| 17 | type PendingRequestKey = string; | |
| 18 | ||
| 19 | interface PendingRequest { | |
| 20 | method: string; | |
| 21 | timeout: ReturnType<typeof setTimeout>; | |
| 22 | resolve: (value: unknown) => void; | |
| 23 | reject: (error: Error) => void; | |
| 24 | } | |
| 25 | ||
| 26 | interface PendingApprovalRequest { | |
| 27 | requestId: string; | |
| 28 | jsonRpcId: string | number; | |
| 29 | method: | |
| 30 | | "item/commandExecution/requestApproval" | |
| 31 | | "item/fileChange/requestApproval"; | |
| 32 | requestKind: ProviderRequestKind; | |
| 33 | threadId?: string; | |
| 34 | turnId?: string; | |
| 35 | itemId?: string; | |
| 36 | } | |
| 37 | ||
| 38 | interface CodexSessionContext { | |
| 39 | session: ProviderSession; | |
| 40 | child: ChildProcessWithoutNullStreams; | |
| 41 | output: readline.Interface; | |
| 42 | pending: Map<PendingRequestKey, PendingRequest>; | |
| 43 | pendingApprovals: Map<string, PendingApprovalRequest>; | |
| 44 | nextRequestId: number; | |
| 45 | stopping: boolean; | |
| 46 | } | |
| 47 | ||
| 48 | interface JsonRpcError { | |
| 49 | code?: number; | |
| 50 | message?: string; | |
| 51 | } | |
| 52 | ||
| 53 | interface JsonRpcRequest { | |
| 54 | id: string | number; | |
| 55 | method: string; | |
| 56 | params?: unknown; | |
| 57 | } | |
| 58 | ||
| 59 | interface JsonRpcResponse { | |
| 60 | id: string | number; | |
| 61 | result?: unknown; | |
| 62 | error?: JsonRpcError; | |
| 63 | } | |
| 64 | ||
| 65 | interface JsonRpcNotification { | |
| 66 | method: string; | |
| 67 | params?: unknown; | |
| 68 | } | |
| 69 | ||
| 70 | const ANSI_ESCAPE_CHAR = String.fromCharCode(27); | |
| 71 | const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); | |
| 72 | const CODEX_STDERR_LOG_REGEX = | |
| 73 | /^\d{4}-\d{2}-\d{2}T\S+\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+\S+:\s+(.*)$/; | |
| 74 | const BENIGN_ERROR_LOG_SNIPPETS = [ | |
| 75 | "state db missing rollout path for thread", | |
| 76 | "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", | |
| 77 | ]; | |
| 78 | const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ | |
| 79 | "not found", | |
| 80 | "missing thread", | |
| 81 | "no such thread", | |
| 82 | "unknown thread", | |
| 83 | "does not exist", | |
| 84 | ]; | |
| 85 | ||
| 86 | export function normalizeCodexModelSlug( | |
| 87 | model: string | undefined | null, | |
| 88 | preferredId?: string, | |
| 89 | ): string | undefined { | |
| 90 | const normalized = normalizeModelSlug(model); | |
| 91 | if (!normalized) { | |
| 92 | return undefined; | |
| 93 | } | |
| 94 | ||
| 95 | if (preferredId?.endsWith("-codex") && preferredId !== normalized) { | |
| 96 | return preferredId; | |
| 97 | } | |
| 98 | ||
| 99 | return normalized; | |
| 100 | } | |
| 101 | ||
| 102 | export function classifyCodexStderrLine(rawLine: string): { message: string } | null { | |
| 103 | const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); | |
| 104 | if (!line) { | |
| 105 | return null; | |
| 106 | } | |
| 107 | ||
| 108 | const match = line.match(CODEX_STDERR_LOG_REGEX); | |
| 109 | if (match) { | |
| 110 | const level = match[1]; | |
| 111 | if (level && level !== "ERROR") { | |
| 112 | return null; | |
| 113 | } | |
| 114 | ||
| 115 | const isBenignError = BENIGN_ERROR_LOG_SNIPPETS.some((snippet) => line.includes(snippet)); | |
| 116 | if (isBenignError) { | |
| 117 | return null; | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | return { message: line }; | |
| 122 | } | |
| 123 | ||
| 124 | export function isRecoverableThreadResumeError(error: unknown): boolean { | |
| 125 | const message = ( | |
| 126 | error instanceof Error ? error.message : String(error) | |
| 127 | ).toLowerCase(); | |
| 128 | if (!message.includes("thread/resume")) { | |
| 129 | return false; | |
| 130 | } | |
| 131 | ||
| 132 | return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => | |
| 133 | message.includes(snippet), | |
| 134 | ); | |
| 135 | } | |
| 136 | ||
| 137 | export interface CodexAppServerManagerEvents { | |
| 138 | event: [event: ProviderEvent]; | |
| 139 | } | |
| 140 | ||
| 141 | export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> { | |
| 142 | private readonly sessions = new Map<string, CodexSessionContext>(); | |
| 143 | ||
| 144 | async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { | |
| 145 | const sessionId = randomUUID(); | |
| 146 | const now = new Date().toISOString(); | |
| 147 | ||
| 148 | const session: ProviderSession = { | |
| 149 | sessionId, | |
| 150 | provider: "codex", | |
| 151 | status: "connecting", | |
| 152 | model: normalizeCodexModelSlug(input.model), | |
| 153 | cwd: input.cwd, | |
| 154 | createdAt: now, | |
| 155 | updatedAt: now, | |
| 156 | }; | |
| 157 | ||
| 158 | const child = spawn("codex", ["app-server"], { | |
| 159 | cwd: input.cwd, | |
| 160 | env: process.env, | |
| 161 | stdio: ["pipe", "pipe", "pipe"], | |
| 162 | }); | |
| 163 | const output = readline.createInterface({ input: child.stdout }); | |
| 164 | ||
| 165 | const context: CodexSessionContext = { | |
| 166 | session, | |
| 167 | child, | |
| 168 | output, | |
| 169 | pending: new Map(), | |
| 170 | pendingApprovals: new Map(), | |
| 171 | nextRequestId: 1, | |
| 172 | stopping: false, | |
| 173 | }; | |
| 174 | ||
| 175 | this.sessions.set(sessionId, context); | |
| 176 | this.attachProcessListeners(context); | |
| 177 | ||
| 178 | this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); | |
| 179 | ||
| 180 | try { | |
| 181 | await this.sendRequest(context, "initialize", { | |
| 182 | clientInfo: { | |
| 183 | name: "codething_desktop", | |
| 184 | title: "CodeThing Desktop", | |
| 185 | version: "0.1.0", | |
| 186 | }, | |
| 187 | capabilities: { | |
| 188 | experimentalApi: false, | |
| 189 | }, | |
| 190 | }); | |
| 191 | ||
| 192 | this.writeMessage(context, { method: "initialized" }); | |
| 193 | ||
| 194 | const normalizedModel = normalizeCodexModelSlug(input.model); | |
| 195 | const sessionOverrides = { | |
| 196 | model: normalizedModel ?? null, | |
| 197 | cwd: input.cwd ?? null, | |
| 198 | approvalPolicy: input.approvalPolicy, | |
| 199 | sandbox: input.sandboxMode, | |
| 200 | }; | |
| 201 | ||
| 202 | const threadStartParams = { | |
| 203 | ...sessionOverrides, | |
| 204 | experimentalRawEvents: false, | |
| 205 | }; | |
| 206 | ||
| 207 | let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start"; | |
| 208 | let threadOpenResponse: unknown; | |
| 209 | if (input.resumeThreadId) { | |
| 210 | try { | |
| 211 | threadOpenMethod = "thread/resume"; | |
| 212 | threadOpenResponse = await this.sendRequest( | |
| 213 | context, | |
| 214 | "thread/resume", | |
| 215 | { | |
| 216 | ...sessionOverrides, | |
| 217 | threadId: input.resumeThreadId, | |
| 218 | }, | |
| 219 | ); | |
| 220 | } catch (error) { | |
| 221 | if (!isRecoverableThreadResumeError(error)) { | |
| 222 | throw error; | |
| 223 | } | |
| 224 | ||
| 225 | threadOpenMethod = "thread/start"; | |
| 226 | this.emitLifecycleEvent( | |
| 227 | context, | |
| 228 | "session/threadResumeFallback", | |
| 229 | `Could not resume thread ${input.resumeThreadId}; started a new thread instead.`, | |
| 230 | ); | |
| 231 | threadOpenResponse = await this.sendRequest( | |
| 232 | context, | |
| 233 | "thread/start", | |
| 234 | threadStartParams, | |
| 235 | ); | |
| 236 | } | |
| 237 | } else { | |
| 238 | threadOpenMethod = "thread/start"; | |
| 239 | threadOpenResponse = await this.sendRequest( | |
| 240 | context, | |
| 241 | "thread/start", | |
| 242 | threadStartParams, | |
| 243 | ); | |
| 244 | } | |
| 245 | ||
| 246 | const threadOpenRecord = this.readObject(threadOpenResponse); | |
| 247 | const threadId = | |
| 248 | this.readString(this.readObject(threadOpenRecord, "thread"), "id") ?? | |
| 249 | this.readString(threadOpenRecord, "threadId"); | |
| 250 | if (!threadId) { | |
| 251 | throw new Error( | |
| 252 | `${threadOpenMethod} response did not include a thread id.`, | |
| 253 | ); | |
| 254 | } | |
| 255 | ||
| 256 | this.updateSession(context, { | |
| 257 | status: "ready", | |
| 258 | threadId, | |
| 259 | }); | |
| 260 | this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`); | |
| 261 | return { ...context.session }; | |
| 262 | } catch (error) { | |
| 263 | const message = error instanceof Error ? error.message : "Failed to start Codex session."; | |
| 264 | this.updateSession(context, { | |
| 265 | status: "error", | |
| 266 | lastError: message, | |
| 267 | }); | |
| 268 | this.emitErrorEvent(context, "session/startFailed", message); | |
| 269 | this.stopSession(sessionId); | |
| 270 | throw new Error(message, { cause: error }); | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | async sendTurn(input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> { | |
| 275 | const context = this.requireSession(input.sessionId); | |
| 276 | if (!context.session.threadId) { | |
| 277 | throw new Error("Session is missing a thread id."); | |
| 278 | } | |
| 279 | ||
| 280 | const turnStartParams: { | |
| 281 | threadId: string; | |
| 282 | input: Array<{ type: "text"; text: string; text_elements: [] }>; | |
| 283 | model?: string; | |
| 284 | effort?: string; | |
| 285 | } = { | |
| 286 | threadId: context.session.threadId, | |
| 287 | input: [ | |
| 288 | { | |
| 289 | type: "text", | |
| 290 | text: input.input, | |
| 291 | text_elements: [], | |
| 292 | }, | |
| 293 | ], | |
| 294 | }; | |
| 295 | const normalizedModel = normalizeCodexModelSlug(input.model); | |
| 296 | if (normalizedModel) { | |
| 297 | turnStartParams.model = normalizedModel; | |
| 298 | } | |
| 299 | if (input.effort) { | |
| 300 | turnStartParams.effort = input.effort; | |
| 301 | } | |
| 302 | ||
| 303 | const response = await this.sendRequest(context, "turn/start", turnStartParams); | |
| 304 | ||
| 305 | const turn = this.readObject(this.readObject(response), "turn"); | |
| 306 | const turnId = this.readString(turn, "id"); | |
| 307 | if (!turnId) { | |
| 308 | throw new Error("turn/start response did not include a turn id."); | |
| 309 | } | |
| 310 | ||
| 311 | this.updateSession(context, { | |
| 312 | status: "running", | |
| 313 | activeTurnId: turnId, | |
| 314 | }); | |
| 315 | ||
| 316 | return { | |
| 317 | threadId: context.session.threadId, | |
| 318 | turnId, | |
| 319 | }; | |
| 320 | } | |
| 321 | ||
| 322 | async interruptTurn(sessionId: string, turnId?: string): Promise<void> { | |
| 323 | const context = this.requireSession(sessionId); | |
| 324 | const effectiveTurnId = turnId ?? context.session.activeTurnId; | |
| 325 | ||
| 326 | if (!effectiveTurnId || !context.session.threadId) { | |
| 327 | return; | |
| 328 | } | |
| 329 | ||
| 330 | await this.sendRequest(context, "turn/interrupt", { | |
| 331 | threadId: context.session.threadId, | |
| 332 | turnId: effectiveTurnId, | |
| 333 | }); | |
| 334 | } | |
| 335 | ||
| 336 | async respondToRequest( | |
| 337 | sessionId: string, | |
| 338 | requestId: string, | |
| 339 | decision: ProviderApprovalDecision, | |
| 340 | ): Promise<void> { | |
| 341 | const context = this.requireSession(sessionId); | |
| 342 | const pendingRequest = context.pendingApprovals.get(requestId); | |
| 343 | if (!pendingRequest) { | |
| 344 | throw new Error(`Unknown pending approval request: ${requestId}`); | |
| 345 | } | |
| 346 | ||
| 347 | context.pendingApprovals.delete(requestId); | |
| 348 | this.writeMessage(context, { | |
| 349 | id: pendingRequest.jsonRpcId, | |
| 350 | result: { | |
| 351 | decision, | |
| 352 | }, | |
| 353 | }); | |
| 354 | ||
| 355 | this.emitEvent({ | |
| 356 | id: randomUUID(), | |
| 357 | kind: "notification", | |
| 358 | provider: "codex", | |
| 359 | sessionId: context.session.sessionId, | |
| 360 | createdAt: new Date().toISOString(), | |
| 361 | method: "item/requestApproval/decision", | |
| 362 | threadId: pendingRequest.threadId, | |
| 363 | turnId: pendingRequest.turnId, | |
| 364 | itemId: pendingRequest.itemId, | |
| 365 | requestId: pendingRequest.requestId, | |
| 366 | requestKind: pendingRequest.requestKind, | |
| 367 | payload: { | |
| 368 | requestId: pendingRequest.requestId, | |
| 369 | requestKind: pendingRequest.requestKind, | |
| 370 | decision, | |
| 371 | }, | |
| 372 | }); | |
| 373 | } | |
| 374 | ||
| 375 | stopSession(sessionId: string): void { | |
| 376 | const context = this.sessions.get(sessionId); | |
| 377 | if (!context) { | |
| 378 | return; | |
| 379 | } | |
| 380 | ||
| 381 | context.stopping = true; | |
| 382 | ||
| 383 | for (const pending of context.pending.values()) { | |
| 384 | clearTimeout(pending.timeout); | |
| 385 | pending.reject(new Error("Session stopped before request completed.")); | |
| 386 | } | |
| 387 | context.pending.clear(); | |
| 388 | context.pendingApprovals.clear(); | |
| 389 | ||
| 390 | context.output.close(); | |
| 391 | ||
| 392 | if (!context.child.killed) { | |
| 393 | context.child.kill(); | |
| 394 | } | |
| 395 | ||
| 396 | this.updateSession(context, { | |
| 397 | status: "closed", | |
| 398 | activeTurnId: undefined, | |
| 399 | }); | |
| 400 | this.emitLifecycleEvent(context, "session/closed", "Session stopped"); | |
| 401 | this.sessions.delete(sessionId); | |
| 402 | } | |
| 403 | ||
| 404 | listSessions(): ProviderSession[] { | |
| 405 | return Array.from(this.sessions.values(), ({ session }) => ({ | |
| 406 | ...session, | |
| 407 | })); | |
| 408 | } | |
| 409 | ||
| 410 | hasSession(sessionId: string): boolean { | |
| 411 | return this.sessions.has(sessionId); | |
| 412 | } | |
| 413 | ||
| 414 | stopAll(): void { | |
| 415 | for (const sessionId of this.sessions.keys()) { | |
| 416 | this.stopSession(sessionId); | |
| 417 | } | |
| 418 | } | |
| 419 | ||
| 420 | private requireSession(sessionId: string): CodexSessionContext { | |
| 421 | const context = this.sessions.get(sessionId); | |
| 422 | if (!context) { | |
| 423 | throw new Error(`Unknown session: ${sessionId}`); | |
| 424 | } | |
| 425 | ||
| 426 | if (context.session.status === "closed") { | |
| 427 | throw new Error(`Session is closed: ${sessionId}`); | |
| 428 | } | |
| 429 | ||
| 430 | return context; | |
| 431 | } | |
| 432 | ||
| 433 | private attachProcessListeners(context: CodexSessionContext): void { | |
| 434 | context.output.on("line", (line) => { | |
| 435 | this.handleStdoutLine(context, line); | |
| 436 | }); | |
| 437 | ||
| 438 | context.child.stderr.on("data", (chunk: Buffer) => { | |
| 439 | const raw = chunk.toString(); | |
| 440 | const lines = raw.split(/\r?\n/g); | |
| 441 | for (const rawLine of lines) { | |
| 442 | const classified = classifyCodexStderrLine(rawLine); | |
| 443 | if (!classified) { | |
| 444 | continue; | |
| 445 | } | |
| 446 | ||
| 447 | this.emitErrorEvent(context, "process/stderr", classified.message); | |
| 448 | } | |
| 449 | }); | |
| 450 | ||
| 451 | context.child.on("error", (error) => { | |
| 452 | const message = error.message || "codex app-server process errored."; | |
| 453 | this.updateSession(context, { | |
| 454 | status: "error", | |
| 455 | lastError: message, | |
| 456 | }); | |
| 457 | this.emitErrorEvent(context, "process/error", message); | |
| 458 | }); | |
| 459 | ||
| 460 | context.child.on("exit", (code, signal) => { | |
| 461 | if (context.stopping) { | |
| 462 | return; | |
| 463 | } | |
| 464 | ||
| 465 | const message = `codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`; | |
| 466 | this.updateSession(context, { | |
| 467 | status: "closed", | |
| 468 | activeTurnId: undefined, | |
| 469 | lastError: code === 0 ? context.session.lastError : message, | |
| 470 | }); | |
| 471 | this.emitLifecycleEvent(context, "session/exited", message); | |
| 472 | this.sessions.delete(context.session.sessionId); | |
| 473 | }); | |
| 474 | } | |
| 475 | ||
| 476 | private handleStdoutLine(context: CodexSessionContext, line: string): void { | |
| 477 | let parsed: unknown; | |
| 478 | try { | |
| 479 | parsed = JSON.parse(line); | |
| 480 | } catch { | |
| 481 | this.emitErrorEvent( | |
| 482 | context, | |
| 483 | "protocol/parseError", | |
| 484 | "Received invalid JSON from codex app-server.", | |
| 485 | ); | |
| 486 | return; | |
| 487 | } | |
| 488 | ||
| 489 | if (!parsed || typeof parsed !== "object") { | |
| 490 | this.emitErrorEvent( | |
| 491 | context, | |
| 492 | "protocol/invalidMessage", | |
| 493 | "Received non-object protocol message.", | |
| 494 | ); | |
| 495 | return; | |
| 496 | } | |
| 497 | ||
| 498 | if (this.isServerRequest(parsed)) { | |
| 499 | this.handleServerRequest(context, parsed); | |
| 500 | return; | |
| 501 | } | |
| 502 | ||
| 503 | if (this.isServerNotification(parsed)) { | |
| 504 | this.handleServerNotification(context, parsed); | |
| 505 | return; | |
| 506 | } | |
| 507 | ||
| 508 | if (this.isResponse(parsed)) { | |
| 509 | this.handleResponse(context, parsed); | |
| 510 | return; | |
| 511 | } | |
| 512 | ||
| 513 | this.emitErrorEvent( | |
| 514 | context, | |
| 515 | "protocol/unrecognizedMessage", | |
| 516 | "Received protocol message in an unknown shape.", | |
| 517 | ); | |
| 518 | } | |
| 519 | ||
| 520 | private handleServerNotification( | |
| 521 | context: CodexSessionContext, | |
| 522 | notification: JsonRpcNotification, | |
| 523 | ): void { | |
| 524 | const route = this.readRouteFields(notification.params); | |
| 525 | const textDelta = | |
| 526 | notification.method === "item/agentMessage/delta" | |
| 527 | ? this.readString(notification.params, "delta") | |
| 528 | : undefined; | |
| 529 | ||
| 530 | this.emitEvent({ | |
| 531 | id: randomUUID(), | |
| 532 | kind: "notification", | |
| 533 | provider: "codex", | |
| 534 | sessionId: context.session.sessionId, | |
| 535 | createdAt: new Date().toISOString(), | |
| 536 | method: notification.method, | |
| 537 | threadId: route.threadId, | |
| 538 | turnId: route.turnId, | |
| 539 | itemId: route.itemId, | |
| 540 | textDelta, | |
| 541 | payload: notification.params, | |
| 542 | }); | |
| 543 | ||
| 544 | if (notification.method === "thread/started") { | |
| 545 | const threadId = this.readString(this.readObject(notification.params)?.thread, "id"); | |
| 546 | if (threadId) { | |
| 547 | this.updateSession(context, { threadId }); | |
| 548 | } | |
| 549 | return; | |
| 550 | } | |
| 551 | ||
| 552 | if (notification.method === "turn/started") { | |
| 553 | const turnId = this.readString(this.readObject(notification.params)?.turn, "id"); | |
| 554 | this.updateSession(context, { | |
| 555 | status: "running", | |
| 556 | activeTurnId: turnId, | |
| 557 | }); | |
| 558 | return; | |
| 559 | } | |
| 560 | ||
| 561 | if (notification.method === "turn/completed") { | |
| 562 | const turn = this.readObject(notification.params, "turn"); | |
| 563 | const status = this.readString(turn, "status"); | |
| 564 | const errorMessage = this.readString(this.readObject(turn, "error"), "message"); | |
| 565 | this.updateSession(context, { | |
| 566 | status: status === "failed" ? "error" : "ready", | |
| 567 | activeTurnId: undefined, | |
| 568 | lastError: errorMessage ?? context.session.lastError, | |
| 569 | }); | |
| 570 | return; | |
| 571 | } | |
| 572 | ||
| 573 | if (notification.method === "error") { | |
| 574 | const message = this.readString(this.readObject(notification.params)?.error, "message"); | |
| 575 | const willRetry = this.readBoolean(notification.params, "willRetry"); | |
| 576 | ||
| 577 | this.updateSession(context, { | |
| 578 | status: willRetry ? "running" : "error", | |
| 579 | lastError: message ?? context.session.lastError, | |
| 580 | }); | |
| 581 | } | |
| 582 | } | |
| 583 | ||
| 584 | private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void { | |
| 585 | const route = this.readRouteFields(request.params); | |
| 586 | const requestKind = this.requestKindForMethod(request.method); | |
| 587 | let requestId: string | undefined; | |
| 588 | if (requestKind) { | |
| 589 | requestId = randomUUID(); | |
| 590 | const pendingRequest: PendingApprovalRequest = { | |
| 591 | requestId, | |
| 592 | jsonRpcId: request.id, | |
| 593 | method: | |
| 594 | requestKind === "command" | |
| 595 | ? "item/commandExecution/requestApproval" | |
| 596 | : "item/fileChange/requestApproval", | |
| 597 | requestKind, | |
| 598 | ...(route.threadId ? { threadId: route.threadId } : {}), | |
| 599 | ...(route.turnId ? { turnId: route.turnId } : {}), | |
| 600 | ...(route.itemId ? { itemId: route.itemId } : {}), | |
| 601 | }; | |
| 602 | context.pendingApprovals.set(requestId, pendingRequest); | |
| 603 | } | |
| 604 | ||
| 605 | this.emitEvent({ | |
| 606 | id: randomUUID(), | |
| 607 | kind: "request", | |
| 608 | provider: "codex", | |
| 609 | sessionId: context.session.sessionId, | |
| 610 | createdAt: new Date().toISOString(), | |
| 611 | method: request.method, | |
| 612 | threadId: route.threadId, | |
| 613 | turnId: route.turnId, | |
| 614 | itemId: route.itemId, | |
| 615 | requestId, | |
| 616 | requestKind, | |
| 617 | payload: request.params, | |
| 618 | }); | |
| 619 | ||
| 620 | if (requestKind) { | |
| 621 | return; | |
| 622 | } | |
| 623 | ||
| 624 | if (request.method === "item/tool/requestUserInput") { | |
| 625 | this.writeMessage(context, { | |
| 626 | id: request.id, | |
| 627 | result: { answers: {} }, | |
| 628 | }); | |
| 629 | return; | |
| 630 | } | |
| 631 | ||
| 632 | this.writeMessage(context, { | |
| 633 | id: request.id, | |
| 634 | error: { | |
| 635 | code: -32601, | |
| 636 | message: `Unsupported server request: ${request.method}`, | |
| 637 | }, | |
| 638 | }); | |
| 639 | } | |
| 640 | ||
| 641 | private handleResponse(context: CodexSessionContext, response: JsonRpcResponse): void { | |
| 642 | const key = String(response.id); | |
| 643 | const pending = context.pending.get(key); | |
| 644 | if (!pending) { | |
| 645 | return; | |
| 646 | } | |
| 647 | ||
| 648 | clearTimeout(pending.timeout); | |
| 649 | context.pending.delete(key); | |
| 650 | ||
| 651 | if (response.error?.message) { | |
| 652 | pending.reject(new Error(`${pending.method} failed: ${String(response.error.message)}`)); | |
| 653 | return; | |
| 654 | } | |
| 655 | ||
| 656 | pending.resolve(response.result); | |
| 657 | } | |
| 658 | ||
| 659 | private async sendRequest<TResponse>( | |
| 660 | context: CodexSessionContext, | |
| 661 | method: string, | |
| 662 | params: unknown, | |
| 663 | timeoutMs = 20_000, | |
| 664 | ): Promise<TResponse> { | |
| 665 | const id = context.nextRequestId; | |
| 666 | context.nextRequestId += 1; | |
| 667 | ||
| 668 | const result = await new Promise<unknown>((resolve, reject) => { | |
| 669 | const timeout = setTimeout(() => { | |
| 670 | context.pending.delete(String(id)); | |
| 671 | reject(new Error(`Timed out waiting for ${method}.`)); | |
| 672 | }, timeoutMs); | |
| 673 | ||
| 674 | context.pending.set(String(id), { | |
| 675 | method, | |
| 676 | timeout, | |
| 677 | resolve, | |
| 678 | reject, | |
| 679 | }); | |
| 680 | this.writeMessage(context, { | |
| 681 | method, | |
| 682 | id, | |
| 683 | params, | |
| 684 | }); | |
| 685 | }); | |
| 686 | ||
| 687 | return result as TResponse; | |
| 688 | } | |
| 689 | ||
| 690 | private writeMessage(context: CodexSessionContext, message: unknown): void { | |
| 691 | const encoded = JSON.stringify(message); | |
| 692 | if (!context.child.stdin.writable) { | |
| 693 | throw new Error("Cannot write to codex app-server stdin."); | |
| 694 | } | |
| 695 | ||
| 696 | context.child.stdin.write(`${encoded}\n`); | |
| 697 | } | |
| 698 | ||
| 699 | private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void { | |
| 700 | this.emitEvent({ | |
| 701 | id: randomUUID(), | |
| 702 | kind: "session", | |
| 703 | provider: "codex", | |
| 704 | sessionId: context.session.sessionId, | |
| 705 | createdAt: new Date().toISOString(), | |
| 706 | method, | |
| 707 | message, | |
| 708 | }); | |
| 709 | } | |
| 710 | ||
| 711 | private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void { | |
| 712 | this.emitEvent({ | |
| 713 | id: randomUUID(), | |
| 714 | kind: "error", | |
| 715 | provider: "codex", | |
| 716 | sessionId: context.session.sessionId, | |
| 717 | createdAt: new Date().toISOString(), | |
| 718 | method, | |
| 719 | message, | |
| 720 | }); | |
| 721 | } | |
| 722 | ||
| 723 | private emitEvent(event: ProviderEvent): void { | |
| 724 | this.emit("event", event); | |
| 725 | } | |
| 726 | ||
| 727 | private updateSession(context: CodexSessionContext, updates: Partial<ProviderSession>): void { | |
| 728 | context.session = { | |
| 729 | ...context.session, | |
| 730 | ...updates, | |
| 731 | updatedAt: new Date().toISOString(), | |
| 732 | }; | |
| 733 | } | |
| 734 | ||
| 735 | private requestKindForMethod( | |
| 736 | method: string, | |
| 737 | ): ProviderRequestKind | undefined { | |
| 738 | if (method === "item/commandExecution/requestApproval") { | |
| 739 | return "command"; | |
| 740 | } | |
| 741 | ||
| 742 | if (method === "item/fileChange/requestApproval") { | |
| 743 | return "file-change"; | |
| 744 | } | |
| 745 | ||
| 746 | return undefined; | |
| 747 | } | |
| 748 | ||
| 749 | private isServerRequest(value: unknown): value is JsonRpcRequest { | |
| 750 | if (!value || typeof value !== "object") { | |
| 751 | return false; | |
| 752 | } | |
| 753 | ||
| 754 | const candidate = value as Record<string, unknown>; | |
| 755 | return ( | |
| 756 | typeof candidate.method === "string" && | |
| 757 | (typeof candidate.id === "string" || typeof candidate.id === "number") | |
| 758 | ); | |
| 759 | } | |
| 760 | ||
| 761 | private isServerNotification(value: unknown): value is JsonRpcNotification { | |
| 762 | if (!value || typeof value !== "object") { | |
| 763 | return false; | |
| 764 | } | |
| 765 | ||
| 766 | const candidate = value as Record<string, unknown>; | |
| 767 | return typeof candidate.method === "string" && !("id" in candidate); | |
| 768 | } | |
| 769 | ||
| 770 | private isResponse(value: unknown): value is JsonRpcResponse { | |
| 771 | if (!value || typeof value !== "object") { | |
| 772 | return false; | |
| 773 | } | |
| 774 | ||
| 775 | const candidate = value as Record<string, unknown>; | |
| 776 | const hasId = typeof candidate.id === "string" || typeof candidate.id === "number"; | |
| 777 | const hasMethod = typeof candidate.method === "string"; | |
| 778 | return hasId && !hasMethod; | |
| 779 | } | |
| 780 | ||
| 781 | private readRouteFields(params: unknown): { | |
| 782 | threadId?: string; | |
| 783 | turnId?: string; | |
| 784 | itemId?: string; | |
| 785 | } { | |
| 786 | const route: { | |
| 787 | threadId?: string; | |
| 788 | turnId?: string; | |
| 789 | itemId?: string; | |
| 790 | } = {}; | |
| 791 | ||
| 792 | const threadId = | |
| 793 | this.readString(params, "threadId") ?? | |
| 794 | this.readString(this.readObject(params, "thread"), "id"); | |
| 795 | const turnId = | |
| 796 | this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"); | |
| 797 | const itemId = | |
| 798 | this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"); | |
| 799 | ||
| 800 | if (threadId) { | |
| 801 | route.threadId = threadId; | |
| 802 | } | |
| 803 | ||
| 804 | if (turnId) { | |
| 805 | route.turnId = turnId; | |
| 806 | } | |
| 807 | ||
| 808 | if (itemId) { | |
| 809 | route.itemId = itemId; | |
| 810 | } | |
| 811 | ||
| 812 | return route; | |
| 813 | } | |
| 814 | ||
| 815 | private readObject(value: unknown, key?: string): Record<string, unknown> | undefined { | |
| 816 | const target = | |
| 817 | key === undefined | |
| 818 | ? value | |
| 819 | : value && typeof value === "object" | |
| 820 | ? (value as Record<string, unknown>)[key] | |
| 821 | : undefined; | |
| 822 | ||
| 823 | if (!target || typeof target !== "object") { | |
| 824 | return undefined; | |
| 825 | } | |
| 826 | ||
| 827 | return target as Record<string, unknown>; | |
| 828 | } | |
| 829 | ||
| 830 | private readString(value: unknown, key: string): string | undefined { | |
| 831 | if (!value || typeof value !== "object") { | |
| 832 | return undefined; | |
| 833 | } | |
| 834 | ||
| 835 | const candidate = (value as Record<string, unknown>)[key]; | |
| 836 | return typeof candidate === "string" ? candidate : undefined; | |
| 837 | } | |
| 838 | ||
| 839 | private readBoolean(value: unknown, key: string): boolean | undefined { | |
| 840 | if (!value || typeof value !== "object") { | |
| 841 | return undefined; | |
| 842 | } | |
| 843 | ||
| 844 | const candidate = (value as Record<string, unknown>)[key]; | |
| 845 | return typeof candidate === "boolean" ? candidate : undefined; | |
| 846 | } | |
| 847 | } |
apps/server/src/fixPath.ts created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | import { execFileSync } from "node:child_process"; | |
| 2 | ||
| 3 | export function fixPath(): void { | |
| 4 | if (process.platform !== "darwin") return; | |
| 5 | ||
| 6 | try { | |
| 7 | const shell = process.env.SHELL ?? "/bin/zsh"; | |
| 8 | const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], { | |
| 9 | encoding: "utf8", | |
| 10 | timeout: 5000, | |
| 11 | }); | |
| 12 | if (result) { | |
| 13 | process.env.PATH = result; | |
| 14 | } | |
| 15 | } catch { | |
| 16 | // Silently ignore — keep default PATH | |
| 17 | } | |
| 18 | } |
apps/server/src/index.ts created+176| ... | ... | @@ -0,0 +1,176 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import net from "node:net"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | import { fileURLToPath } from "node:url"; | |
| 6 | ||
| 7 | import { fixPath } from "./fixPath"; | |
| 8 | import { createLogger } from "./logger"; | |
| 9 | import { ProjectRegistry } from "./projectRegistry"; | |
| 10 | import { createServer } from "./wsServer"; | |
| 11 | ||
| 12 | fixPath(); | |
| 13 | ||
| 14 | const DEFAULT_PORT = 3773; | |
| 15 | const cwd = process.cwd(); | |
| 16 | const logger = createLogger("server"); | |
| 17 | ||
| 18 | type RuntimeMode = "web" | "desktop"; | |
| 19 | ||
| 20 | function parseBooleanEnv(value: string | undefined): boolean | undefined { | |
| 21 | if (value === undefined) return undefined; | |
| 22 | const normalized = value.trim().toLowerCase(); | |
| 23 | if (["1", "true", "yes", "on"].includes(normalized)) return true; | |
| 24 | if (["0", "false", "no", "off"].includes(normalized)) return false; | |
| 25 | return undefined; | |
| 26 | } | |
| 27 | ||
| 28 | function parsePort(value: string | undefined): number | undefined { | |
| 29 | if (!value) return undefined; | |
| 30 | const parsed = Number(value); | |
| 31 | if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { | |
| 32 | throw new Error(`Invalid CODETHING_PORT: ${value}`); | |
| 33 | } | |
| 34 | return parsed; | |
| 35 | } | |
| 36 | ||
| 37 | function expandHomePath(input: string): string { | |
| 38 | if (input === "~") return os.homedir(); | |
| 39 | if (input.startsWith("~/")) { | |
| 40 | return path.join(os.homedir(), input.slice(2)); | |
| 41 | } | |
| 42 | return input; | |
| 43 | } | |
| 44 | ||
| 45 | function resolveStateDir(raw: string | undefined): string { | |
| 46 | if (!raw || raw.trim().length === 0) { | |
| 47 | return path.join(os.homedir(), ".t3", "userdata"); | |
| 48 | } | |
| 49 | return path.resolve(expandHomePath(raw.trim())); | |
| 50 | } | |
| 51 | ||
| 52 | async function findAvailablePort(preferred: number): Promise<number> { | |
| 53 | return new Promise((resolve, reject) => { | |
| 54 | const server = net.createServer(); | |
| 55 | server.listen(preferred, () => { | |
| 56 | server.close(() => resolve(preferred)); | |
| 57 | }); | |
| 58 | server.on("error", () => { | |
| 59 | // Preferred port busy — let the OS pick one | |
| 60 | const fallback = net.createServer(); | |
| 61 | fallback.listen(0, () => { | |
| 62 | const addr = fallback.address(); | |
| 63 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 64 | fallback.close(() => { | |
| 65 | if (port > 0) resolve(port); | |
| 66 | else reject(new Error("Could not find an available port.")); | |
| 67 | }); | |
| 68 | }); | |
| 69 | fallback.on("error", reject); | |
| 70 | }); | |
| 71 | }); | |
| 72 | } | |
| 73 | ||
| 74 | function resolveStaticDir(): string | undefined { | |
| 75 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 76 | ||
| 77 | // Check for bundled client (npm publish / npx scenario): | |
| 78 | // dist/client/ lives alongside dist/index.js | |
| 79 | const bundledClient = path.resolve(__dirname, "client"); | |
| 80 | try { | |
| 81 | const stat = fs.statSync(path.join(bundledClient, "index.html")); | |
| 82 | if (stat.isFile()) return bundledClient; | |
| 83 | } catch { | |
| 84 | // Not bundled — check monorepo layout | |
| 85 | } | |
| 86 | ||
| 87 | // Monorepo layout: apps/server/dist/index.js → apps/renderer/dist/ | |
| 88 | const monorepoClient = path.resolve(__dirname, "../../renderer/dist"); | |
| 89 | try { | |
| 90 | const stat = fs.statSync(path.join(monorepoClient, "index.html")); | |
| 91 | if (stat.isFile()) return monorepoClient; | |
| 92 | } catch { | |
| 93 | // Not found — probably dev mode, Vite will serve | |
| 94 | } | |
| 95 | ||
| 96 | return undefined; | |
| 97 | } | |
| 98 | ||
| 99 | async function main() { | |
| 100 | const mode: RuntimeMode = process.env.CODETHING_MODE === "desktop" ? "desktop" : "web"; | |
| 101 | const requestedPort = parsePort(process.env.CODETHING_PORT); | |
| 102 | const port = | |
| 103 | requestedPort ?? (mode === "desktop" ? DEFAULT_PORT : await findAvailablePort(DEFAULT_PORT)); | |
| 104 | const stateDir = resolveStateDir(process.env.CODETHING_STATE_DIR); | |
| 105 | const projectRegistry = new ProjectRegistry(stateDir); | |
| 106 | const devUrl = process.env.VITE_DEV_SERVER_URL; | |
| 107 | const noBrowser = parseBooleanEnv(process.env.CODETHING_NO_BROWSER) ?? mode === "desktop"; | |
| 108 | const authToken = process.env.CODETHING_AUTH_TOKEN; | |
| 109 | const staticDir = devUrl ? undefined : resolveStaticDir(); | |
| 110 | ||
| 111 | if (!devUrl && !staticDir) { | |
| 112 | logger.warn("renderer bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", { | |
| 113 | hint: "Run `bun run --cwd apps/renderer build` or set VITE_DEV_SERVER_URL for dev mode.", | |
| 114 | }); | |
| 115 | } | |
| 116 | ||
| 117 | const server = createServer({ | |
| 118 | port, | |
| 119 | host: mode === "desktop" ? "127.0.0.1" : undefined, | |
| 120 | cwd, | |
| 121 | staticDir, | |
| 122 | devUrl, | |
| 123 | projectRegistry, | |
| 124 | authToken, | |
| 125 | }); | |
| 126 | await server.start(); | |
| 127 | ||
| 128 | const url = `http://localhost:${port}`; | |
| 129 | logger.info("CodeThing running", { | |
| 130 | url, | |
| 131 | cwd, | |
| 132 | mode, | |
| 133 | stateDir, | |
| 134 | authEnabled: Boolean(authToken), | |
| 135 | }); | |
| 136 | ||
| 137 | // Open browser (dynamic import because `open` is ESM-only) | |
| 138 | if (!noBrowser) { | |
| 139 | try { | |
| 140 | const open = await import("open"); | |
| 141 | const target = devUrl ?? url; | |
| 142 | await open.default(target); | |
| 143 | } catch { | |
| 144 | logger.info("browser auto-open unavailable", { | |
| 145 | hint: `Open ${devUrl ?? url} in your browser.`, | |
| 146 | }); | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | // Graceful shutdown | |
| 151 | let shuttingDown = false; | |
| 152 | async function shutdown() { | |
| 153 | if (shuttingDown) return; | |
| 154 | shuttingDown = true; | |
| 155 | logger.info("shutting down"); | |
| 156 | try { | |
| 157 | await server.stop(); | |
| 158 | process.exit(0); | |
| 159 | } catch (error) { | |
| 160 | logger.error("failed to stop server cleanly", { error }); | |
| 161 | process.exit(1); | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 165 | process.on("SIGINT", () => { | |
| 166 | void shutdown(); | |
| 167 | }); | |
| 168 | process.on("SIGTERM", () => { | |
| 169 | void shutdown(); | |
| 170 | }); | |
| 171 | } | |
| 172 | ||
| 173 | main().catch((err) => { | |
| 174 | logger.error("failed to start CodeThing", { error: err }); | |
| 175 | process.exit(1); | |
| 176 | }); |
apps/server/src/logger.ts created+103| ... | ... | @@ -0,0 +1,103 @@ |
| 1 | import util from "node:util"; | |
| 2 | ||
| 3 | type LogLevel = "info" | "warn" | "error" | "event"; | |
| 4 | ||
| 5 | type LogContext = Record<string, unknown>; | |
| 6 | ||
| 7 | const ANSI = { | |
| 8 | reset: "\u001b[0m", | |
| 9 | dim: "\u001b[2m", | |
| 10 | cyan: "\u001b[36m", | |
| 11 | yellow: "\u001b[33m", | |
| 12 | red: "\u001b[31m", | |
| 13 | magenta: "\u001b[35m", | |
| 14 | } as const; | |
| 15 | ||
| 16 | const LEVEL_LABEL: Record<LogLevel, string> = { | |
| 17 | info: "INFO", | |
| 18 | warn: "WARN", | |
| 19 | error: "ERROR", | |
| 20 | event: "EVENT", | |
| 21 | }; | |
| 22 | ||
| 23 | const LEVEL_COLOR: Record<LogLevel, string> = { | |
| 24 | info: ANSI.cyan, | |
| 25 | warn: ANSI.yellow, | |
| 26 | error: ANSI.red, | |
| 27 | event: ANSI.magenta, | |
| 28 | }; | |
| 29 | ||
| 30 | function useColors() { | |
| 31 | return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined; | |
| 32 | } | |
| 33 | ||
| 34 | function colorize(value: string, color: string, enabled: boolean) { | |
| 35 | return enabled ? `${color}${value}${ANSI.reset}` : value; | |
| 36 | } | |
| 37 | ||
| 38 | function timeStamp() { | |
| 39 | return new Date().toISOString().slice(11, 23); | |
| 40 | } | |
| 41 | ||
| 42 | function formatValue(value: unknown) { | |
| 43 | if (typeof value === "string") { | |
| 44 | return JSON.stringify(value); | |
| 45 | } | |
| 46 | if ( | |
| 47 | typeof value === "number" || | |
| 48 | typeof value === "boolean" || | |
| 49 | value === null || | |
| 50 | value === undefined | |
| 51 | ) { | |
| 52 | return String(value); | |
| 53 | } | |
| 54 | return util.inspect(value, { | |
| 55 | depth: 4, | |
| 56 | breakLength: Infinity, | |
| 57 | compact: true, | |
| 58 | maxArrayLength: 25, | |
| 59 | maxStringLength: 320, | |
| 60 | }); | |
| 61 | } | |
| 62 | ||
| 63 | function formatContext(context: LogContext | undefined) { | |
| 64 | if (!context) return ""; | |
| 65 | const entries = Object.entries(context).filter(([, value]) => value !== undefined); | |
| 66 | if (entries.length === 0) return ""; | |
| 67 | return entries.map(([key, value]) => `${key}=${formatValue(value)}`).join(" "); | |
| 68 | } | |
| 69 | ||
| 70 | function write(level: LogLevel, scope: string, message: string, context?: LogContext) { | |
| 71 | const colorEnabled = useColors(); | |
| 72 | const ts = colorize(timeStamp(), ANSI.dim, colorEnabled); | |
| 73 | const levelLabel = colorize(LEVEL_LABEL[level], LEVEL_COLOR[level], colorEnabled); | |
| 74 | const contextText = formatContext(context); | |
| 75 | const line = `${ts} ${levelLabel} [${scope}] ${message}${contextText ? ` ${contextText}` : ""}`; | |
| 76 | ||
| 77 | if (level === "warn") { | |
| 78 | console.warn(line); | |
| 79 | return; | |
| 80 | } | |
| 81 | if (level === "error") { | |
| 82 | console.error(line); | |
| 83 | return; | |
| 84 | } | |
| 85 | console.log(line); | |
| 86 | } | |
| 87 | ||
| 88 | export function createLogger(scope: string) { | |
| 89 | return { | |
| 90 | info(message: string, context?: LogContext) { | |
| 91 | write("info", scope, message, context); | |
| 92 | }, | |
| 93 | warn(message: string, context?: LogContext) { | |
| 94 | write("warn", scope, message, context); | |
| 95 | }, | |
| 96 | error(message: string, context?: LogContext) { | |
| 97 | write("error", scope, message, context); | |
| 98 | }, | |
| 99 | event(message: string, context?: LogContext) { | |
| 100 | write("event", scope, message, context); | |
| 101 | }, | |
| 102 | }; | |
| 103 | } |
apps/server/src/projectRegistry.ts created+172| ... | ... | @@ -0,0 +1,172 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | type ProjectAddInput, | |
| 7 | type ProjectAddResult, | |
| 8 | type ProjectListResult, | |
| 9 | type ProjectRecord, | |
| 10 | type ProjectRemoveInput, | |
| 11 | projectAddInputSchema, | |
| 12 | projectRecordSchema, | |
| 13 | projectRemoveInputSchema, | |
| 14 | } from "@acme/contracts"; | |
| 15 | ||
| 16 | function normalizeCwd(rawCwd: string): string { | |
| 17 | const resolved = path.resolve(rawCwd.trim()); | |
| 18 | const normalized = path.normalize(resolved); | |
| 19 | if (process.platform === "win32") { | |
| 20 | return normalized.toLowerCase(); | |
| 21 | } | |
| 22 | return normalized; | |
| 23 | } | |
| 24 | ||
| 25 | function isDirectory(cwd: string): boolean { | |
| 26 | try { | |
| 27 | return fs.statSync(cwd).isDirectory(); | |
| 28 | } catch { | |
| 29 | return false; | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | function inferProjectName(cwd: string): string { | |
| 34 | const name = path.basename(cwd); | |
| 35 | return name.length > 0 ? name : "project"; | |
| 36 | } | |
| 37 | ||
| 38 | export class ProjectRegistry { | |
| 39 | private readonly stateDir: string; | |
| 40 | private readonly filePath: string; | |
| 41 | private projects: ProjectRecord[]; | |
| 42 | ||
| 43 | constructor(stateDir: string) { | |
| 44 | this.stateDir = stateDir; | |
| 45 | this.filePath = path.join(this.stateDir, "projects.json"); | |
| 46 | fs.mkdirSync(this.stateDir, { recursive: true }); | |
| 47 | this.projects = this.loadFromDisk(); | |
| 48 | } | |
| 49 | ||
| 50 | list(): ProjectListResult { | |
| 51 | return this.projects.map((project) => ({ ...project })); | |
| 52 | } | |
| 53 | ||
| 54 | add(raw: ProjectAddInput): ProjectAddResult { | |
| 55 | const input = projectAddInputSchema.parse(raw); | |
| 56 | const normalizedCwd = normalizeCwd(input.cwd); | |
| 57 | if (!isDirectory(normalizedCwd)) { | |
| 58 | throw new Error(`Project path does not exist: ${normalizedCwd}`); | |
| 59 | } | |
| 60 | ||
| 61 | const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd); | |
| 62 | if (existing) { | |
| 63 | return { project: { ...existing }, created: false }; | |
| 64 | } | |
| 65 | ||
| 66 | const now = new Date().toISOString(); | |
| 67 | const project: ProjectRecord = { | |
| 68 | id: randomUUID(), | |
| 69 | cwd: normalizedCwd, | |
| 70 | name: inferProjectName(normalizedCwd), | |
| 71 | createdAt: now, | |
| 72 | updatedAt: now, | |
| 73 | }; | |
| 74 | ||
| 75 | this.projects = [project, ...this.projects]; | |
| 76 | this.persist(); | |
| 77 | return { project: { ...project }, created: true }; | |
| 78 | } | |
| 79 | ||
| 80 | remove(raw: ProjectRemoveInput): void { | |
| 81 | const input = projectRemoveInputSchema.parse(raw); | |
| 82 | const next = this.projects.filter((project) => project.id !== input.id); | |
| 83 | if (next.length === this.projects.length) { | |
| 84 | return; | |
| 85 | } | |
| 86 | ||
| 87 | this.projects = next; | |
| 88 | this.persist(); | |
| 89 | } | |
| 90 | ||
| 91 | private loadFromDisk(): ProjectRecord[] { | |
| 92 | const loaded = this.readPersistedProjects(); | |
| 93 | const deduped = new Map<string, ProjectRecord>(); | |
| 94 | const normalizedProjects: ProjectRecord[] = []; | |
| 95 | ||
| 96 | for (const project of loaded) { | |
| 97 | const normalizedCwd = normalizeCwd(project.cwd); | |
| 98 | if (!isDirectory(normalizedCwd)) { | |
| 99 | continue; | |
| 100 | } | |
| 101 | if (deduped.has(normalizedCwd)) { | |
| 102 | continue; | |
| 103 | } | |
| 104 | ||
| 105 | const normalizedProject: ProjectRecord = { | |
| 106 | ...project, | |
| 107 | cwd: normalizedCwd, | |
| 108 | name: | |
| 109 | project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), | |
| 110 | }; | |
| 111 | deduped.set(normalizedCwd, normalizedProject); | |
| 112 | normalizedProjects.push(normalizedProject); | |
| 113 | } | |
| 114 | ||
| 115 | const changed = | |
| 116 | normalizedProjects.length !== loaded.length || | |
| 117 | normalizedProjects.some((project, index) => { | |
| 118 | const source = loaded[index]; | |
| 119 | if (!source) return true; | |
| 120 | return ( | |
| 121 | source.id !== project.id || | |
| 122 | source.cwd !== project.cwd || | |
| 123 | source.name !== project.name || | |
| 124 | source.createdAt !== project.createdAt || | |
| 125 | source.updatedAt !== project.updatedAt | |
| 126 | ); | |
| 127 | }); | |
| 128 | ||
| 129 | if (changed) { | |
| 130 | this.projects = normalizedProjects; | |
| 131 | this.persist(); | |
| 132 | } | |
| 133 | ||
| 134 | return normalizedProjects; | |
| 135 | } | |
| 136 | ||
| 137 | private readPersistedProjects(): ProjectRecord[] { | |
| 138 | try { | |
| 139 | const raw = fs.readFileSync(this.filePath, "utf8"); | |
| 140 | const parsed = JSON.parse(raw) as { projects?: unknown }; | |
| 141 | if (!parsed || !Array.isArray(parsed.projects)) { | |
| 142 | return []; | |
| 143 | } | |
| 144 | ||
| 145 | const projects: ProjectRecord[] = []; | |
| 146 | for (const candidate of parsed.projects) { | |
| 147 | const row = projectRecordSchema.safeParse(candidate); | |
| 148 | if (!row.success) { | |
| 149 | continue; | |
| 150 | } | |
| 151 | projects.push(row.data); | |
| 152 | } | |
| 153 | return projects; | |
| 154 | } catch { | |
| 155 | return []; | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | private persist(): void { | |
| 160 | const payload = JSON.stringify( | |
| 161 | { | |
| 162 | version: 1, | |
| 163 | projects: this.projects, | |
| 164 | }, | |
| 165 | null, | |
| 166 | 2, | |
| 167 | ); | |
| 168 | const tempFile = `${this.filePath}.tmp-${process.pid}-${Date.now()}`; | |
| 169 | fs.writeFileSync(tempFile, payload); | |
| 170 | fs.renameSync(tempFile, this.filePath); | |
| 171 | } | |
| 172 | } |
apps/server/src/providerManager.test.ts created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { ProviderManager } from "./providerManager"; | |
| 4 | ||
| 5 | describe("ProviderManager", () => { | |
| 6 | it("detaches provider event listener and ends thread log streams on dispose", () => { | |
| 7 | const manager = new ProviderManager(); | |
| 8 | const internals = manager as unknown as { | |
| 9 | codex: { listenerCount: (event: string) => number }; | |
| 10 | onCodexEvent: (event: { | |
| 11 | id: string; | |
| 12 | kind: "notification"; | |
| 13 | provider: "codex"; | |
| 14 | sessionId: string; | |
| 15 | createdAt: string; | |
| 16 | method: string; | |
| 17 | threadId: string; | |
| 18 | }) => void; | |
| 19 | threadLogStreams: Map< | |
| 20 | string, | |
| 21 | { writableEnded: boolean; destroyed: boolean } | |
| 22 | >; | |
| 23 | }; | |
| 24 | internals.onCodexEvent({ | |
| 25 | id: "evt-1", | |
| 26 | kind: "notification", | |
| 27 | provider: "codex", | |
| 28 | sessionId: "sess-1", | |
| 29 | createdAt: new Date().toISOString(), | |
| 30 | method: "thread/started", | |
| 31 | threadId: "thread-1", | |
| 32 | }); | |
| 33 | const stream = internals.threadLogStreams.get("thread-1"); | |
| 34 | expect(stream).toBeDefined(); | |
| 35 | if (!stream) return; | |
| 36 | ||
| 37 | expect(internals.codex.listenerCount("event")).toBe(1); | |
| 38 | manager.dispose(); | |
| 39 | ||
| 40 | expect(internals.codex.listenerCount("event")).toBe(0); | |
| 41 | expect(stream.writableEnded || stream.destroyed).toBe(true); | |
| 42 | expect(internals.threadLogStreams.size).toBe(0); | |
| 43 | }); | |
| 44 | ||
| 45 | it("allows multiple dispose calls", () => { | |
| 46 | const manager = new ProviderManager(); | |
| 47 | ||
| 48 | manager.dispose(); | |
| 49 | expect(() => manager.dispose()).not.toThrow(); | |
| 50 | }); | |
| 51 | ||
| 52 | it("rejects request responses for unknown sessions", async () => { | |
| 53 | const manager = new ProviderManager(); | |
| 54 | ||
| 55 | await expect( | |
| 56 | manager.respondToRequest({ | |
| 57 | sessionId: "missing-session", | |
| 58 | requestId: "req-1", | |
| 59 | decision: "accept", | |
| 60 | }), | |
| 61 | ).rejects.toThrow("Unknown provider session: missing-session"); | |
| 62 | ||
| 63 | manager.dispose(); | |
| 64 | }); | |
| 65 | }); |
apps/server/src/providerManager.ts created+209| ... | ... | @@ -0,0 +1,209 @@ |
| 1 | import { EventEmitter } from "node:events"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | type ProviderEvent, | |
| 7 | type ProviderInterruptTurnInput, | |
| 8 | type ProviderRespondToRequestInput, | |
| 9 | type ProviderSendTurnInput, | |
| 10 | type ProviderSession, | |
| 11 | type ProviderSessionStartInput, | |
| 12 | type ProviderStopSessionInput, | |
| 13 | type ProviderTurnStartResult, | |
| 14 | providerInterruptTurnInputSchema, | |
| 15 | providerRespondToRequestInputSchema, | |
| 16 | providerSendTurnInputSchema, | |
| 17 | providerSessionStartInputSchema, | |
| 18 | providerStopSessionInputSchema, | |
| 19 | } from "@acme/contracts"; | |
| 20 | import { CodexAppServerManager } from "./codexAppServerManager"; | |
| 21 | ||
| 22 | export interface ProviderManagerEvents { | |
| 23 | event: [event: ProviderEvent]; | |
| 24 | } | |
| 25 | ||
| 26 | export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | |
| 27 | private readonly codex = new CodexAppServerManager(); | |
| 28 | private readonly threadLogsDir: string; | |
| 29 | private readonly threadLogStreams = new Map<string, fs.WriteStream>(); | |
| 30 | private readonly sessionThreadIds = new Map<string, string>(); | |
| 31 | private readonly pendingEventsBySession = new Map<string, ProviderEvent[]>(); | |
| 32 | private disposed = false; | |
| 33 | private readonly onCodexEvent = (event: ProviderEvent) => { | |
| 34 | if (this.disposed) { | |
| 35 | return; | |
| 36 | } | |
| 37 | ||
| 38 | this.routeEventToThreadLog(event); | |
| 39 | this.emit("event", event); | |
| 40 | }; | |
| 41 | ||
| 42 | constructor() { | |
| 43 | super(); | |
| 44 | ||
| 45 | const logsDir = path.resolve(process.cwd(), ".logs"); | |
| 46 | this.threadLogsDir = path.join(logsDir, "threads"); | |
| 47 | fs.mkdirSync(this.threadLogsDir, { recursive: true }); | |
| 48 | ||
| 49 | this.codex.on("event", this.onCodexEvent); | |
| 50 | } | |
| 51 | ||
| 52 | async startSession(raw: ProviderSessionStartInput): Promise<ProviderSession> { | |
| 53 | const input = providerSessionStartInputSchema.parse(raw); | |
| 54 | if (input.provider !== "codex") { | |
| 55 | throw new Error(`Provider '${input.provider}' is not implemented yet.`); | |
| 56 | } | |
| 57 | ||
| 58 | const session = await this.codex.startSession(input); | |
| 59 | if (session.threadId) { | |
| 60 | this.sessionThreadIds.set(session.sessionId, session.threadId); | |
| 61 | this.flushPendingSessionEvents(session.sessionId, session.threadId); | |
| 62 | } | |
| 63 | return session; | |
| 64 | } | |
| 65 | ||
| 66 | async sendTurn(raw: ProviderSendTurnInput): Promise<ProviderTurnStartResult> { | |
| 67 | const input = providerSendTurnInputSchema.parse(raw); | |
| 68 | if (!this.codex.hasSession(input.sessionId)) { | |
| 69 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 70 | } | |
| 71 | ||
| 72 | return this.codex.sendTurn(input); | |
| 73 | } | |
| 74 | ||
| 75 | async interruptTurn(raw: ProviderInterruptTurnInput): Promise<void> { | |
| 76 | const input = providerInterruptTurnInputSchema.parse(raw); | |
| 77 | if (!this.codex.hasSession(input.sessionId)) { | |
| 78 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 79 | } | |
| 80 | ||
| 81 | await this.codex.interruptTurn(input.sessionId, input.turnId); | |
| 82 | } | |
| 83 | ||
| 84 | async respondToRequest(raw: ProviderRespondToRequestInput): Promise<void> { | |
| 85 | const input = providerRespondToRequestInputSchema.parse(raw); | |
| 86 | if (!this.codex.hasSession(input.sessionId)) { | |
| 87 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 88 | } | |
| 89 | ||
| 90 | await this.codex.respondToRequest( | |
| 91 | input.sessionId, | |
| 92 | input.requestId, | |
| 93 | input.decision, | |
| 94 | ); | |
| 95 | } | |
| 96 | ||
| 97 | stopSession(raw: ProviderStopSessionInput): void { | |
| 98 | const input = providerStopSessionInputSchema.parse(raw); | |
| 99 | this.codex.stopSession(input.sessionId); | |
| 100 | this.sessionThreadIds.delete(input.sessionId); | |
| 101 | this.pendingEventsBySession.delete(input.sessionId); | |
| 102 | } | |
| 103 | ||
| 104 | listSessions(): ProviderSession[] { | |
| 105 | return this.codex.listSessions(); | |
| 106 | } | |
| 107 | ||
| 108 | stopAll(): void { | |
| 109 | this.codex.stopAll(); | |
| 110 | } | |
| 111 | ||
| 112 | dispose(): void { | |
| 113 | if (this.disposed) { | |
| 114 | return; | |
| 115 | } | |
| 116 | ||
| 117 | this.disposed = true; | |
| 118 | this.codex.off("event", this.onCodexEvent); | |
| 119 | for (const stream of this.threadLogStreams.values()) { | |
| 120 | stream.end(); | |
| 121 | } | |
| 122 | this.threadLogStreams.clear(); | |
| 123 | this.sessionThreadIds.clear(); | |
| 124 | this.pendingEventsBySession.clear(); | |
| 125 | } | |
| 126 | ||
| 127 | private routeEventToThreadLog(event: ProviderEvent): void { | |
| 128 | const threadId = this.resolveThreadId(event); | |
| 129 | if (!threadId) { | |
| 130 | const pending = this.pendingEventsBySession.get(event.sessionId) ?? []; | |
| 131 | pending.push(event); | |
| 132 | this.pendingEventsBySession.set(event.sessionId, pending.slice(-100)); | |
| 133 | return; | |
| 134 | } | |
| 135 | ||
| 136 | this.flushPendingSessionEvents(event.sessionId, threadId); | |
| 137 | this.writeThreadEvent(threadId, event); | |
| 138 | } | |
| 139 | ||
| 140 | private flushPendingSessionEvents(sessionId: string, threadId: string): void { | |
| 141 | const pending = this.pendingEventsBySession.get(sessionId); | |
| 142 | if (!pending || pending.length === 0) { | |
| 143 | return; | |
| 144 | } | |
| 145 | ||
| 146 | for (const event of pending) { | |
| 147 | this.writeThreadEvent(threadId, event); | |
| 148 | } | |
| 149 | this.pendingEventsBySession.delete(sessionId); | |
| 150 | } | |
| 151 | ||
| 152 | private resolveThreadId(event: ProviderEvent): string | undefined { | |
| 153 | const fromPayload = this.readThreadIdFromPayload(event.payload); | |
| 154 | const threadId = | |
| 155 | event.threadId ?? | |
| 156 | fromPayload ?? | |
| 157 | this.sessionThreadIds.get(event.sessionId); | |
| 158 | ||
| 159 | if (threadId) { | |
| 160 | this.sessionThreadIds.set(event.sessionId, threadId); | |
| 161 | } | |
| 162 | ||
| 163 | return threadId; | |
| 164 | } | |
| 165 | ||
| 166 | private readThreadIdFromPayload(payload: unknown): string | undefined { | |
| 167 | if (!payload || typeof payload !== "object") { | |
| 168 | return undefined; | |
| 169 | } | |
| 170 | ||
| 171 | const record = payload as Record<string, unknown>; | |
| 172 | const direct = record.threadId; | |
| 173 | if (typeof direct === "string" && direct.length > 0) { | |
| 174 | return direct; | |
| 175 | } | |
| 176 | ||
| 177 | const thread = record.thread; | |
| 178 | if (!thread || typeof thread !== "object") { | |
| 179 | return undefined; | |
| 180 | } | |
| 181 | ||
| 182 | const nested = (thread as Record<string, unknown>).id; | |
| 183 | if (typeof nested === "string" && nested.length > 0) { | |
| 184 | return nested; | |
| 185 | } | |
| 186 | ||
| 187 | return undefined; | |
| 188 | } | |
| 189 | ||
| 190 | private writeThreadEvent(threadId: string, event: ProviderEvent): void { | |
| 191 | const stream = this.getOrCreateThreadLogStream(threadId); | |
| 192 | stream.write(`${JSON.stringify(event)}\n`); | |
| 193 | } | |
| 194 | ||
| 195 | private getOrCreateThreadLogStream(threadId: string): fs.WriteStream { | |
| 196 | const cached = this.threadLogStreams.get(threadId); | |
| 197 | if (cached) { | |
| 198 | return cached; | |
| 199 | } | |
| 200 | ||
| 201 | const safeThreadId = threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); | |
| 202 | const stream = fs.createWriteStream( | |
| 203 | path.join(this.threadLogsDir, `${safeThreadId}.events.ndjson`), | |
| 204 | { flags: "a" }, | |
| 205 | ); | |
| 206 | this.threadLogStreams.set(threadId, stream); | |
| 207 | return stream; | |
| 208 | } | |
| 209 | } |
apps/server/src/wsServer.test.ts created+347| ... | ... | @@ -0,0 +1,347 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { describe, expect, it, afterEach, vi } from "vitest"; | |
| 6 | import { createServer } from "./wsServer"; | |
| 7 | import WebSocket from "ws"; | |
| 8 | ||
| 9 | import { WS_CHANNELS, WS_METHODS, type WsPush, type WsResponse } from "@acme/contracts"; | |
| 10 | import { ProjectRegistry } from "./projectRegistry"; | |
| 11 | ||
| 12 | interface PendingMessages { | |
| 13 | queue: unknown[]; | |
| 14 | waiters: Array<(message: unknown) => void>; | |
| 15 | } | |
| 16 | ||
| 17 | const pendingBySocket = new WeakMap<WebSocket, PendingMessages>(); | |
| 18 | ||
| 19 | function connectWs(port: number, token?: string): Promise<WebSocket> { | |
| 20 | return new Promise((resolve, reject) => { | |
| 21 | const query = token ? `?token=${encodeURIComponent(token)}` : ""; | |
| 22 | const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`); | |
| 23 | const pending: PendingMessages = { queue: [], waiters: [] }; | |
| 24 | pendingBySocket.set(ws, pending); | |
| 25 | ||
| 26 | ws.on("message", (raw) => { | |
| 27 | const parsed = JSON.parse(String(raw)); | |
| 28 | const waiter = pending.waiters.shift(); | |
| 29 | if (waiter) { | |
| 30 | waiter(parsed); | |
| 31 | return; | |
| 32 | } | |
| 33 | pending.queue.push(parsed); | |
| 34 | }); | |
| 35 | ||
| 36 | ws.once("open", () => resolve(ws)); | |
| 37 | ws.once("error", () => reject(new Error("WebSocket connection failed"))); | |
| 38 | }); | |
| 39 | } | |
| 40 | ||
| 41 | function waitForMessage(ws: WebSocket): Promise<unknown> { | |
| 42 | const pending = pendingBySocket.get(ws); | |
| 43 | if (!pending) { | |
| 44 | return Promise.reject(new Error("WebSocket not initialized")); | |
| 45 | } | |
| 46 | ||
| 47 | const queued = pending.queue.shift(); | |
| 48 | if (queued !== undefined) { | |
| 49 | return Promise.resolve(queued); | |
| 50 | } | |
| 51 | ||
| 52 | return new Promise((resolve) => { | |
| 53 | pending.waiters.push(resolve); | |
| 54 | }); | |
| 55 | } | |
| 56 | ||
| 57 | async function sendRequest(ws: WebSocket, method: string, params?: unknown): Promise<WsResponse> { | |
| 58 | const id = crypto.randomUUID(); | |
| 59 | const message = JSON.stringify({ id, method, ...(params !== undefined ? { params } : {}) }); | |
| 60 | ws.send(message); | |
| 61 | ||
| 62 | // Wait for response with matching id | |
| 63 | while (true) { | |
| 64 | const parsed = (await waitForMessage(ws)) as Record<string, unknown>; | |
| 65 | if (parsed.id === id) { | |
| 66 | return parsed as WsResponse; | |
| 67 | } | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | describe("WebSocket Server", () => { | |
| 72 | let server: ReturnType<typeof createServer> | null = null; | |
| 73 | const connections: WebSocket[] = []; | |
| 74 | const tempDirs: string[] = []; | |
| 75 | ||
| 76 | function makeTempDir(prefix: string): string { | |
| 77 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 78 | tempDirs.push(dir); | |
| 79 | return dir; | |
| 80 | } | |
| 81 | ||
| 82 | function createTestServer( | |
| 83 | options: { | |
| 84 | cwd?: string; | |
| 85 | devUrl?: string; | |
| 86 | authToken?: string; | |
| 87 | stateDir?: string; | |
| 88 | } = {}, | |
| 89 | ): ReturnType<typeof createServer> { | |
| 90 | const stateDir = options.stateDir ?? makeTempDir("codething-ws-state-"); | |
| 91 | return createServer({ | |
| 92 | port: 0, | |
| 93 | cwd: options.cwd ?? "/test/project", | |
| 94 | ...(options.devUrl ? { devUrl: options.devUrl } : {}), | |
| 95 | ...(options.authToken ? { authToken: options.authToken } : {}), | |
| 96 | projectRegistry: new ProjectRegistry(stateDir), | |
| 97 | }); | |
| 98 | } | |
| 99 | ||
| 100 | afterEach(async () => { | |
| 101 | for (const ws of connections) { | |
| 102 | ws.close(); | |
| 103 | } | |
| 104 | connections.length = 0; | |
| 105 | if (server) { | |
| 106 | await server.stop(); | |
| 107 | } | |
| 108 | server = null; | |
| 109 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 110 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 111 | } | |
| 112 | vi.restoreAllMocks(); | |
| 113 | }); | |
| 114 | ||
| 115 | it("sends welcome message on connect", async () => { | |
| 116 | server = createTestServer({ cwd: "/test/project" }); | |
| 117 | // Get the actual port after listen | |
| 118 | await server.start(); | |
| 119 | const addr = server.httpServer.address(); | |
| 120 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 121 | expect(port).toBeGreaterThan(0); | |
| 122 | ||
| 123 | const ws = await connectWs(port); | |
| 124 | connections.push(ws); | |
| 125 | ||
| 126 | const message = (await waitForMessage(ws)) as WsPush; | |
| 127 | expect(message.type).toBe("push"); | |
| 128 | expect(message.channel).toBe(WS_CHANNELS.serverWelcome); | |
| 129 | expect(message.data).toEqual({ | |
| 130 | cwd: "/test/project", | |
| 131 | projectName: "project", | |
| 132 | }); | |
| 133 | }); | |
| 134 | ||
| 135 | it("logs outbound websocket push events in dev mode", async () => { | |
| 136 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => { | |
| 137 | // Keep test output clean while verifying websocket logs. | |
| 138 | }); | |
| 139 | ||
| 140 | server = createTestServer({ | |
| 141 | cwd: "/test/project", | |
| 142 | devUrl: "http://localhost:5173", | |
| 143 | }); | |
| 144 | await server.start(); | |
| 145 | const addr = server.httpServer.address(); | |
| 146 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 147 | expect(port).toBeGreaterThan(0); | |
| 148 | ||
| 149 | const ws = await connectWs(port); | |
| 150 | connections.push(ws); | |
| 151 | await waitForMessage(ws); | |
| 152 | ||
| 153 | expect( | |
| 154 | logSpy.mock.calls.some(([message]) => { | |
| 155 | if (typeof message !== "string") return false; | |
| 156 | return ( | |
| 157 | message.includes("[ws]") && | |
| 158 | message.includes("outgoing push") && | |
| 159 | message.includes(`channel="${WS_CHANNELS.serverWelcome}"`) | |
| 160 | ); | |
| 161 | }), | |
| 162 | ).toBe(true); | |
| 163 | }); | |
| 164 | ||
| 165 | it("responds to server.getConfig", async () => { | |
| 166 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 167 | await server.start(); | |
| 168 | const addr = server.httpServer.address(); | |
| 169 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 170 | ||
| 171 | const ws = await connectWs(port); | |
| 172 | connections.push(ws); | |
| 173 | ||
| 174 | // Consume welcome message | |
| 175 | await waitForMessage(ws); | |
| 176 | ||
| 177 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 178 | expect(response.error).toBeUndefined(); | |
| 179 | expect(response.result).toEqual({ cwd: "/my/workspace" }); | |
| 180 | }); | |
| 181 | ||
| 182 | it("returns error for unknown methods", async () => { | |
| 183 | server = createTestServer({ cwd: "/test" }); | |
| 184 | await server.start(); | |
| 185 | const addr = server.httpServer.address(); | |
| 186 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 187 | ||
| 188 | const ws = await connectWs(port); | |
| 189 | connections.push(ws); | |
| 190 | ||
| 191 | // Consume welcome | |
| 192 | await waitForMessage(ws); | |
| 193 | ||
| 194 | const response = await sendRequest(ws, "nonexistent.method"); | |
| 195 | expect(response.error).toBeDefined(); | |
| 196 | expect(response.error!.message).toContain("Unknown method"); | |
| 197 | }); | |
| 198 | ||
| 199 | it("responds to providers.listSessions with empty array", async () => { | |
| 200 | server = createTestServer({ cwd: "/test" }); | |
| 201 | await server.start(); | |
| 202 | const addr = server.httpServer.address(); | |
| 203 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 204 | ||
| 205 | const ws = await connectWs(port); | |
| 206 | connections.push(ws); | |
| 207 | ||
| 208 | // Consume welcome | |
| 209 | await waitForMessage(ws); | |
| 210 | ||
| 211 | const response = await sendRequest(ws, WS_METHODS.providersListSessions); | |
| 212 | expect(response.error).toBeUndefined(); | |
| 213 | expect(response.result).toEqual([]); | |
| 214 | }); | |
| 215 | ||
| 216 | it("handles invalid JSON gracefully", async () => { | |
| 217 | server = createTestServer({ cwd: "/test" }); | |
| 218 | await server.start(); | |
| 219 | const addr = server.httpServer.address(); | |
| 220 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 221 | ||
| 222 | const ws = await connectWs(port); | |
| 223 | connections.push(ws); | |
| 224 | ||
| 225 | // Consume welcome | |
| 226 | await waitForMessage(ws); | |
| 227 | ||
| 228 | // Send garbage | |
| 229 | ws.send("not json at all"); | |
| 230 | ||
| 231 | const response = (await waitForMessage(ws)) as WsResponse; | |
| 232 | expect(response.error).toBeDefined(); | |
| 233 | expect(response.error!.message).toContain("Invalid request format"); | |
| 234 | }); | |
| 235 | ||
| 236 | it("supports projects list/add/dedupe/remove", async () => { | |
| 237 | const stateDir = makeTempDir("codething-ws-projects-state-"); | |
| 238 | const firstProjectCwd = makeTempDir("codething-ws-project-a-"); | |
| 239 | ||
| 240 | server = createTestServer({ stateDir, cwd: "/test" }); | |
| 241 | await server.start(); | |
| 242 | const addr = server.httpServer.address(); | |
| 243 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 244 | ||
| 245 | const ws = await connectWs(port); | |
| 246 | connections.push(ws); | |
| 247 | await waitForMessage(ws); | |
| 248 | ||
| 249 | const emptyList = await sendRequest(ws, WS_METHODS.projectsList); | |
| 250 | expect(emptyList.error).toBeUndefined(); | |
| 251 | expect(emptyList.result).toEqual([]); | |
| 252 | ||
| 253 | const created = await sendRequest(ws, WS_METHODS.projectsAdd, { | |
| 254 | cwd: firstProjectCwd, | |
| 255 | }); | |
| 256 | expect(created.error).toBeUndefined(); | |
| 257 | expect((created.result as { created: boolean }).created).toBe(true); | |
| 258 | ||
| 259 | const duplicate = await sendRequest(ws, WS_METHODS.projectsAdd, { | |
| 260 | cwd: firstProjectCwd, | |
| 261 | }); | |
| 262 | expect(duplicate.error).toBeUndefined(); | |
| 263 | expect((duplicate.result as { created: boolean }).created).toBe(false); | |
| 264 | ||
| 265 | const listed = await sendRequest(ws, WS_METHODS.projectsList); | |
| 266 | expect(listed.error).toBeUndefined(); | |
| 267 | const listedProjects = listed.result as Array<{ id: string; cwd: string }>; | |
| 268 | expect(listedProjects).toHaveLength(1); | |
| 269 | ||
| 270 | const projectId = listedProjects[0]?.id; | |
| 271 | expect(projectId).toBeTruthy(); | |
| 272 | if (!projectId) return; | |
| 273 | ||
| 274 | const removed = await sendRequest(ws, WS_METHODS.projectsRemove, { | |
| 275 | id: projectId, | |
| 276 | }); | |
| 277 | expect(removed.error).toBeUndefined(); | |
| 278 | ||
| 279 | const afterRemove = await sendRequest(ws, WS_METHODS.projectsList); | |
| 280 | expect(afterRemove.error).toBeUndefined(); | |
| 281 | expect(afterRemove.result).toEqual([]); | |
| 282 | }); | |
| 283 | ||
| 284 | it("prunes missing projects on startup", async () => { | |
| 285 | const stateDir = makeTempDir("codething-ws-prune-state-"); | |
| 286 | const existing = makeTempDir("codething-ws-existing-project-"); | |
| 287 | const missing = path.join(stateDir, "definitely-missing"); | |
| 288 | const now = new Date().toISOString(); | |
| 289 | const projectsFile = path.join(stateDir, "projects.json"); | |
| 290 | ||
| 291 | fs.writeFileSync( | |
| 292 | projectsFile, | |
| 293 | JSON.stringify( | |
| 294 | { | |
| 295 | version: 1, | |
| 296 | projects: [ | |
| 297 | { | |
| 298 | id: "project-existing", | |
| 299 | cwd: existing, | |
| 300 | name: "existing", | |
| 301 | createdAt: now, | |
| 302 | updatedAt: now, | |
| 303 | }, | |
| 304 | { | |
| 305 | id: "project-missing", | |
| 306 | cwd: missing, | |
| 307 | name: "missing", | |
| 308 | createdAt: now, | |
| 309 | updatedAt: now, | |
| 310 | }, | |
| 311 | ], | |
| 312 | }, | |
| 313 | null, | |
| 314 | 2, | |
| 315 | ), | |
| 316 | ); | |
| 317 | ||
| 318 | server = createTestServer({ stateDir, cwd: "/test" }); | |
| 319 | await server.start(); | |
| 320 | const addr = server.httpServer.address(); | |
| 321 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 322 | ||
| 323 | const ws = await connectWs(port); | |
| 324 | connections.push(ws); | |
| 325 | await waitForMessage(ws); | |
| 326 | ||
| 327 | const response = await sendRequest(ws, WS_METHODS.projectsList); | |
| 328 | expect(response.error).toBeUndefined(); | |
| 329 | const listed = response.result as Array<{ id: string }>; | |
| 330 | expect(listed).toHaveLength(1); | |
| 331 | expect(listed[0]?.id).toBe("project-existing"); | |
| 332 | }); | |
| 333 | ||
| 334 | it("rejects websocket connections without a valid auth token", async () => { | |
| 335 | server = createTestServer({ cwd: "/test", authToken: "secret-token" }); | |
| 336 | await server.start(); | |
| 337 | const addr = server.httpServer.address(); | |
| 338 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 339 | ||
| 340 | await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed"); | |
| 341 | ||
| 342 | const authorizedWs = await connectWs(port, "secret-token"); | |
| 343 | connections.push(authorizedWs); | |
| 344 | const welcome = (await waitForMessage(authorizedWs)) as WsPush; | |
| 345 | expect(welcome.channel).toBe(WS_CHANNELS.serverWelcome); | |
| 346 | }); | |
| 347 | }); |
apps/server/src/wsServer.ts created+373| ... | ... | @@ -0,0 +1,373 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import http from "node:http"; | |
| 4 | import os from "node:os"; | |
| 5 | import path from "node:path"; | |
| 6 | import type { Duplex } from "node:stream"; | |
| 7 | ||
| 8 | import { | |
| 9 | EDITORS, | |
| 10 | WS_CHANNELS, | |
| 11 | WS_METHODS, | |
| 12 | type WsPush, | |
| 13 | type WsRequest, | |
| 14 | type WsResponse, | |
| 15 | wsRequestSchema, | |
| 16 | } from "@acme/contracts"; | |
| 17 | import { WebSocketServer, type WebSocket } from "ws"; | |
| 18 | ||
| 19 | import { createLogger } from "./logger"; | |
| 20 | import { ProjectRegistry } from "./projectRegistry"; | |
| 21 | import { ProviderManager } from "./providerManager"; | |
| 22 | ||
| 23 | const MIME_TYPES: Record<string, string> = { | |
| 24 | ".html": "text/html; charset=utf-8", | |
| 25 | ".js": "application/javascript; charset=utf-8", | |
| 26 | ".css": "text/css; charset=utf-8", | |
| 27 | ".json": "application/json; charset=utf-8", | |
| 28 | ".png": "image/png", | |
| 29 | ".jpg": "image/jpeg", | |
| 30 | ".svg": "image/svg+xml", | |
| 31 | ".ico": "image/x-icon", | |
| 32 | ".woff": "font/woff", | |
| 33 | ".woff2": "font/woff2", | |
| 34 | ".ttf": "font/ttf", | |
| 35 | ".map": "application/json", | |
| 36 | }; | |
| 37 | ||
| 38 | export interface ServerOptions { | |
| 39 | port: number; | |
| 40 | host?: string | undefined; | |
| 41 | cwd: string; | |
| 42 | staticDir?: string | undefined; | |
| 43 | devUrl?: string | undefined; | |
| 44 | logWebSocketEvents?: boolean | undefined; | |
| 45 | projectRegistry?: ProjectRegistry | undefined; | |
| 46 | authToken?: string | undefined; | |
| 47 | } | |
| 48 | ||
| 49 | function parseBooleanEnv(value: string | undefined): boolean | undefined { | |
| 50 | if (value === undefined) return undefined; | |
| 51 | const normalized = value.trim().toLowerCase(); | |
| 52 | if (["1", "true", "yes", "on"].includes(normalized)) return true; | |
| 53 | if (["0", "false", "no", "off"].includes(normalized)) return false; | |
| 54 | return undefined; | |
| 55 | } | |
| 56 | ||
| 57 | export function createServer(options: ServerOptions) { | |
| 58 | const { | |
| 59 | port, | |
| 60 | host, | |
| 61 | cwd, | |
| 62 | staticDir, | |
| 63 | devUrl, | |
| 64 | logWebSocketEvents: explicitLogWsEvents, | |
| 65 | projectRegistry: providedRegistry, | |
| 66 | authToken, | |
| 67 | } = options; | |
| 68 | const providerManager = new ProviderManager(); | |
| 69 | const projectRegistry = | |
| 70 | providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); | |
| 71 | const clients = new Set<WebSocket>(); | |
| 72 | const logger = createLogger("ws"); | |
| 73 | const logWebSocketEvents = | |
| 74 | explicitLogWsEvents ?? parseBooleanEnv(process.env.CODETHING_LOG_WS_EVENTS) ?? Boolean(devUrl); | |
| 75 | ||
| 76 | function logOutgoingPush(push: WsPush, recipients: number) { | |
| 77 | if (!logWebSocketEvents) return; | |
| 78 | logger.event("outgoing push", { | |
| 79 | channel: push.channel, | |
| 80 | recipients, | |
| 81 | payload: push.data, | |
| 82 | }); | |
| 83 | } | |
| 84 | ||
| 85 | // Forward provider events to all connected WebSocket clients | |
| 86 | providerManager.on("event", (event) => { | |
| 87 | const push: WsPush = { | |
| 88 | type: "push", | |
| 89 | channel: WS_CHANNELS.providerEvent, | |
| 90 | data: event, | |
| 91 | }; | |
| 92 | const message = JSON.stringify(push); | |
| 93 | let recipients = 0; | |
| 94 | for (const client of clients) { | |
| 95 | if (client.readyState === client.OPEN) { | |
| 96 | client.send(message); | |
| 97 | recipients += 1; | |
| 98 | } | |
| 99 | } | |
| 100 | logOutgoingPush(push, recipients); | |
| 101 | }); | |
| 102 | ||
| 103 | // HTTP server — serves static files or redirects to Vite dev server | |
| 104 | const httpServer = http.createServer((req, res) => { | |
| 105 | // In dev mode, redirect to Vite dev server | |
| 106 | if (devUrl) { | |
| 107 | res.writeHead(302, { Location: devUrl }); | |
| 108 | res.end(); | |
| 109 | return; | |
| 110 | } | |
| 111 | ||
| 112 | // Serve static files from the renderer build | |
| 113 | if (!staticDir) { | |
| 114 | res.writeHead(503, { "Content-Type": "text/plain" }); | |
| 115 | res.end("No static directory configured and no dev URL set."); | |
| 116 | return; | |
| 117 | } | |
| 118 | ||
| 119 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); | |
| 120 | let filePath = path.join(staticDir, url.pathname); | |
| 121 | ||
| 122 | // SPA fallback: if no file extension and not found, serve index.html | |
| 123 | const ext = path.extname(filePath); | |
| 124 | if (!ext) { | |
| 125 | filePath = path.join(filePath, "index.html"); | |
| 126 | } | |
| 127 | ||
| 128 | fs.stat(filePath, (err, stats) => { | |
| 129 | if (err || !stats?.isFile()) { | |
| 130 | // SPA fallback | |
| 131 | const indexPath = path.join(staticDir, "index.html"); | |
| 132 | fs.readFile(indexPath, (readErr, data) => { | |
| 133 | if (readErr) { | |
| 134 | res.writeHead(404, { "Content-Type": "text/plain" }); | |
| 135 | res.end("Not Found"); | |
| 136 | return; | |
| 137 | } | |
| 138 | res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); | |
| 139 | res.end(data); | |
| 140 | }); | |
| 141 | return; | |
| 142 | } | |
| 143 | ||
| 144 | const fileExt = path.extname(filePath); | |
| 145 | const contentType = MIME_TYPES[fileExt] ?? "application/octet-stream"; | |
| 146 | ||
| 147 | fs.readFile(filePath, (readErr, data) => { | |
| 148 | if (readErr) { | |
| 149 | res.writeHead(500, { "Content-Type": "text/plain" }); | |
| 150 | res.end("Internal Server Error"); | |
| 151 | return; | |
| 152 | } | |
| 153 | res.writeHead(200, { "Content-Type": contentType }); | |
| 154 | res.end(data); | |
| 155 | }); | |
| 156 | }); | |
| 157 | }); | |
| 158 | ||
| 159 | // WebSocket server — upgrades from the HTTP server | |
| 160 | const wss = new WebSocketServer({ noServer: true }); | |
| 161 | ||
| 162 | function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { | |
| 163 | socket.write( | |
| 164 | `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + | |
| 165 | "Connection: close\r\n" + | |
| 166 | "Content-Type: text/plain\r\n" + | |
| 167 | `Content-Length: ${Buffer.byteLength(message)}\r\n` + | |
| 168 | "\r\n" + | |
| 169 | message, | |
| 170 | ); | |
| 171 | socket.destroy(); | |
| 172 | } | |
| 173 | ||
| 174 | httpServer.on("upgrade", (request, socket, head) => { | |
| 175 | if (authToken) { | |
| 176 | let providedToken: string | null = null; | |
| 177 | try { | |
| 178 | const url = new URL(request.url ?? "/", `http://localhost:${port}`); | |
| 179 | providedToken = url.searchParams.get("token"); | |
| 180 | } catch { | |
| 181 | rejectUpgrade(socket, 400, "Invalid WebSocket URL"); | |
| 182 | return; | |
| 183 | } | |
| 184 | ||
| 185 | if (providedToken !== authToken) { | |
| 186 | rejectUpgrade(socket, 401, "Unauthorized WebSocket connection"); | |
| 187 | return; | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 191 | wss.handleUpgrade(request, socket, head, (ws) => { | |
| 192 | wss.emit("connection", ws, request); | |
| 193 | }); | |
| 194 | }); | |
| 195 | ||
| 196 | wss.on("connection", (ws) => { | |
| 197 | clients.add(ws); | |
| 198 | ||
| 199 | // Send welcome message with project info | |
| 200 | const segments = cwd.split(/[/\\]/).filter(Boolean); | |
| 201 | const projectName = segments[segments.length - 1] ?? "project"; | |
| 202 | ||
| 203 | const welcome: WsPush = { | |
| 204 | type: "push", | |
| 205 | channel: WS_CHANNELS.serverWelcome, | |
| 206 | data: { cwd, projectName }, | |
| 207 | }; | |
| 208 | logOutgoingPush(welcome, 1); | |
| 209 | ws.send(JSON.stringify(welcome)); | |
| 210 | ||
| 211 | ws.on("message", (raw) => { | |
| 212 | void handleMessage(ws, raw); | |
| 213 | }); | |
| 214 | ||
| 215 | ws.on("close", () => { | |
| 216 | clients.delete(ws); | |
| 217 | }); | |
| 218 | ||
| 219 | ws.on("error", () => { | |
| 220 | clients.delete(ws); | |
| 221 | }); | |
| 222 | }); | |
| 223 | ||
| 224 | async function handleMessage(ws: WebSocket, raw: unknown) { | |
| 225 | let request: WsRequest; | |
| 226 | try { | |
| 227 | const parsed = JSON.parse(String(raw)); | |
| 228 | request = wsRequestSchema.parse(parsed); | |
| 229 | } catch { | |
| 230 | const errorResponse: WsResponse = { | |
| 231 | id: "unknown", | |
| 232 | error: { message: "Invalid request format" }, | |
| 233 | }; | |
| 234 | ws.send(JSON.stringify(errorResponse)); | |
| 235 | return; | |
| 236 | } | |
| 237 | ||
| 238 | try { | |
| 239 | const result = await routeRequest(request); | |
| 240 | const response: WsResponse = { id: request.id, result }; | |
| 241 | ws.send(JSON.stringify(response)); | |
| 242 | } catch (err) { | |
| 243 | const message = err instanceof Error ? err.message : "Unknown server error"; | |
| 244 | const response: WsResponse = { | |
| 245 | id: request.id, | |
| 246 | error: { message }, | |
| 247 | }; | |
| 248 | ws.send(JSON.stringify(response)); | |
| 249 | } | |
| 250 | } | |
| 251 | ||
| 252 | async function routeRequest(request: WsRequest): Promise<unknown> { | |
| 253 | switch (request.method) { | |
| 254 | case WS_METHODS.providersStartSession: | |
| 255 | return providerManager.startSession(request.params as never); | |
| 256 | ||
| 257 | case WS_METHODS.providersSendTurn: | |
| 258 | return providerManager.sendTurn(request.params as never); | |
| 259 | ||
| 260 | case WS_METHODS.providersInterruptTurn: | |
| 261 | return providerManager.interruptTurn(request.params as never); | |
| 262 | ||
| 263 | case WS_METHODS.providersRespondToRequest: | |
| 264 | return providerManager.respondToRequest(request.params as never); | |
| 265 | ||
| 266 | case WS_METHODS.providersStopSession: { | |
| 267 | providerManager.stopSession(request.params as never); | |
| 268 | return undefined; | |
| 269 | } | |
| 270 | ||
| 271 | case WS_METHODS.providersListSessions: | |
| 272 | return providerManager.listSessions(); | |
| 273 | ||
| 274 | case WS_METHODS.projectsList: | |
| 275 | return projectRegistry.list(); | |
| 276 | ||
| 277 | case WS_METHODS.projectsAdd: | |
| 278 | return projectRegistry.add(request.params as never); | |
| 279 | ||
| 280 | case WS_METHODS.projectsRemove: | |
| 281 | projectRegistry.remove(request.params as never); | |
| 282 | return undefined; | |
| 283 | ||
| 284 | case WS_METHODS.shellOpenInEditor: { | |
| 285 | const params = request.params as { | |
| 286 | cwd: string; | |
| 287 | editor: string; | |
| 288 | }; | |
| 289 | if (!params?.cwd) throw new Error("cwd is required"); | |
| 290 | const editorDef = EDITORS.find((e) => e.id === params.editor); | |
| 291 | if (!editorDef) throw new Error(`Unknown editor: ${params.editor}`); | |
| 292 | if (!editorDef.command) { | |
| 293 | // Can't open file manager programmatically from server, just succeed silently | |
| 294 | return undefined; | |
| 295 | } | |
| 296 | const child = spawn(editorDef.command, [params.cwd], { | |
| 297 | detached: true, | |
| 298 | stdio: "ignore", | |
| 299 | }); | |
| 300 | child.on("error", () => { | |
| 301 | /* ignore spawn failures for detached editors */ | |
| 302 | }); | |
| 303 | child.unref(); | |
| 304 | return undefined; | |
| 305 | } | |
| 306 | ||
| 307 | case WS_METHODS.serverGetConfig: | |
| 308 | return { cwd }; | |
| 309 | ||
| 310 | default: | |
| 311 | throw new Error(`Unknown method: ${request.method}`); | |
| 312 | } | |
| 313 | } | |
| 314 | ||
| 315 | function start() { | |
| 316 | return new Promise<void>((resolve, reject) => { | |
| 317 | const onError = (error: Error) => { | |
| 318 | httpServer.off("error", onError); | |
| 319 | reject(error); | |
| 320 | }; | |
| 321 | httpServer.once("error", onError); | |
| 322 | const onListening = () => { | |
| 323 | httpServer.off("error", onError); | |
| 324 | resolve(); | |
| 325 | }; | |
| 326 | if (host) { | |
| 327 | httpServer.listen(port, host, onListening); | |
| 328 | return; | |
| 329 | } | |
| 330 | httpServer.listen(port, onListening); | |
| 331 | }); | |
| 332 | } | |
| 333 | ||
| 334 | async function stop(): Promise<void> { | |
| 335 | providerManager.stopAll(); | |
| 336 | providerManager.dispose(); | |
| 337 | ||
| 338 | for (const client of clients) { | |
| 339 | client.close(); | |
| 340 | } | |
| 341 | clients.clear(); | |
| 342 | ||
| 343 | const isServerNotRunningError = (error: unknown): boolean => { | |
| 344 | if (!(error instanceof Error)) return false; | |
| 345 | const maybeCode = (error as NodeJS.ErrnoException).code; | |
| 346 | return maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running"); | |
| 347 | }; | |
| 348 | ||
| 349 | const closeWebSocketServer = new Promise<void>((resolve, reject) => { | |
| 350 | wss.close((error) => { | |
| 351 | if (error && !isServerNotRunningError(error)) { | |
| 352 | reject(error); | |
| 353 | return; | |
| 354 | } | |
| 355 | resolve(); | |
| 356 | }); | |
| 357 | }); | |
| 358 | ||
| 359 | const closeHttpServer = new Promise<void>((resolve, reject) => { | |
| 360 | httpServer.close((error) => { | |
| 361 | if (error && !isServerNotRunningError(error)) { | |
| 362 | reject(error); | |
| 363 | return; | |
| 364 | } | |
| 365 | resolve(); | |
| 366 | }); | |
| 367 | }); | |
| 368 | ||
| 369 | await Promise.all([closeWebSocketServer, closeHttpServer]); | |
| 370 | } | |
| 371 | ||
| 372 | return { start, stop, httpServer }; | |
| 373 | } |
apps/server/tsconfig.json created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | { | |
| 2 | "extends": "../../tsconfig.base.json", | |
| 3 | "compilerOptions": { | |
| 4 | "composite": true, | |
| 5 | "types": ["node"], | |
| 6 | "lib": ["ES2023"] | |
| 7 | }, | |
| 8 | "include": ["src", "tsup.config.ts"] | |
| 9 | } |
apps/server/tsup.config.ts created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | import { defineConfig } from "tsup"; | |
| 2 | ||
| 3 | export default defineConfig({ | |
| 4 | entry: ["src/index.ts"], | |
| 5 | format: "esm", | |
| 6 | outDir: "dist", | |
| 7 | sourcemap: true, | |
| 8 | clean: true, | |
| 9 | noExternal: ["@acme/contracts"], | |
| 10 | banner: { | |
| 11 | js: '#!/usr/bin/env node\n', | |
| 12 | }, | |
| 13 | }); |
bun.lock+56-232| ... | ... | @@ -16,17 +16,15 @@ |
| 16 | 16 | "name": "@acme/desktop", |
| 17 | 17 | "version": "0.0.0", |
| 18 | 18 | "dependencies": { |
| 19 | "@acme/contracts": "workspace:*", | |
| 20 | 19 | "electron": "33.4.11", |
| 21 | "node-pty": "^1.0.0", | |
| 22 | 20 | }, |
| 23 | 21 | "devDependencies": { |
| 24 | "@electron/rebuild": "^3.7.0", | |
| 25 | 22 | "@types/node": "^22.10.2", |
| 26 | 23 | "concurrently": "^9.1.2", |
| 27 | 24 | "electronmon": "^2.0.2", |
| 28 | 25 | "tsup": "^8.3.5", |
| 29 | 26 | "typescript": "^5.7.3", |
| 27 | "vitest": "^3.0.0", | |
| 30 | 28 | "wait-on": "^8.0.2", |
| 31 | 29 | }, |
| 32 | 30 | }, |
| ... | ... | @@ -55,6 +53,26 @@ |
| 55 | 53 | "vite": "^6.0.5", |
| 56 | 54 | }, |
| 57 | 55 | }, |
| 56 | "apps/server": { | |
| 57 | "name": "t3", | |
| 58 | "version": "0.1.0", | |
| 59 | "bin": { | |
| 60 | "t3": "./dist/index.js", | |
| 61 | }, | |
| 62 | "dependencies": { | |
| 63 | "open": "^10.1.0", | |
| 64 | "ws": "^8.18.0", | |
| 65 | }, | |
| 66 | "devDependencies": { | |
| 67 | "@acme/contracts": "workspace:*", | |
| 68 | "@acme/renderer": "workspace:*", | |
| 69 | "@types/node": "^22.10.2", | |
| 70 | "@types/ws": "^8.5.13", | |
| 71 | "tsup": "^8.3.5", | |
| 72 | "tsx": "^4.19.0", | |
| 73 | "typescript": "^5.7.3", | |
| 74 | }, | |
| 75 | }, | |
| 58 | 76 | "packages/contracts": { |
| 59 | 77 | "name": "@acme/contracts", |
| 60 | 78 | "version": "0.0.0", |
| ... | ... | @@ -114,10 +132,6 @@ |
| 114 | 132 | |
| 115 | 133 | "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], |
| 116 | 134 | |
| 117 | "@electron/node-gyp": ["@electron/node-gyp@github:electron/node-gyp#06b29aa", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": "./bin/node-gyp.js" }, "electron-node-gyp-06b29aa"], | |
| 118 | ||
| 119 | "@electron/rebuild": ["@electron/rebuild@3.7.2", "", { "dependencies": { "@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "fs-extra": "^10.0.0", "got": "^11.7.0", "node-abi": "^3.45.0", "node-api-version": "^0.2.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", "tar": "^6.0.5", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-19/KbIR/DAxbsCkiaGMXIdPnMCJLkcf8AvGnduJtWBs/CBwiAjY1apCqOLVxrXg+rtXFCngbXhBanWjxLUt1Mg=="], | |
| 120 | ||
| 121 | 135 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], |
| 122 | 136 | |
| 123 | 137 | "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], |
| ... | ... | @@ -170,8 +184,6 @@ |
| 170 | 184 | |
| 171 | 185 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], |
| 172 | 186 | |
| 173 | "@gar/promisify": ["@gar/promisify@1.1.3", "", {}, "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw=="], | |
| 174 | ||
| 175 | 187 | "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], |
| 176 | 188 | |
| 177 | 189 | "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="], |
| ... | ... | @@ -194,12 +206,6 @@ |
| 194 | 206 | |
| 195 | 207 | "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], |
| 196 | 208 | |
| 197 | "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], | |
| 198 | ||
| 199 | "@npmcli/fs": ["@npmcli/fs@2.1.2", "", { "dependencies": { "@gar/promisify": "^1.1.3", "semver": "^7.3.5" } }, "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ=="], | |
| 200 | ||
| 201 | "@npmcli/move-file": ["@npmcli/move-file@2.0.1", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ=="], | |
| 202 | ||
| 203 | 209 | "@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jmUfF7cNJPw57bEK7sMIqrYRgn4LH428tSgtgLTCtjuGuu1ShREyrkeB7y8HtkXRfhBs4lVY+HMLhqElJvZ6ww=="], |
| 204 | 210 | |
| 205 | 211 | "@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-S6vlV8S7jbjzJOSjfVg2CimUC0r7/aHDLdUm/3+/B/SU/s1jV7ivqWkMv1/8EB43d1BBwT9JQ60ZMTkBqeXSFA=="], |
| ... | ... | @@ -320,8 +326,6 @@ |
| 320 | 326 | |
| 321 | 327 | "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], |
| 322 | 328 | |
| 323 | "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="], | |
| 324 | ||
| 325 | 329 | "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], |
| 326 | 330 | |
| 327 | 331 | "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], |
| ... | ... | @@ -362,6 +366,8 @@ |
| 362 | 366 | |
| 363 | 367 | "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], |
| 364 | 368 | |
| 369 | "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], | |
| 370 | ||
| 365 | 371 | "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], |
| 366 | 372 | |
| 367 | 373 | "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], |
| ... | ... | @@ -382,16 +388,8 @@ |
| 382 | 388 | |
| 383 | 389 | "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], |
| 384 | 390 | |
| 385 | "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], | |
| 386 | ||
| 387 | 391 | "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], |
| 388 | 392 | |
| 389 | "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], | |
| 390 | ||
| 391 | "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], | |
| 392 | ||
| 393 | "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], | |
| 394 | ||
| 395 | 393 | "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], |
| 396 | 394 | |
| 397 | 395 | "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], |
| ... | ... | @@ -402,38 +400,28 @@ |
| 402 | 400 | |
| 403 | 401 | "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], |
| 404 | 402 | |
| 405 | "axios": ["axios@1.13.4", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="], | |
| 403 | "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], | |
| 406 | 404 | |
| 407 | 405 | "babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.0.0-beta-ebf51a3-20250411", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-q84bNR9JG1crykAlJUt5Ud0/5BUyMFuQww/mrwIQDFBaxsikqBDj3f/FNDsVd2iR26A1HvXKWPEIfgJDv8/V2g=="], |
| 408 | 406 | |
| 409 | 407 | "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], |
| 410 | 408 | |
| 411 | "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], | |
| 412 | ||
| 413 | "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], | |
| 414 | ||
| 415 | 409 | "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], |
| 416 | 410 | |
| 417 | "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], | |
| 418 | ||
| 419 | 411 | "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], |
| 420 | 412 | |
| 421 | "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], | |
| 422 | ||
| 423 | 413 | "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], |
| 424 | 414 | |
| 425 | 415 | "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], |
| 426 | 416 | |
| 427 | "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], | |
| 428 | ||
| 429 | 417 | "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], |
| 430 | 418 | |
| 419 | "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], | |
| 420 | ||
| 431 | 421 | "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], |
| 432 | 422 | |
| 433 | 423 | "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], |
| 434 | 424 | |
| 435 | "cacache": ["cacache@16.1.3", "", { "dependencies": { "@npmcli/fs": "^2.1.0", "@npmcli/move-file": "^2.0.0", "chownr": "^2.0.0", "fs-minipass": "^2.1.0", "glob": "^8.0.1", "infer-owner": "^1.0.4", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "mkdirp": "^1.0.4", "p-map": "^4.0.0", "promise-inflight": "^1.0.1", "rimraf": "^3.0.2", "ssri": "^9.0.0", "tar": "^6.1.11", "unique-filename": "^2.0.0" } }, "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ=="], | |
| 436 | ||
| 437 | 425 | "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], |
| 438 | 426 | |
| 439 | 427 | "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], |
| ... | ... | @@ -460,18 +448,8 @@ |
| 460 | 448 | |
| 461 | 449 | "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], |
| 462 | 450 | |
| 463 | "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], | |
| 464 | ||
| 465 | "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], | |
| 466 | ||
| 467 | "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], | |
| 468 | ||
| 469 | "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], | |
| 470 | ||
| 471 | 451 | "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], |
| 472 | 452 | |
| 473 | "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], | |
| 474 | ||
| 475 | 453 | "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], |
| 476 | 454 | |
| 477 | 455 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], |
| ... | ... | @@ -484,8 +462,6 @@ |
| 484 | 462 | |
| 485 | 463 | "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], |
| 486 | 464 | |
| 487 | "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], | |
| 488 | ||
| 489 | 465 | "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], |
| 490 | 466 | |
| 491 | 467 | "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], |
| ... | ... | @@ -494,8 +470,6 @@ |
| 494 | 470 | |
| 495 | 471 | "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], |
| 496 | 472 | |
| 497 | "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], | |
| 498 | ||
| 499 | 473 | "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], |
| 500 | 474 | |
| 501 | 475 | "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], |
| ... | ... | @@ -506,12 +480,16 @@ |
| 506 | 480 | |
| 507 | 481 | "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], |
| 508 | 482 | |
| 509 | "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], | |
| 483 | "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], | |
| 484 | ||
| 485 | "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], | |
| 510 | 486 | |
| 511 | 487 | "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], |
| 512 | 488 | |
| 513 | 489 | "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], |
| 514 | 490 | |
| 491 | "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], | |
| 492 | ||
| 515 | 493 | "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], |
| 516 | 494 | |
| 517 | 495 | "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], |
| ... | ... | @@ -534,16 +512,12 @@ |
| 534 | 512 | |
| 535 | 513 | "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], |
| 536 | 514 | |
| 537 | "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], | |
| 538 | ||
| 539 | 515 | "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], |
| 540 | 516 | |
| 541 | 517 | "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], |
| 542 | 518 | |
| 543 | 519 | "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], |
| 544 | 520 | |
| 545 | "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], | |
| 546 | ||
| 547 | 521 | "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], |
| 548 | 522 | |
| 549 | 523 | "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], |
| ... | ... | @@ -568,8 +542,6 @@ |
| 568 | 542 | |
| 569 | 543 | "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], |
| 570 | 544 | |
| 571 | "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], | |
| 572 | ||
| 573 | 545 | "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], |
| 574 | 546 | |
| 575 | 547 | "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], |
| ... | ... | @@ -586,11 +558,7 @@ |
| 586 | 558 | |
| 587 | 559 | "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], |
| 588 | 560 | |
| 589 | "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], | |
| 590 | ||
| 591 | "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], | |
| 592 | ||
| 593 | "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], | |
| 561 | "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], | |
| 594 | 562 | |
| 595 | 563 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], |
| 596 | 564 | |
| ... | ... | @@ -606,7 +574,7 @@ |
| 606 | 574 | |
| 607 | 575 | "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], |
| 608 | 576 | |
| 609 | "glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], | |
| 577 | "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], | |
| 610 | 578 | |
| 611 | 579 | "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], |
| 612 | 580 | |
| ... | ... | @@ -642,55 +610,31 @@ |
| 642 | 610 | |
| 643 | 611 | "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], |
| 644 | 612 | |
| 645 | "http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], | |
| 646 | ||
| 647 | 613 | "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], |
| 648 | 614 | |
| 649 | "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], | |
| 650 | ||
| 651 | "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], | |
| 652 | ||
| 653 | "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], | |
| 654 | ||
| 655 | "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], | |
| 656 | ||
| 657 | 615 | "import-from": ["import-from@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ=="], |
| 658 | 616 | |
| 659 | "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], | |
| 660 | ||
| 661 | "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], | |
| 662 | ||
| 663 | "infer-owner": ["infer-owner@1.0.4", "", {}, "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A=="], | |
| 664 | ||
| 665 | "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], | |
| 666 | ||
| 667 | "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], | |
| 668 | ||
| 669 | 617 | "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], |
| 670 | 618 | |
| 671 | "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], | |
| 672 | ||
| 673 | 619 | "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], |
| 674 | 620 | |
| 675 | 621 | "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], |
| 676 | 622 | |
| 677 | 623 | "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], |
| 678 | 624 | |
| 625 | "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], | |
| 626 | ||
| 679 | 627 | "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], |
| 680 | 628 | |
| 681 | 629 | "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], |
| 682 | 630 | |
| 683 | "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], | |
| 684 | ||
| 685 | "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], | |
| 631 | "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], | |
| 686 | 632 | |
| 687 | 633 | "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], |
| 688 | 634 | |
| 689 | 635 | "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], |
| 690 | 636 | |
| 691 | "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], | |
| 692 | ||
| 693 | "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], | |
| 637 | "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], | |
| 694 | 638 | |
| 695 | 639 | "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], |
| 696 | 640 | |
| ... | ... | @@ -708,7 +652,7 @@ |
| 708 | 652 | |
| 709 | 653 | "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], |
| 710 | 654 | |
| 711 | "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], | |
| 655 | "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], | |
| 712 | 656 | |
| 713 | 657 | "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], |
| 714 | 658 | |
| ... | ... | @@ -746,8 +690,6 @@ |
| 746 | 690 | |
| 747 | 691 | "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="], |
| 748 | 692 | |
| 749 | "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], | |
| 750 | ||
| 751 | 693 | "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], |
| 752 | 694 | |
| 753 | 695 | "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], |
| ... | ... | @@ -756,14 +698,12 @@ |
| 756 | 698 | |
| 757 | 699 | "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], |
| 758 | 700 | |
| 759 | "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], | |
| 701 | "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], | |
| 760 | 702 | |
| 761 | 703 | "lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="], |
| 762 | 704 | |
| 763 | 705 | "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], |
| 764 | 706 | |
| 765 | "make-fetch-happen": ["make-fetch-happen@10.2.1", "", { "dependencies": { "agentkeepalive": "^4.2.1", "cacache": "^16.1.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-fetch": "^2.0.3", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "promise-retry": "^2.0.1", "socks-proxy-agent": "^7.0.0", "ssri": "^9.0.0" } }, "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w=="], | |
| 766 | ||
| 767 | 707 | "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], |
| 768 | 708 | |
| 769 | 709 | "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], |
| ... | ... | @@ -862,30 +802,10 @@ |
| 862 | 802 | |
| 863 | 803 | "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], |
| 864 | 804 | |
| 865 | "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], | |
| 866 | ||
| 867 | 805 | "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], |
| 868 | 806 | |
| 869 | "minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], | |
| 870 | ||
| 871 | 807 | "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], |
| 872 | 808 | |
| 873 | "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], | |
| 874 | ||
| 875 | "minipass-collect": ["minipass-collect@1.0.2", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA=="], | |
| 876 | ||
| 877 | "minipass-fetch": ["minipass-fetch@2.1.2", "", { "dependencies": { "minipass": "^3.1.6", "minipass-sized": "^1.0.3", "minizlib": "^2.1.2" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA=="], | |
| 878 | ||
| 879 | "minipass-flush": ["minipass-flush@1.0.5", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw=="], | |
| 880 | ||
| 881 | "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], | |
| 882 | ||
| 883 | "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], | |
| 884 | ||
| 885 | "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], | |
| 886 | ||
| 887 | "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], | |
| 888 | ||
| 889 | 809 | "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], |
| 890 | 810 | |
| 891 | 811 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], |
| ... | ... | @@ -894,20 +814,8 @@ |
| 894 | 814 | |
| 895 | 815 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], |
| 896 | 816 | |
| 897 | "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], | |
| 898 | ||
| 899 | "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], | |
| 900 | ||
| 901 | "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], | |
| 902 | ||
| 903 | "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], | |
| 904 | ||
| 905 | "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], | |
| 906 | ||
| 907 | 817 | "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], |
| 908 | 818 | |
| 909 | "nopt": ["nopt@6.0.0", "", { "dependencies": { "abbrev": "^1.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g=="], | |
| 910 | ||
| 911 | 819 | "normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="], |
| 912 | 820 | |
| 913 | 821 | "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], |
| ... | ... | @@ -918,9 +826,7 @@ |
| 918 | 826 | |
| 919 | 827 | "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], |
| 920 | 828 | |
| 921 | "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], | |
| 922 | ||
| 923 | "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], | |
| 829 | "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], | |
| 924 | 830 | |
| 925 | 831 | "oxfmt": ["oxfmt@0.28.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.28.0", "@oxfmt/darwin-x64": "0.28.0", "@oxfmt/linux-arm64-gnu": "0.28.0", "@oxfmt/linux-arm64-musl": "0.28.0", "@oxfmt/linux-x64-gnu": "0.28.0", "@oxfmt/linux-x64-musl": "0.28.0", "@oxfmt/win32-arm64": "0.28.0", "@oxfmt/win32-x64": "0.28.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-3+hhBqPE6Kp22KfJmnstrZbl+KdOVSEu1V0ABaFIg1rYLtrMgrupx9znnHgHLqKxAVHebjTdiCJDk30CXOt6cw=="], |
| 926 | 832 | |
| ... | ... | @@ -928,14 +834,8 @@ |
| 928 | 834 | |
| 929 | 835 | "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], |
| 930 | 836 | |
| 931 | "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], | |
| 932 | ||
| 933 | 837 | "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], |
| 934 | 838 | |
| 935 | "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], | |
| 936 | ||
| 937 | "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], | |
| 938 | ||
| 939 | 839 | "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], |
| 940 | 840 | |
| 941 | 841 | "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], |
| ... | ... | @@ -956,14 +856,8 @@ |
| 956 | 856 | |
| 957 | 857 | "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], |
| 958 | 858 | |
| 959 | "proc-log": ["proc-log@2.0.1", "", {}, "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw=="], | |
| 960 | ||
| 961 | 859 | "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], |
| 962 | 860 | |
| 963 | "promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="], | |
| 964 | ||
| 965 | "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], | |
| 966 | ||
| 967 | 861 | "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], |
| 968 | 862 | |
| 969 | 863 | "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], |
| ... | ... | @@ -980,10 +874,6 @@ |
| 980 | 874 | |
| 981 | 875 | "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], |
| 982 | 876 | |
| 983 | "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], | |
| 984 | ||
| 985 | "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], | |
| 986 | ||
| 987 | 877 | "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], |
| 988 | 878 | |
| 989 | 879 | "rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="], |
| ... | ... | @@ -1004,50 +894,32 @@ |
| 1004 | 894 | |
| 1005 | 895 | "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], |
| 1006 | 896 | |
| 1007 | "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], | |
| 1008 | ||
| 1009 | "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], | |
| 1010 | ||
| 1011 | "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], | |
| 897 | "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], | |
| 1012 | 898 | |
| 1013 | "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], | |
| 899 | "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], | |
| 1014 | 900 | |
| 1015 | 901 | "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], |
| 1016 | 902 | |
| 1017 | 903 | "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], |
| 1018 | 904 | |
| 905 | "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], | |
| 906 | ||
| 1019 | 907 | "runtime-required": ["runtime-required@1.1.0", "", {}, "sha512-yX97f5E0WfNpcQnfVjap6vzQcvErkYYCx6eTK4siqGEdC8lglwypUFgZVTX7ShvIlgfkC4XGFl9O1KTYcff0pw=="], |
| 1020 | 908 | |
| 1021 | 909 | "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], |
| 1022 | 910 | |
| 1023 | "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], | |
| 1024 | ||
| 1025 | "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], | |
| 1026 | ||
| 1027 | 911 | "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], |
| 1028 | 912 | |
| 1029 | "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], | |
| 913 | "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], | |
| 1030 | 914 | |
| 1031 | 915 | "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], |
| 1032 | 916 | |
| 1033 | 917 | "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], |
| 1034 | 918 | |
| 1035 | "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], | |
| 1036 | ||
| 1037 | "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], | |
| 1038 | ||
| 1039 | 919 | "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], |
| 1040 | 920 | |
| 1041 | 921 | "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], |
| 1042 | 922 | |
| 1043 | "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], | |
| 1044 | ||
| 1045 | "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], | |
| 1046 | ||
| 1047 | "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], | |
| 1048 | ||
| 1049 | "socks-proxy-agent": ["socks-proxy-agent@7.0.0", "", { "dependencies": { "agent-base": "^6.0.2", "debug": "^4.3.3", "socks": "^2.6.2" } }, "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww=="], | |
| 1050 | ||
| 1051 | 923 | "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], |
| 1052 | 924 | |
| 1053 | 925 | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], |
| ... | ... | @@ -1056,16 +928,12 @@ |
| 1056 | 928 | |
| 1057 | 929 | "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], |
| 1058 | 930 | |
| 1059 | "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], | |
| 1060 | ||
| 1061 | 931 | "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], |
| 1062 | 932 | |
| 1063 | 933 | "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], |
| 1064 | 934 | |
| 1065 | 935 | "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], |
| 1066 | 936 | |
| 1067 | "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], | |
| 1068 | ||
| 1069 | 937 | "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], |
| 1070 | 938 | |
| 1071 | 939 | "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], |
| ... | ... | @@ -1082,12 +950,12 @@ |
| 1082 | 950 | |
| 1083 | 951 | "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], |
| 1084 | 952 | |
| 953 | "t3": ["t3@workspace:apps/server"], | |
| 954 | ||
| 1085 | 955 | "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], |
| 1086 | 956 | |
| 1087 | 957 | "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], |
| 1088 | 958 | |
| 1089 | "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], | |
| 1090 | ||
| 1091 | 959 | "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], |
| 1092 | 960 | |
| 1093 | 961 | "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], |
| ... | ... | @@ -1118,6 +986,8 @@ |
| 1118 | 986 | |
| 1119 | 987 | "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="], |
| 1120 | 988 | |
| 989 | "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], | |
| 990 | ||
| 1121 | 991 | "turbo": ["turbo@2.8.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.3", "turbo-darwin-arm64": "2.8.3", "turbo-linux-64": "2.8.3", "turbo-linux-arm64": "2.8.3", "turbo-windows-64": "2.8.3", "turbo-windows-arm64": "2.8.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ=="], |
| 1122 | 992 | |
| 1123 | 993 | "turbo-darwin-64": ["turbo-darwin-64@2.8.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw=="], |
| ... | ... | @@ -1142,10 +1012,6 @@ |
| 1142 | 1012 | |
| 1143 | 1013 | "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], |
| 1144 | 1014 | |
| 1145 | "unique-filename": ["unique-filename@2.0.1", "", { "dependencies": { "unique-slug": "^3.0.0" } }, "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A=="], | |
| 1146 | ||
| 1147 | "unique-slug": ["unique-slug@3.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w=="], | |
| 1148 | ||
| 1149 | 1015 | "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], |
| 1150 | 1016 | |
| 1151 | 1017 | "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], |
| ... | ... | @@ -1158,14 +1024,12 @@ |
| 1158 | 1024 | |
| 1159 | 1025 | "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], |
| 1160 | 1026 | |
| 1161 | "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], | |
| 1027 | "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], | |
| 1162 | 1028 | |
| 1163 | 1029 | "unixify": ["unixify@1.0.0", "", { "dependencies": { "normalize-path": "^2.1.1" } }, "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg=="], |
| 1164 | 1030 | |
| 1165 | 1031 | "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], |
| 1166 | 1032 | |
| 1167 | "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], | |
| 1168 | ||
| 1169 | 1033 | "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], |
| 1170 | 1034 | |
| 1171 | 1035 | "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], |
| ... | ... | @@ -1180,19 +1044,19 @@ |
| 1180 | 1044 | |
| 1181 | 1045 | "watchboy": ["watchboy@0.4.3", "", { "dependencies": { "lodash.difference": "^4.5.0", "micromatch": "^4.0.2", "pify": "^4.0.1", "unixify": "^1.0.0" } }, "sha512-GHs1HxwvxSMBsqd/WfTOZhj5gBdMqf5HQpfgtKxDfZRxrlYPDdVLRB61LCeRzJaWANmvSIMlfmRVDwVmJFgAKA=="], |
| 1182 | 1046 | |
| 1183 | "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], | |
| 1184 | ||
| 1185 | "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], | |
| 1186 | ||
| 1187 | 1047 | "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], |
| 1188 | 1048 | |
| 1189 | 1049 | "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], |
| 1190 | 1050 | |
| 1191 | 1051 | "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], |
| 1192 | 1052 | |
| 1053 | "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], | |
| 1054 | ||
| 1055 | "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], | |
| 1056 | ||
| 1193 | 1057 | "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], |
| 1194 | 1058 | |
| 1195 | "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], | |
| 1059 | "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], | |
| 1196 | 1060 | |
| 1197 | 1061 | "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], |
| 1198 | 1062 | |
| ... | ... | @@ -1206,16 +1070,6 @@ |
| 1206 | 1070 | |
| 1207 | 1071 | "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], |
| 1208 | 1072 | |
| 1209 | "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], | |
| 1210 | ||
| 1211 | "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], | |
| 1212 | ||
| 1213 | "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], | |
| 1214 | ||
| 1215 | "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], | |
| 1216 | ||
| 1217 | "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], | |
| 1218 | ||
| 1219 | 1073 | "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], |
| 1220 | 1074 | |
| 1221 | 1075 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], |
| ... | ... | @@ -1230,8 +1084,6 @@ |
| 1230 | 1084 | |
| 1231 | 1085 | "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], |
| 1232 | 1086 | |
| 1233 | "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], | |
| 1234 | ||
| 1235 | 1087 | "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], |
| 1236 | 1088 | |
| 1237 | 1089 | "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], |
| ... | ... | @@ -1240,46 +1092,20 @@ |
| 1240 | 1092 | |
| 1241 | 1093 | "electronmon/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], |
| 1242 | 1094 | |
| 1243 | "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], | |
| 1244 | ||
| 1245 | "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], | |
| 1095 | "global-agent/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], | |
| 1246 | 1096 | |
| 1247 | 1097 | "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], |
| 1248 | 1098 | |
| 1249 | 1099 | "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], |
| 1250 | 1100 | |
| 1251 | "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], | |
| 1252 | ||
| 1253 | "minipass-fetch/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], | |
| 1254 | ||
| 1255 | "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], | |
| 1256 | ||
| 1257 | "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], | |
| 1258 | ||
| 1259 | "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], | |
| 1260 | ||
| 1261 | "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], | |
| 1262 | ||
| 1263 | 1101 | "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], |
| 1264 | 1102 | |
| 1265 | "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], | |
| 1266 | ||
| 1267 | "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], | |
| 1268 | ||
| 1269 | 1103 | "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], |
| 1270 | 1104 | |
| 1271 | 1105 | "vitest/tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], |
| 1272 | 1106 | |
| 1273 | "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], | |
| 1274 | ||
| 1275 | "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], | |
| 1276 | ||
| 1277 | "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], | |
| 1278 | ||
| 1279 | 1107 | "electronmon/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], |
| 1280 | 1108 | |
| 1281 | "rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], | |
| 1282 | ||
| 1283 | 1109 | "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], |
| 1284 | 1110 | |
| 1285 | 1111 | "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], |
| ... | ... | @@ -1331,7 +1157,5 @@ |
| 1331 | 1157 | "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], |
| 1332 | 1158 | |
| 1333 | 1159 | "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], |
| 1334 | ||
| 1335 | "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], | |
| 1336 | 1160 | } |
| 1337 | 1161 | } |
package.json+8-2| ... | ... | @@ -6,13 +6,19 @@ |
| 6 | 6 | "packages/*" |
| 7 | 7 | ], |
| 8 | 8 | "scripts": { |
| 9 | "dev": "bun run build:contracts && turbo run dev --parallel", | |
| 9 | "dev": "CODETHING_LOG_WS_EVENTS=1 VITE_WS_URL=ws://localhost:3773 turbo run dev --ui=tui --filter=@acme/contracts --filter=@acme/renderer --filter=t3 --parallel", | |
| 10 | "dev:server": "bun run build:contracts && bun run --cwd apps/server dev", | |
| 11 | "dev:web": "VITE_WS_URL=ws://localhost:3773 bun run --cwd apps/renderer dev", | |
| 12 | "dev:desktop": "bun run --cwd apps/desktop dev", | |
| 13 | "start": "node apps/server/dist/index.js", | |
| 14 | "start:desktop": "bun run --cwd apps/desktop start", | |
| 10 | 15 | "build": "turbo run build", |
| 16 | "build:desktop": "bun run build:contracts && bun run --cwd apps/renderer build && bun run --cwd apps/server build && bun run --cwd apps/desktop build", | |
| 11 | 17 | "typecheck": "turbo run typecheck", |
| 12 | 18 | "lint": "oxlint --report-unused-disable-directives", |
| 13 | 19 | "test": "turbo run test", |
| 20 | "test:desktop-smoke": "bun run --cwd apps/desktop smoke-test", | |
| 14 | 21 | "fmt": "oxfmt", |
| 15 | "smoke-test": "turbo run smoke-test", | |
| 16 | 22 | "build:contracts": "bun run --cwd packages/contracts build" |
| 17 | 23 | }, |
| 18 | 24 | "devDependencies": { |
packages/contracts/src/index.ts+2| ... | ... | @@ -4,3 +4,5 @@ export * from "./agent"; |
| 4 | 4 | export * from "./terminal"; |
| 5 | 5 | export * from "./provider"; |
| 6 | 6 | export * from "./model"; |
| 7 | export * from "./ws"; | |
| 8 | export * from "./project"; |
packages/contracts/src/ipc.ts+11-22| ... | ... | @@ -9,6 +9,12 @@ import type { |
| 9 | 9 | ProviderStopSessionInput, |
| 10 | 10 | ProviderTurnStartResult, |
| 11 | 11 | } from "./provider"; |
| 12 | import type { | |
| 13 | ProjectAddInput, | |
| 14 | ProjectAddResult, | |
| 15 | ProjectListResult, | |
| 16 | ProjectRemoveInput, | |
| 17 | } from "./project"; | |
| 12 | 18 | import type { TerminalCommandInput, TerminalCommandResult } from "./terminal"; |
| 13 | 19 | import type { NewTodoInput, Todo } from "./todo"; |
| 14 | 20 | |
| ... | ... | @@ -19,28 +25,6 @@ export const EDITORS = [ |
| 19 | 25 | |
| 20 | 26 | export type EditorId = (typeof EDITORS)[number]["id"]; |
| 21 | 27 | |
| 22 | export const IPC_CHANNELS = { | |
| 23 | todosList: "todos:list", | |
| 24 | todosAdd: "todos:add", | |
| 25 | todosToggle: "todos:toggle", | |
| 26 | todosRemove: "todos:remove", | |
| 27 | dialogPickFolder: "dialog:pick-folder", | |
| 28 | terminalRun: "terminal:run", | |
| 29 | agentSpawn: "agent:spawn", | |
| 30 | agentKill: "agent:kill", | |
| 31 | agentWrite: "agent:write", | |
| 32 | agentOutput: "agent:output", | |
| 33 | agentExit: "agent:exit", | |
| 34 | providerSessionStart: "provider:session:start", | |
| 35 | providerTurnStart: "provider:turn:start", | |
| 36 | providerTurnInterrupt: "provider:turn:interrupt", | |
| 37 | providerSessionStop: "provider:session:stop", | |
| 38 | providerSessionList: "provider:session:list", | |
| 39 | providerRequestRespond: "provider:request:respond", | |
| 40 | providerEvent: "provider:event", | |
| 41 | shellOpenInEditor: "shell:open-in-editor", | |
| 42 | } as const; | |
| 43 | ||
| 44 | 28 | export interface NativeApi { |
| 45 | 29 | todos: { |
| 46 | 30 | list: () => Promise<Todo[]>; |
| ... | ... | @@ -70,6 +54,11 @@ export interface NativeApi { |
| 70 | 54 | listSessions: () => Promise<ProviderSession[]>; |
| 71 | 55 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; |
| 72 | 56 | }; |
| 57 | projects: { | |
| 58 | list: () => Promise<ProjectListResult>; | |
| 59 | add: (input: ProjectAddInput) => Promise<ProjectAddResult>; | |
| 60 | remove: (input: ProjectRemoveInput) => Promise<void>; | |
| 61 | }; | |
| 73 | 62 | shell: { |
| 74 | 63 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
| 75 | 64 | }; |
packages/contracts/src/project.test.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | projectAddInputSchema, | |
| 5 | projectAddResultSchema, | |
| 6 | projectListResultSchema, | |
| 7 | projectRemoveInputSchema, | |
| 8 | } from "./project"; | |
| 9 | ||
| 10 | describe("project contracts", () => { | |
| 11 | it("parses project list result", () => { | |
| 12 | const result = projectListResultSchema.parse([ | |
| 13 | { | |
| 14 | id: "project-1", | |
| 15 | cwd: "/tmp/project", | |
| 16 | name: "project", | |
| 17 | createdAt: "2026-02-10T00:00:00.000Z", | |
| 18 | updatedAt: "2026-02-10T00:00:00.000Z", | |
| 19 | }, | |
| 20 | ]); | |
| 21 | ||
| 22 | expect(result).toHaveLength(1); | |
| 23 | expect(result[0]?.id).toBe("project-1"); | |
| 24 | }); | |
| 25 | ||
| 26 | it("trims add input cwd", () => { | |
| 27 | const parsed = projectAddInputSchema.parse({ cwd: " /tmp/project " }); | |
| 28 | expect(parsed.cwd).toBe("/tmp/project"); | |
| 29 | }); | |
| 30 | ||
| 31 | it("requires add result created flag", () => { | |
| 32 | const parsed = projectAddResultSchema.parse({ | |
| 33 | project: { | |
| 34 | id: "project-1", | |
| 35 | cwd: "/tmp/project", | |
| 36 | name: "project", | |
| 37 | createdAt: "2026-02-10T00:00:00.000Z", | |
| 38 | updatedAt: "2026-02-10T00:00:00.000Z", | |
| 39 | }, | |
| 40 | created: true, | |
| 41 | }); | |
| 42 | expect(parsed.created).toBe(true); | |
| 43 | }); | |
| 44 | ||
| 45 | it("parses remove input", () => { | |
| 46 | const parsed = projectRemoveInputSchema.parse({ id: "project-1" }); | |
| 47 | expect(parsed.id).toBe("project-1"); | |
| 48 | }); | |
| 49 | }); |
packages/contracts/src/project.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | import { z } from "zod"; | |
| 2 | ||
| 3 | export const projectRecordSchema = z.object({ | |
| 4 | id: z.string().min(1), | |
| 5 | cwd: z.string().min(1), | |
| 6 | name: z.string().min(1), | |
| 7 | createdAt: z.string().datetime(), | |
| 8 | updatedAt: z.string().datetime(), | |
| 9 | }); | |
| 10 | ||
| 11 | export const projectListResultSchema = z.array(projectRecordSchema); | |
| 12 | ||
| 13 | export const projectAddInputSchema = z.object({ | |
| 14 | cwd: z.string().trim().min(1), | |
| 15 | }); | |
| 16 | ||
| 17 | export const projectAddResultSchema = z.object({ | |
| 18 | project: projectRecordSchema, | |
| 19 | created: z.boolean(), | |
| 20 | }); | |
| 21 | ||
| 22 | export const projectRemoveInputSchema = z.object({ | |
| 23 | id: z.string().min(1), | |
| 24 | }); | |
| 25 | ||
| 26 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; | |
| 27 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; | |
| 28 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; | |
| 29 | export type ProjectAddResult = z.infer<typeof projectAddResultSchema>; | |
| 30 | export type ProjectRemoveInput = z.input<typeof projectRemoveInputSchema>; |
packages/contracts/src/ws.ts created+78| ... | ... | @@ -0,0 +1,78 @@ |
| 1 | import { z } from "zod"; | |
| 2 | ||
| 3 | // ── WebSocket RPC Method Names ─────────────────────────────────────── | |
| 4 | ||
| 5 | export const WS_METHODS = { | |
| 6 | // Provider methods (mirrors NativeApi.providers) | |
| 7 | providersStartSession: "providers.startSession", | |
| 8 | providersSendTurn: "providers.sendTurn", | |
| 9 | providersInterruptTurn: "providers.interruptTurn", | |
| 10 | providersRespondToRequest: "providers.respondToRequest", | |
| 11 | providersStopSession: "providers.stopSession", | |
| 12 | providersListSessions: "providers.listSessions", | |
| 13 | ||
| 14 | // Project registry methods | |
| 15 | projectsList: "projects.list", | |
| 16 | projectsAdd: "projects.add", | |
| 17 | projectsRemove: "projects.remove", | |
| 18 | ||
| 19 | // Shell methods | |
| 20 | shellOpenInEditor: "shell.openInEditor", | |
| 21 | ||
| 22 | // Server meta | |
| 23 | serverGetConfig: "server.getConfig", | |
| 24 | } as const; | |
| 25 | ||
| 26 | // ── Push Event Channels ────────────────────────────────────────────── | |
| 27 | ||
| 28 | export const WS_CHANNELS = { | |
| 29 | providerEvent: "providers.event", | |
| 30 | serverWelcome: "server.welcome", | |
| 31 | } as const; | |
| 32 | ||
| 33 | // ── Client → Server (request) ──────────────────────────────────────── | |
| 34 | ||
| 35 | export const wsRequestSchema = z.object({ | |
| 36 | id: z.string().min(1), | |
| 37 | method: z.string().min(1), | |
| 38 | params: z.unknown().optional(), | |
| 39 | }); | |
| 40 | ||
| 41 | export type WsRequest = z.infer<typeof wsRequestSchema>; | |
| 42 | ||
| 43 | // ── Server → Client (response to request) ──────────────────────────── | |
| 44 | ||
| 45 | export const wsResponseSchema = z.object({ | |
| 46 | id: z.string().min(1), | |
| 47 | result: z.unknown().optional(), | |
| 48 | error: z | |
| 49 | .object({ | |
| 50 | message: z.string(), | |
| 51 | }) | |
| 52 | .optional(), | |
| 53 | }); | |
| 54 | ||
| 55 | export type WsResponse = z.infer<typeof wsResponseSchema>; | |
| 56 | ||
| 57 | // ── Server → Client (push event) ───────────────────────────────────── | |
| 58 | ||
| 59 | export const wsPushSchema = z.object({ | |
| 60 | type: z.literal("push"), | |
| 61 | channel: z.string().min(1), | |
| 62 | data: z.unknown(), | |
| 63 | }); | |
| 64 | ||
| 65 | export type WsPush = z.infer<typeof wsPushSchema>; | |
| 66 | ||
| 67 | // ── Union of all server → client messages ───────────────────────────── | |
| 68 | ||
| 69 | export type WsServerMessage = WsResponse | WsPush; | |
| 70 | ||
| 71 | // ── Server welcome payload ─────────────────────────────────────────── | |
| 72 | ||
| 73 | export const wsWelcomePayloadSchema = z.object({ | |
| 74 | cwd: z.string().min(1), | |
| 75 | projectName: z.string().min(1), | |
| 76 | }); | |
| 77 | ||
| 78 | export type WsWelcomePayload = z.infer<typeof wsWelcomePayloadSchema>; |
turbo.json+13-6| ... | ... | @@ -1,12 +1,24 @@ |
| 1 | 1 | { |
| 2 | 2 | "$schema": "https://turbo.build/schema.json", |
| 3 | "globalEnv": ["ELECTRON_RENDERER_PORT"], | |
| 3 | "globalEnv": [ | |
| 4 | "PORT", | |
| 5 | "VITE_WS_URL", | |
| 6 | "VITE_DEV_SERVER_URL", | |
| 7 | "CODETHING_LOG_WS_EVENTS", | |
| 8 | "CODETHING_MODE", | |
| 9 | "CODETHING_PORT", | |
| 10 | "CODETHING_NO_BROWSER", | |
| 11 | "CODETHING_STATE_DIR", | |
| 12 | "CODETHING_AUTH_TOKEN", | |
| 13 | "CODETHING_DESKTOP_WS_URL" | |
| 14 | ], | |
| 4 | 15 | "tasks": { |
| 5 | 16 | "build": { |
| 6 | 17 | "dependsOn": ["^build"], |
| 7 | 18 | "outputs": ["dist/**", "dist-electron/**"] |
| 8 | 19 | }, |
| 9 | 20 | "dev": { |
| 21 | "dependsOn": ["@acme/contracts#build"], | |
| 10 | 22 | "cache": false, |
| 11 | 23 | "persistent": true |
| 12 | 24 | }, |
| ... | ... | @@ -17,11 +29,6 @@ |
| 17 | 29 | "test": { |
| 18 | 30 | "dependsOn": ["^build"], |
| 19 | 31 | "outputs": [] |
| 20 | }, | |
| 21 | "smoke-test": { | |
| 22 | "dependsOn": ["build"], | |
| 23 | "cache": false, | |
| 24 | "outputs": [] | |
| 25 | 32 | } |
| 26 | 33 | } |
| 27 | 34 | } |