authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-09 22:15:48-08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-02-09 22:15:48-08:00
log8ebd2479cf976a6ef79843b97d477777efb7cb6a
tree85e8cea2dc57ab2e1c59cc74576ff027c37464d0
parente7c4292d271f14ace547c135089245825b82e17b
signature Signed by PGP key B5690EEEBB952194

bring back electron (+ websocket/package changes) (#10)

* 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:
88
99jobs:
1010 quality:
11 name: Lint, Typecheck, Test
11 name: Lint, Typecheck, Test, Build
1212 runs-on: ubuntu-latest
1313 steps:
1414 - name: Checkout
......@@ -17,7 +17,7 @@ jobs:
1717 - name: Setup Bun
1818 uses: oven-sh/setup-bun@v2
1919 with:
20 bun-version: 1.1.38
20 bun-version: 1.3.9
2121
2222 - name: Cache Bun and Turbo
2323 uses: actions/cache@v4
......@@ -40,3 +40,11 @@ jobs:
4040
4141 - name: Test
4242 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
55*.log
66*.tsbuildinfo
77apps/*/dist
8apps/*/dist-electron
98packages/*/dist
109.env
1110.env.local
AGENTS.md+10-9
......@@ -1,7 +1,7 @@
1# AGENTS.md
1# CLAUDE.md
22
33## Project Snapshot
4CodeThing is a minimal GUI for using code agents like Codex and Claude Code (coming soon).
4CodeThing is a minimal web GUI for using code agents like Codex and Claude Code (coming soon).
55
66This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged.
77
......@@ -13,17 +13,18 @@ This repository is a VERY EARLY WIP. Proposing sweeping changes that improve lon
1313If a tradeoff is required, choose correctness and robustness over short-term convenience.
1414
1515## 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.
1919
2020## Codex App Server (Important)
21CodeThing 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.
21CodeThing 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.
2222
2323How 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`.
2728
2829Docs:
2930- 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
3CodeThing is a desktop shell for coding agents. This first implementation is:
4
51. Codex-first: connects to `codex app-server` and streams turn/item events.
62. Provider-ready: renderer speaks a provider abstraction so Claude Code can plug in later.
73. Typed end-to-end: contracts validate payloads at preload/main boundaries.
1# CodeThing
2
3CodeThing is a minimal web GUI for coding agents. Currently Codex-first, with Claude Code support coming soon.
4
5Run `npx t3` in any project directory to launch the web interface.
6Run `bun run dev:desktop` to launch the Electron desktop app in this monorepo.
7
8## Architecture
9
10CodeThing 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```
829
930## Workspace layout
1031
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.
1436
1537## Codex prerequisites
1638
......@@ -18,50 +40,55 @@ CodeThing is a desktop shell for coding agents. This first implementation is:
1840- Authenticate Codex before running CodeThing (for example via API key or ChatGPT auth supported by Codex).
1941- CodeThing starts the server via `codex app-server` per session.
2042
21## Security and boundary model
43## Quick start
2244
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)
47bun run dev
2848
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
50bun run dev:desktop
3051
31## Runtime modes
52# Production
53bun run build
54bun run start
3255
33CodeThing has a global runtime mode switch in the sidebar:
56# Or from any project directory after publishing:
57npx t3
58```
3459
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
3761
38Mode 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.
3969
40## Scripts
70## Runtime modes
4171
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.
72CodeThing has a global runtime mode switch in the chat toolbar:
4673
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.
4876
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
5078
51Optional:
79The renderer communicates with the server via WebSocket using a simple JSON-RPC-style protocol:
5280
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
5483
55## Provider architecture
84Methods mirror the `NativeApi` interface defined in `@acme/contracts`:
5685
57The 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`
5889
591. `startSession`
602. `sendTurn`
613. `interruptTurn`
624. `respondToRequest`
635. `stopSession`
646. `listSessions`
657. `onEvent`
90Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI.
6691
67Codex 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 @@
1dist-electron/
apps/desktop/package.json+5-8
......@@ -4,28 +4,25 @@
44 "private": true,
55 "main": "dist-electron/main.js",
66 "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\"",
88 "dev:bundle": "tsup --watch",
99 "dev:electron": "bun run scripts/dev-electron.mjs",
1010 "build": "tsup",
1111 "start": "electron dist-electron/main.js",
12 "postinstall": "electron-rebuild",
1312 "typecheck": "tsc --noEmit",
14 "test": "vitest run",
13 "test": "vitest run --passWithNoTests",
1514 "smoke-test": "node scripts/smoke-test.mjs"
1615 },
1716 "dependencies": {
18 "@acme/contracts": "workspace:*",
19 "electron": "33.4.11",
20 "node-pty": "^1.0.0"
17 "electron": "33.4.11"
2118 },
2219 "devDependencies": {
23 "concurrently": "^9.1.2",
24 "@electron/rebuild": "^3.7.0",
2520 "@types/node": "^22.10.2",
21 "concurrently": "^9.1.2",
2622 "electronmon": "^2.0.2",
2723 "tsup": "^8.3.5",
2824 "typescript": "^5.7.3",
25 "vitest": "^3.0.0",
2926 "wait-on": "^8.0.2"
3027 }
3128}
apps/desktop/scripts/dev-electron.mjs+2-2
......@@ -10,11 +10,11 @@ await waitOn({
1010 `tcp:${port}`,
1111 "file:dist-electron/main.js",
1212 "file:dist-electron/preload.js",
13 "file:../server/dist/index.js",
1314 ],
1415});
1516
16const command =
17 process.platform === "win32" ? "electronmon.cmd" : "electronmon";
17const command = process.platform === "win32" ? "electronmon.cmd" : "electronmon";
1818const child = spawn(command, ["dist-electron/main.js"], {
1919 stdio: "inherit",
2020 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 */
16import { spawn, execSync } from "node:child_process";
1import { execSync, spawn } from "node:child_process";
172import { dirname, resolve } from "node:path";
183import { fileURLToPath } from "node:url";
194
205const __dirname = dirname(fileURLToPath(import.meta.url));
21const root = resolve(__dirname, "../../..");
226const desktopDir = resolve(__dirname, "..");
7const rootDir = resolve(__dirname, "../../..");
238const electronBin = resolve(desktopDir, "node_modules/.bin/electron");
249const mainJs = resolve(desktopDir, "dist-electron/main.js");
2510
26// ── Build first ──────────────────────────────────────────────────────
27console.log("Building contracts + renderer + desktop...");
28execSync("bun run build", { cwd: root, stdio: "inherit" });
11console.log("Building contracts + renderer + server + desktop...");
12execSync("bun run build", { cwd: rootDir, stdio: "inherit" });
2913
30// ── Launch Electron (production mode — no VITE_DEV_SERVER_URL) ──────
31console.log("\nLaunching Electron (production mode)...");
14console.log("\nLaunching Electron smoke test...");
3215
3316const child = spawn(electronBin, [mainJs], {
3417 stdio: ["pipe", "pipe", "pipe"],
3518 env: {
3619 ...process.env,
37 VITE_DEV_SERVER_URL: "", // ensure production path
20 VITE_DEV_SERVER_URL: "",
3821 ELECTRON_ENABLE_LOGGING: "1",
3922 },
4023});
4124
4225let output = "";
43
44child.stdout.on("data", (d) => {
45 output += d.toString();
26child.stdout.on("data", (chunk) => {
27 output += chunk.toString();
4628});
47child.stderr.on("data", (d) => {
48 output += d.toString();
29child.stderr.on("data", (chunk) => {
30 output += chunk.toString();
4931});
5032
51const TIMEOUT_MS = 8_000;
52
53const timer = setTimeout(() => {
33const timeout = setTimeout(() => {
5434 child.kill();
55}, TIMEOUT_MS);
35}, 8_000);
5636
5737child.on("exit", () => {
58 clearTimeout(timer);
38 clearTimeout(timeout);
5939
60 // Fatal patterns that indicate broken builds
6140 const fatalPatterns = [
6241 "Cannot find module",
6342 "MODULE_NOT_FOUND",
64 "Refused to execute", // CSP blocking scripts
65 "can't detect preamble", // @vitejs/plugin-react failure
43 "Refused to execute",
6644 "Uncaught Error",
6745 "Uncaught TypeError",
6846 "Uncaught ReferenceError",
6947 ];
70
71 const failures = fatalPatterns.filter((p) => output.includes(p));
48 const failures = fatalPatterns.filter((pattern) => output.includes(pattern));
7249
7350 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}`);
7754 }
7855 console.error("\nFull output:\n" + output);
7956 process.exit(1);
8057 }
8158
82 console.log("✅ Smoke test passed — no fatal errors detected");
59 console.log("Desktop smoke test passed.");
8360 process.exit(0);
8461});
apps/desktop/src/codexAppServerManager.test.ts deleted-81
......@@ -1,81 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 classifyCodexStderrLine,
5 isRecoverableThreadResumeError,
6 normalizeCodexModelSlug,
7} from "./codexAppServerManager";
8
9describe("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
41describe("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
57describe("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 @@
1import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2import { randomUUID } from "node:crypto";
3import { EventEmitter } from "node:events";
4import readline from "node:readline";
5
6import type {
7 ProviderApprovalDecision,
8 ProviderEvent,
9 ProviderRequestKind,
10 ProviderSendTurnInput,
11 ProviderSession,
12 ProviderSessionStartInput,
13 ProviderTurnStartResult,
14} from "@acme/contracts";
15import { normalizeModelSlug } from "@acme/contracts";
16
17type PendingRequestKey = string;
18
19interface PendingRequest {
20 method: string;
21 timeout: ReturnType<typeof setTimeout>;
22 resolve: (value: unknown) => void;
23 reject: (error: Error) => void;
24}
25
26interface 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
38interface 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
48interface JsonRpcError {
49 code?: number;
50 message?: string;
51}
52
53interface JsonRpcRequest {
54 id: string | number;
55 method: string;
56 params?: unknown;
57}
58
59interface JsonRpcResponse {
60 id: string | number;
61 result?: unknown;
62 error?: JsonRpcError;
63}
64
65interface JsonRpcNotification {
66 method: string;
67 params?: unknown;
68}
69
70const ANSI_ESCAPE_CHAR = String.fromCharCode(27);
71const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g");
72const CODEX_STDERR_LOG_REGEX =
73 /^\d{4}-\d{2}-\d{2}T\S+\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+\S+:\s+(.*)$/;
74const 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];
78const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
79 "not found",
80 "missing thread",
81 "no such thread",
82 "unknown thread",
83 "does not exist",
84];
85
86export 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
102export 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
124export 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
137export interface CodexAppServerManagerEvents {
138 event: [event: ProviderEvent];
139}
140
141export 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 {
1313 process.env.PATH = result;
1414 }
1515 } catch {
16 // Silently ignore — keep default PATH
16 // Keep inherited PATH if shell lookup fails.
1717 }
1818}
apps/desktop/src/ipcHelpers.test.ts deleted-104
......@@ -1,104 +0,0 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { withParsedArgs, withParsedPayload } from "./ipcHelpers";
4
5describe("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
56describe("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 @@
1type MaybePromise<T> = T | Promise<T>;
2type Parser<T> = {
3 parse: (value: unknown) => T;
4};
5type ArgsParser<T extends unknown[]> = {
6 parse: (value: unknown[]) => T;
7};
8
9export 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
16export 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 @@
1import { fixPath } from "./fixPath";
2fixPath();
3
4import { spawn } from "node:child_process";
1import { spawn, type ChildProcess } from "node:child_process";
2import { randomBytes } from "node:crypto";
3import fs from "node:fs";
4import net from "node:net";
5import os from "node:os";
56import path from "node:path";
6import { BrowserWindow, app, dialog, ipcMain, session, shell } from "electron";
77
8import {
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";
24import { withParsedArgs, withParsedPayload } from "./ipcHelpers";
25import { ProcessManager } from "./processManager";
26import { ProviderManager } from "./providerManager";
27import { TodoStore } from "./todoStore";
8import { app, BrowserWindow, dialog, ipcMain } from "electron";
289
29const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
10import { fixPath } from "./fixPath";
3011
31let todoStore: TodoStore;
32const processManager = new ProcessManager();
33const providerManager = new ProviderManager();
34const 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 }
12fixPath();
4013
41 return [agentSessionIdSchema.parse(sessionId), data];
42 },
43};
14const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
15const ROOT_DIR = path.resolve(__dirname, "../../..");
16const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js");
17const RENDERER_ENTRY = path.join(ROOT_DIR, "apps/renderer/dist/index.html");
18const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");
19const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
4420
45function 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 },
21let mainWindow: BrowserWindow | null = null;
22let backendProcess: ChildProcess | null = null;
23let backendPort = 0;
24let backendAuthToken = "";
25let backendWsUrl = "";
26let restartAttempt = 0;
27let restartTimer: ReturnType<typeof setTimeout> | null = null;
28let isQuitting = false;
29
30async 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);
6145 });
46}
6247
63 window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
48function 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}
6458
65 window.once("ready-to-show", () => {
66 window.show();
67 });
59function scheduleBackendRestart(reason: string): void {
60 if (isQuitting || restartTimer) return;
6861
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`);
7065
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
72function startBackend(): void {
73 if (isQuitting || backendProcess) return;
74
75 if (!fs.existsSync(BACKEND_ENTRY)) {
76 scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`);
77 return;
7478 }
7579
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;
7986
80function 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);
84105 });
106}
85107
86 ipcMain.handle(
87 IPC_CHANNELS.todosAdd,
88 withParsedPayload(newTodoInputSchema, async (_event, payload) => {
89 return todoStore.add(payload);
90 }),
91 );
108function stopBackend(): void {
109 if (restartTimer) {
110 clearTimeout(restartTimer);
111 restartTimer = null;
112 }
92113
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;
99117
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}
106127
107 ipcMain.handle(IPC_CHANNELS.dialogPickFolder, async () => {
108 const owner = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0];
128function registerIpcHandlers(): void {
129 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
130 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
131 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
109132 const result = owner
110133 ? await dialog.showOpenDialog(owner, {
111134 properties: ["openDirectory", "createDirectory"],
......@@ -113,233 +136,76 @@ function registerIpcHandlers(): void {
113136 : await dialog.showOpenDialog({
114137 properties: ["openDirectory", "createDirectory"],
115138 });
116
117139 if (result.canceled) return null;
118140 return result.filePaths[0] ?? null;
119141 });
142}
120143
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();
144function 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,
149159 },
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();
215160 });
216}
217
218async 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 });
260161
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();
271165 });
272}
273
274function setupEventForwarding(window: BrowserWindow): void {
275 const onOutput = (chunk: unknown) => {
276 if (!window.isDestroyed()) {
277 window.webContents.send(IPC_CHANNELS.agentOutput, chunk);
278 }
279 };
280166
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}`);
290172 }
291 };
292
293 processManager.on("output", onOutput);
294 processManager.on("exit", onExit);
295 providerManager.on("event", onProviderEvent);
173 void window.loadFile(RENDERER_ENTRY);
174 }
296175
297176 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 }
301180 });
302}
303
304function 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'";
309181
310 callback({
311 responseHeaders: {
312 ...details.responseHeaders,
313 "Content-Security-Policy": [csp],
314 },
315 });
316 });
182 return window;
317183}
318184
319185async 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;
324190
325191 registerIpcHandlers();
326 createWindow();
327
328 app.on("activate", () => {
329 if (BrowserWindow.getAllWindows().length === 0) {
330 createWindow();
331 }
332 });
192 startBackend();
193 mainWindow = createWindow();
333194}
334195
335196app.on("before-quit", () => {
336 processManager.killAll();
337 providerManager.stopAll();
338 providerManager.dispose();
197 isQuitting = true;
198 stopBackend();
339199});
340200
341201app.whenReady().then(() => {
342202 void bootstrap();
203
204 app.on("activate", () => {
205 if (BrowserWindow.getAllWindows().length === 0) {
206 mainWindow = createWindow();
207 }
208 });
343209});
344210
345211app.on("window-all-closed", () => {
apps/desktop/src/preload.ts+6-52
......@@ -1,55 +1,9 @@
11import { contextBridge, ipcRenderer } from "electron";
22
3import { IPC_CHANNELS, type NativeApi } from "@acme/contracts";
3const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
4const wsUrl = process.env.CODETHING_DESKTOP_WS_URL ?? null;
45
5const 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
55contextBridge.exposeInMainWorld("nativeApi", nativeApi);
6contextBridge.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 @@
1import { describe, expect, it } from "vitest";
2
3import { ProcessManager } from "./processManager";
4
5describe("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 @@
1import { type ChildProcess, spawn } from "node:child_process";
2import { randomUUID } from "node:crypto";
3import { EventEmitter } from "node:events";
4
5import {
6 type AgentConfig,
7 type AgentExit,
8 type OutputChunk,
9 agentConfigSchema,
10} from "@acme/contracts";
11import type { IPty } from "node-pty";
12
13export interface ProcessManagerEvents {
14 output: [chunk: OutputChunk];
15 exit: [exit: AgentExit];
16}
17
18export 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 @@
1import { describe, expect, it } from "vitest";
2
3import { ProviderManager } from "./providerManager";
4
5describe("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 @@
1import { EventEmitter } from "node:events";
2import fs from "node:fs";
3import path from "node:path";
4
5import {
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";
20import { CodexAppServerManager } from "./codexAppServerManager";
21
22export interface ProviderManagerEvents {
23 event: [event: ProviderEvent];
24}
25
26export 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 @@
1import { randomUUID } from "node:crypto";
2import { promises as fs } from "node:fs";
3import path from "node:path";
4
5import {
6 type NewTodoInput,
7 type Todo,
8 newTodoInputSchema,
9 todoIdSchema,
10 todoListSchema,
11} from "@acme/contracts";
12
13export 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
99function 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 @@
33 <head>
44 <meta charset="UTF-8" />
55 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Electron Todo</title>
6 <title>CodeThing</title>
77 </head>
88 <body>
99 <div id="root"></div>
apps/renderer/src/App.tsx+118-4
......@@ -3,8 +3,11 @@ import { useEffect, useMemo, useRef } from "react";
33import ChatView from "./components/ChatView";
44import DiffPanel from "./components/DiffPanel";
55import Sidebar from "./components/Sidebar";
6import { isElectron } from "./env";
7import { DEFAULT_MODEL } from "./model-logic";
68import { readNativeApi } from "./session-logic";
79import { StoreProvider, useStore } from "./store";
10import { onServerWelcome } from "./wsNativeApi";
811
912function EventRouter() {
1013 const api = useMemo(() => readNativeApi(), []);
......@@ -25,6 +28,118 @@ function EventRouter() {
2528 return null;
2629}
2730
31function 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
93function 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
28143function Layout() {
29144 const api = useMemo(() => readNativeApi(), []);
30145 const { state } = useStore();
......@@ -32,11 +147,8 @@ function Layout() {
32147 if (!api) {
33148 return (
34149 <div className="flex h-screen flex-col bg-background text-foreground">
35 <div className="drag-region h-[52px] shrink-0" />
36150 <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>
40152 </div>
41153 </div>
42154 );
......@@ -45,6 +157,8 @@ function Layout() {
45157 return (
46158 <div className="flex h-screen overflow-hidden bg-background text-foreground">
47159 <EventRouter />
160 <AutoProjectBootstrap />
161 <DesktopProjectBootstrap />
48162 <Sidebar />
49163 <ChatView />
50164 {state.diffOpen && <DiffPanel />}
apps/renderer/src/components/ChatView.tsx+3-2
......@@ -14,6 +14,7 @@ import {
1414} from "react";
1515
1616import { EDITORS, type EditorId } from "@acme/contracts";
17import { isElectron } from "../env";
1718import { buildBootstrapInput } from "../historyBootstrap";
1819import {
1920 DEFAULT_MODEL,
......@@ -563,7 +564,7 @@ export default function ChatView() {
563564 if (!activeThread) {
564565 return (
565566 <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" />}
567568 <div className="flex flex-1 items-center justify-center">
568569 <div className="text-center">
569570 <p className="text-sm">Select a thread or create a new one to get started.</p>
......@@ -576,7 +577,7 @@ export default function ChatView() {
576577 return (
577578 <div className="flex flex-1 flex-col bg-background">
578579 {/* 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"}`}>
580581 <div className="flex items-center gap-3">
581582 <h2 className="text-sm font-medium text-foreground">
582583 {activeThread.title}
apps/renderer/src/components/DiffPanel.tsx+2-1
......@@ -1,3 +1,4 @@
1import { isElectron } from "../env";
12import { useStore } from "../store";
23
34export default function DiffPanel() {
......@@ -6,7 +7,7 @@ export default function DiffPanel() {
67 return (
78 <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
89 {/* 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"}`}>
1011 <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3>
1112 <button
1213 type="button"
apps/renderer/src/components/Sidebar.tsx+112-43
......@@ -1,5 +1,6 @@
11import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
22import { useCallback, useEffect, useMemo, useState } from "react";
3import { isElectron } from "../env";
34import { useTheme } from "../hooks/useTheme";
45import { DEFAULT_MODEL } from "../model-logic";
56import { readNativeApi } from "../session-logic";
......@@ -17,6 +18,13 @@ function formatRelativeTime(iso: string): string {
1718 return `${Math.floor(hours / 24)}d ago`;
1819}
1920
21function 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
2028function threadStatusLabel(
2129 status: "connecting" | "ready" | "running" | "error" | "closed" | undefined,
2230): "Working" | "Connecting" | null {
......@@ -32,22 +40,7 @@ export default function Sidebar() {
3240 const [addingProject, setAddingProject] = useState(false);
3341 const [newCwd, setNewCwd] = useState("");
3442 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);
5144
5245 const handleNewThread = useCallback(
5346 (projectId: string) => {
......@@ -58,9 +51,7 @@ export default function Sidebar() {
5851 codexThreadId: null,
5952 projectId,
6053 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,
6455 session: null,
6556 messages: [],
6657 events: [],
......@@ -72,13 +63,98 @@ export default function Sidebar() {
7263 [dispatch, state.projects],
7364 );
7465
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
75151 const handlePickFolder = async () => {
76152 if (!api || isPickingFolder) return;
77153 setIsPickingFolder(true);
78154 try {
79155 const pickedPath = await api.dialogs.pickFolder();
80156 if (!pickedPath) return;
81 setNewCwd(pickedPath);
157 await addProjectFromPath(pickedPath);
82158 } finally {
83159 setIsPickingFolder(false);
84160 }
......@@ -107,9 +183,7 @@ export default function Sidebar() {
107183 }
108184 }
109185
110 const activeThread = state.threads.find(
111 (t) => t.id === state.activeThreadId,
112 );
186 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
113187 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
114188 if (!projectId) return;
115189
......@@ -125,10 +199,10 @@ export default function Sidebar() {
125199
126200 return (
127201 <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" />}
130204 {/* 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"}`}>
132206 <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-xs font-bold text-primary-foreground">
133207 CT
134208 </div>
......@@ -176,10 +250,8 @@ export default function Sidebar() {
176250 {state.projects.map((project) => {
177251 const threads = state.threads
178252 .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();
183255 if (byDate !== 0) return byDate;
184256 return b.id.localeCompare(a.id);
185257 });
......@@ -205,15 +277,15 @@ export default function Sidebar() {
205277 <div className="ml-2 border-l border-border/80 pl-2">
206278 {threads.map((thread) => {
207279 const isActive = state.activeThreadId === thread.id;
208 const threadStatus = threadStatusLabel(
209 thread.session?.status,
210 );
280 const threadStatus = threadStatusLabel(thread.session?.status);
211281 return (
212282 <button
213283 key={thread.id}
214284 type="button"
215285 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"
217289 }`}
218290 onClick={() =>
219291 dispatch({
......@@ -226,14 +298,10 @@ export default function Sidebar() {
226298 {threadStatus && (
227299 <span className="inline-flex items-center gap-1 text-[10px] text-sky-600 dark:text-sky-300/80">
228300 <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>
232302 </span>
233303 )}
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>
237305 </div>
238306 <span className="shrink-0 text-[10px] text-muted-foreground/40">
239307 {formatRelativeTime(thread.createdAt)}
......@@ -281,12 +349,12 @@ export default function Sidebar() {
281349 if (e.key === "Escape") setAddingProject(false);
282350 }}
283351 />
284 {api && (
352 {isElectron && api && (
285353 <button
286354 type="button"
287355 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"
288356 onClick={() => void handlePickFolder()}
289 disabled={isPickingFolder}
357 disabled={isPickingFolder || isAddingProject}
290358 >
291359 {isPickingFolder ? "Picking folder..." : "Browse for folder"}
292360 </button>
......@@ -296,8 +364,9 @@ export default function Sidebar() {
296364 type="button"
297365 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"
298366 onClick={handleAddProject}
367 disabled={isAddingProject}
299368 >
300 Add
369 {isAddingProject ? "Adding..." : "Add"}
301370 </button>
302371 <button
303372 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 */
6export 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 @@
1import type { ProviderEvent, ProviderKind, ProviderSession } from "@acme/contracts";
1import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@acme/contracts";
22import type { ChatMessage, SessionPhase } from "./types";
3import { createWsNativeApi } from "./wsNativeApi";
34
45export const PROVIDER_OPTIONS: Array<{
56 value: ProviderKind;
......@@ -10,9 +11,21 @@ export const PROVIDER_OPTIONS: Array<{
1011 { value: "claudeCode", label: "Claude Code (soon)", available: false },
1112];
1213
13export function readNativeApi() {
14let cachedApi: NativeApi | undefined;
15
16export function readNativeApi(): NativeApi | undefined {
1417 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;
1629}
1730
1831export 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";
44import { type AppState, reducer } from "./store";
55import type { Thread } from "./types";
66
7function makeSession(
8 overrides: Partial<ProviderSession> = {},
9): ProviderSession {
7function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession {
108 return {
119 sessionId: "sess-1",
1210 provider: "codex",
......@@ -131,4 +129,60 @@ describe("store reducer thread continuity", () => {
131129 expect(next.threads[0]?.codexThreadId).toBe("thr_new");
132130 expect(next.threads[0]?.session?.threadId).toBe("thr_new");
133131 });
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 });
134188});
apps/renderer/src/store.ts+57-40
......@@ -11,23 +11,14 @@ import {
1111import type { ProviderEvent, ProviderSession } from "@acme/contracts";
1212import { resolveModelSlug } from "./model-logic";
1313import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14import {
15 applyEventToMessages,
16 asObject,
17 asString,
18 evolveSession,
19} from "./session-logic";
20import {
21 DEFAULT_RUNTIME_MODE,
22 type Project,
23 type RuntimeMode,
24 type Thread,
25} from "./types";
14import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
15import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types";
2616
2717// ── Actions ──────────────────────────────────────────────────────────
2818
2919type Action =
3020 | { type: "ADD_PROJECT"; project: Project }
21 | { type: "SYNC_PROJECTS"; projects: Project[] }
3122 | { type: "TOGGLE_PROJECT"; projectId: string }
3223 | { type: "ADD_THREAD"; thread: Thread }
3324 | { type: "SET_ACTIVE_THREAD"; threadId: string }
......@@ -98,10 +89,7 @@ function persistState(state: AppState): void {
9889 if (typeof window === "undefined") return;
9990
10091 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)));
10593 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
10694 window.localStorage.removeItem(legacyKey);
10795 }
......@@ -143,10 +131,7 @@ function getEventThreadId(event: ProviderEvent): string | undefined {
143131 );
144132}
145133
146function shouldIgnoreForeignThreadEvent(
147 thread: Thread,
148 event: ProviderEvent,
149): boolean {
134function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean {
150135 const eventThreadId = getEventThreadId(event);
151136 if (!eventThreadId) {
152137 return false;
......@@ -175,10 +160,7 @@ function durationMs(startIso: string, endIso: string): number | undefined {
175160 return end - start;
176161}
177162
178function updateTurnFields(
179 thread: Thread,
180 event: ProviderEvent,
181): Partial<Thread> {
163function updateTurnFields(thread: Thread, event: ProviderEvent): Partial<Thread> {
182164 if (event.method === "turn/started") {
183165 return {
184166 latestTurnId: getEventTurnId(event) ?? thread.latestTurnId,
......@@ -195,9 +177,7 @@ function updateTurnFields(
195177 ? thread.latestTurnStartedAt
196178 : undefined;
197179 const elapsed =
198 startedAt && startedAt.length > 0
199 ? durationMs(startedAt, event.createdAt)
200 : undefined;
180 startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined;
201181
202182 return {
203183 latestTurnId: completedTurnId ?? thread.latestTurnId,
......@@ -214,6 +194,9 @@ function updateTurnFields(
214194export function reducer(state: AppState, action: Action): AppState {
215195 switch (action.type) {
216196 case "ADD_PROJECT":
197 if (state.projects.some((project) => project.cwd === action.project.cwd)) {
198 return state;
199 }
217200 return {
218201 ...state,
219202 projects: [
......@@ -225,6 +208,48 @@ export function reducer(state: AppState, action: Action): AppState {
225208 ],
226209 };
227210
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
228253 case "TOGGLE_PROJECT":
229254 return {
230255 ...state,
......@@ -265,24 +290,16 @@ export function reducer(state: AppState, action: Action): AppState {
265290 ...(() => {
266291 const eventThreadId = getEventThreadId(event);
267292 const shouldRebindIdentity =
268 event.method === "thread/started" &&
269 t.session?.status === "connecting";
293 event.method === "thread/started" && t.session?.status === "connecting";
270294 return {
271295 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,
278299 };
279300 })(),
280301 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),
286303 events: [event, ...t.events],
287304 ...updateTurnFields(t, event),
288305 })),
apps/renderer/src/vite-env.d.ts+6
......@@ -2,8 +2,14 @@
22
33import type { NativeApi } from "@acme/contracts";
44
5interface DesktopBridge {
6 getWsUrl: () => string | null;
7 pickFolder: () => Promise<string | null>;
8}
9
510declare global {
611 interface Window {
712 nativeApi?: NativeApi;
13 desktopBridge?: DesktopBridge;
814 }
915}
apps/renderer/src/wsNativeApi.ts created+102
......@@ -0,0 +1,102 @@
1import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@acme/contracts";
2
3import { WsTransport } from "./wsTransport";
4
5let instance: { api: NativeApi; transport: WsTransport } | null = null;
6const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
7let 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 */
14export 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
31export 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 @@
1import type { WsPush, WsRequest, WsResponse } from "@acme/contracts";
2
3type PushListener = (data: unknown) => void;
4
5interface PendingRequest {
6 resolve: (result: unknown) => void;
7 reject: (error: Error) => void;
8 timeout: ReturnType<typeof setTimeout>;
9}
10
11const REQUEST_TIMEOUT_MS = 60_000;
12const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
13
14export 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({
1111 }),
1212 tailwindcss(),
1313 ],
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 },
1420 server: {
15 port: Number(process.env.ELECTRON_RENDERER_PORT ?? 5173),
21 port: Number(process.env.PORT ?? 5173),
1622 strictPort: true,
1723 },
1824 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 */
5import fs from "node:fs";
6import path from "node:path";
7import { fileURLToPath } from "node:url";
8
9const __dirname = path.dirname(fileURLToPath(import.meta.url));
10const rendererDist = path.resolve(__dirname, "../../renderer/dist");
11const target = path.resolve(__dirname, "../dist/client");
12
13if (!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
20fs.cpSync(rendererDist, target, { recursive: true });
21console.log("✓ Bundled renderer into dist/client");
apps/server/src/codexAppServerManager.test.ts created+81
......@@ -0,0 +1,81 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 classifyCodexStderrLine,
5 isRecoverableThreadResumeError,
6 normalizeCodexModelSlug,
7} from "./codexAppServerManager";
8
9describe("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
41describe("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
57describe("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 @@
1import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2import { randomUUID } from "node:crypto";
3import { EventEmitter } from "node:events";
4import readline from "node:readline";
5
6import type {
7 ProviderApprovalDecision,
8 ProviderEvent,
9 ProviderRequestKind,
10 ProviderSendTurnInput,
11 ProviderSession,
12 ProviderSessionStartInput,
13 ProviderTurnStartResult,
14} from "@acme/contracts";
15import { normalizeModelSlug } from "@acme/contracts";
16
17type PendingRequestKey = string;
18
19interface PendingRequest {
20 method: string;
21 timeout: ReturnType<typeof setTimeout>;
22 resolve: (value: unknown) => void;
23 reject: (error: Error) => void;
24}
25
26interface 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
38interface 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
48interface JsonRpcError {
49 code?: number;
50 message?: string;
51}
52
53interface JsonRpcRequest {
54 id: string | number;
55 method: string;
56 params?: unknown;
57}
58
59interface JsonRpcResponse {
60 id: string | number;
61 result?: unknown;
62 error?: JsonRpcError;
63}
64
65interface JsonRpcNotification {
66 method: string;
67 params?: unknown;
68}
69
70const ANSI_ESCAPE_CHAR = String.fromCharCode(27);
71const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g");
72const CODEX_STDERR_LOG_REGEX =
73 /^\d{4}-\d{2}-\d{2}T\S+\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+\S+:\s+(.*)$/;
74const 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];
78const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
79 "not found",
80 "missing thread",
81 "no such thread",
82 "unknown thread",
83 "does not exist",
84];
85
86export 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
102export 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
124export 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
137export interface CodexAppServerManagerEvents {
138 event: [event: ProviderEvent];
139}
140
141export 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 @@
1import { execFileSync } from "node:child_process";
2
3export 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 @@
1import fs from "node:fs";
2import net from "node:net";
3import os from "node:os";
4import path from "node:path";
5import { fileURLToPath } from "node:url";
6
7import { fixPath } from "./fixPath";
8import { createLogger } from "./logger";
9import { ProjectRegistry } from "./projectRegistry";
10import { createServer } from "./wsServer";
11
12fixPath();
13
14const DEFAULT_PORT = 3773;
15const cwd = process.cwd();
16const logger = createLogger("server");
17
18type RuntimeMode = "web" | "desktop";
19
20function 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
28function 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
37function 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
45function 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
52async 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
74function 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
99async 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
173main().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 @@
1import util from "node:util";
2
3type LogLevel = "info" | "warn" | "error" | "event";
4
5type LogContext = Record<string, unknown>;
6
7const 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
16const LEVEL_LABEL: Record<LogLevel, string> = {
17 info: "INFO",
18 warn: "WARN",
19 error: "ERROR",
20 event: "EVENT",
21};
22
23const LEVEL_COLOR: Record<LogLevel, string> = {
24 info: ANSI.cyan,
25 warn: ANSI.yellow,
26 error: ANSI.red,
27 event: ANSI.magenta,
28};
29
30function useColors() {
31 return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined;
32}
33
34function colorize(value: string, color: string, enabled: boolean) {
35 return enabled ? `${color}${value}${ANSI.reset}` : value;
36}
37
38function timeStamp() {
39 return new Date().toISOString().slice(11, 23);
40}
41
42function 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
63function 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
70function 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
88export 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 @@
1import { randomUUID } from "node:crypto";
2import fs from "node:fs";
3import path from "node:path";
4
5import {
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
16function 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
25function isDirectory(cwd: string): boolean {
26 try {
27 return fs.statSync(cwd).isDirectory();
28 } catch {
29 return false;
30 }
31}
32
33function inferProjectName(cwd: string): string {
34 const name = path.basename(cwd);
35 return name.length > 0 ? name : "project";
36}
37
38export 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 @@
1import { describe, expect, it } from "vitest";
2
3import { ProviderManager } from "./providerManager";
4
5describe("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 @@
1import { EventEmitter } from "node:events";
2import fs from "node:fs";
3import path from "node:path";
4
5import {
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";
20import { CodexAppServerManager } from "./codexAppServerManager";
21
22export interface ProviderManagerEvents {
23 event: [event: ProviderEvent];
24}
25
26export 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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { describe, expect, it, afterEach, vi } from "vitest";
6import { createServer } from "./wsServer";
7import WebSocket from "ws";
8
9import { WS_CHANNELS, WS_METHODS, type WsPush, type WsResponse } from "@acme/contracts";
10import { ProjectRegistry } from "./projectRegistry";
11
12interface PendingMessages {
13 queue: unknown[];
14 waiters: Array<(message: unknown) => void>;
15}
16
17const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();
18
19function 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
41function 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
57async 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
71describe("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 @@
1import { spawn } from "node:child_process";
2import fs from "node:fs";
3import http from "node:http";
4import os from "node:os";
5import path from "node:path";
6import type { Duplex } from "node:stream";
7
8import {
9 EDITORS,
10 WS_CHANNELS,
11 WS_METHODS,
12 type WsPush,
13 type WsRequest,
14 type WsResponse,
15 wsRequestSchema,
16} from "@acme/contracts";
17import { WebSocketServer, type WebSocket } from "ws";
18
19import { createLogger } from "./logger";
20import { ProjectRegistry } from "./projectRegistry";
21import { ProviderManager } from "./providerManager";
22
23const 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
38export 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
49function 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
57export 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 @@
1import { defineConfig } from "tsup";
2
3export 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 @@
1616 "name": "@acme/desktop",
1717 "version": "0.0.0",
1818 "dependencies": {
19 "@acme/contracts": "workspace:*",
2019 "electron": "33.4.11",
21 "node-pty": "^1.0.0",
2220 },
2321 "devDependencies": {
24 "@electron/rebuild": "^3.7.0",
2522 "@types/node": "^22.10.2",
2623 "concurrently": "^9.1.2",
2724 "electronmon": "^2.0.2",
2825 "tsup": "^8.3.5",
2926 "typescript": "^5.7.3",
27 "vitest": "^3.0.0",
3028 "wait-on": "^8.0.2",
3129 },
3230 },
......@@ -55,6 +53,26 @@
5553 "vite": "^6.0.5",
5654 },
5755 },
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 },
5876 "packages/contracts": {
5977 "name": "@acme/contracts",
6078 "version": "0.0.0",
......@@ -114,10 +132,6 @@
114132
115133 "@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=="],
116134
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
121135 "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
122136
123137 "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
......@@ -170,8 +184,6 @@
170184
171185 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
172186
173 "@gar/promisify": ["@gar/promisify@1.1.3", "", {}, "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw=="],
174
175187 "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="],
176188
177189 "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="],
......@@ -194,12 +206,6 @@
194206
195207 "@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=="],
196208
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
203209 "@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jmUfF7cNJPw57bEK7sMIqrYRgn4LH428tSgtgLTCtjuGuu1ShREyrkeB7y8HtkXRfhBs4lVY+HMLhqElJvZ6ww=="],
204210
205211 "@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 @@
320326
321327 "@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=="],
322328
323 "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="],
324
325329 "@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=="],
326330
327331 "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
......@@ -362,6 +366,8 @@
362366
363367 "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
364368
369 "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
370
365371 "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
366372
367373 "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
......@@ -382,16 +388,8 @@
382388
383389 "@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=="],
384390
385 "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="],
386
387391 "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
388392
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
395393 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
396394
397395 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
......@@ -402,38 +400,28 @@
402400
403401 "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
404402
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=="],
406404
407405 "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=="],
408406
409407 "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
410408
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
415409 "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
416410
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
419411 "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
420412
421 "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
422
423413 "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
424414
425415 "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=="],
426416
427 "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
428
429417 "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
430418
419 "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
420
431421 "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="],
432422
433423 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
434424
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
437425 "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
438426
439427 "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 @@
460448
461449 "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
462450
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
471451 "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=="],
472452
473 "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
474
475453 "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
476454
477455 "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
......@@ -484,8 +462,6 @@
484462
485463 "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
486464
487 "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
488
489465 "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=="],
490466
491467 "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
......@@ -494,8 +470,6 @@
494470
495471 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
496472
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
499473 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
500474
501475 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
......@@ -506,12 +480,16 @@
506480
507481 "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
508482
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=="],
510486
511487 "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
512488
513489 "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=="],
514490
491 "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
492
515493 "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=="],
516494
517495 "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
......@@ -534,16 +512,12 @@
534512
535513 "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
536514
537 "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
538
539515 "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
540516
541517 "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
542518
543519 "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
544520
545 "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
546
547521 "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
548522
549523 "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
......@@ -568,8 +542,6 @@
568542
569543 "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
570544
571 "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
572
573545 "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
574546
575547 "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 @@
586558
587559 "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=="],
588560
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=="],
594562
595563 "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
596564
......@@ -606,7 +574,7 @@
606574
607575 "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
608576
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=="],
610578
611579 "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=="],
612580
......@@ -642,55 +610,31 @@
642610
643611 "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
644612
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
647613 "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
648614
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
657615 "import-from": ["import-from@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ=="],
658616
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
669617 "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
670618
671 "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
672
673619 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
674620
675621 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
676622
677623 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
678624
625 "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
626
679627 "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
680628
681629 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
682630
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=="],
686632
687633 "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
688634
689635 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
690636
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=="],
694638
695639 "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
696640
......@@ -708,7 +652,7 @@
708652
709653 "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
710654
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=="],
712656
713657 "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
714658
......@@ -746,8 +690,6 @@
746690
747691 "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
748692
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
751693 "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
752694
753695 "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
......@@ -756,14 +698,12 @@
756698
757699 "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
758700
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=="],
760702
761703 "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=="],
762704
763705 "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
764706
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
767707 "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
768708
769709 "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
......@@ -862,30 +802,10 @@
862802
863803 "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
864804
865 "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
866
867805 "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
868806
869 "minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
870
871807 "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
872808
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
889809 "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=="],
890810
891811 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
......@@ -894,20 +814,8 @@
894814
895815 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
896816
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
907817 "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
908818
909 "nopt": ["nopt@6.0.0", "", { "dependencies": { "abbrev": "^1.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g=="],
910
911819 "normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="],
912820
913821 "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
......@@ -918,9 +826,7 @@
918826
919827 "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
920828
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=="],
924830
925831 "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=="],
926832
......@@ -928,14 +834,8 @@
928834
929835 "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
930836
931 "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="],
932
933837 "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=="],
934838
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
939839 "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
940840
941841 "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
......@@ -956,14 +856,8 @@
956856
957857 "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=="],
958858
959 "proc-log": ["proc-log@2.0.1", "", {}, "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw=="],
960
961859 "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
962860
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
967861 "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
968862
969863 "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
......@@ -980,10 +874,6 @@
980874
981875 "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
982876
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
987877 "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
988878
989879 "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 @@
1004894
1005895 "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
1006896
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=="],
1012898
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=="],
1014900
1015901 "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=="],
1016902
1017903 "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=="],
1018904
905 "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
906
1019907 "runtime-required": ["runtime-required@1.1.0", "", {}, "sha512-yX97f5E0WfNpcQnfVjap6vzQcvErkYYCx6eTK4siqGEdC8lglwypUFgZVTX7ShvIlgfkC4XGFl9O1KTYcff0pw=="],
1020908
1021909 "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
1022910
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
1027911 "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
1028912
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=="],
1030914
1031915 "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
1032916
1033917 "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
1034918
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
1039919 "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
1040920
1041921 "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
1042922
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
1051923 "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
1052924
1053925 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
......@@ -1056,16 +928,12 @@
1056928
1057929 "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
1058930
1059 "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="],
1060
1061931 "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
1062932
1063933 "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
1064934
1065935 "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=="],
1066936
1067 "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
1068
1069937 "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=="],
1070938
1071939 "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
......@@ -1082,12 +950,12 @@
1082950
1083951 "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
1084952
953 "t3": ["t3@workspace:apps/server"],
954
1085955 "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
1086956
1087957 "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
1088958
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
1091959 "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
1092960
1093961 "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 @@
1118986
1119987 "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=="],
1120988
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
1121991 "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=="],
1122992
1123993 "turbo-darwin-64": ["turbo-darwin-64@2.8.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw=="],
......@@ -1142,10 +1012,6 @@
11421012
11431013 "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=="],
11441014
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
11491015 "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=="],
11501016
11511017 "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
......@@ -1158,14 +1024,12 @@
11581024
11591025 "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=="],
11601026
1161 "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
1027 "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
11621028
11631029 "unixify": ["unixify@1.0.0", "", { "dependencies": { "normalize-path": "^2.1.1" } }, "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg=="],
11641030
11651031 "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=="],
11661032
1167 "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
1168
11691033 "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
11701034
11711035 "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 @@
11801044
11811045 "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=="],
11821046
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
11871047 "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=="],
11881048
11891049 "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=="],
11901050
11911051 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
11921052
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
11931057 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
11941058
1195 "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
1059 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
11961060
11971061 "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=="],
11981062
......@@ -1206,16 +1070,6 @@
12061070
12071071 "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
12081072
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
12191073 "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
12201074
12211075 "@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 @@
12301084
12311085 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
12321086
1233 "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
1234
12351087 "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
12361088
12371089 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
......@@ -1240,46 +1092,20 @@
12401092
12411093 "electronmon/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="],
12421094
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=="],
12461096
12471097 "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
12481098
12491099 "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
12501100
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
12631101 "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
12641102
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
12691103 "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=="],
12701104
12711105 "vitest/tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
12721106
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
12791107 "electronmon/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
12801108
1281 "rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
1282
12831109 "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
12841110
12851111 "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
......@@ -1331,7 +1157,5 @@
13311157 "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
13321158
13331159 "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=="],
13361160 }
13371161}
package.json+8-2
......@@ -6,13 +6,19 @@
66 "packages/*"
77 ],
88 "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",
1015 "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",
1117 "typecheck": "turbo run typecheck",
1218 "lint": "oxlint --report-unused-disable-directives",
1319 "test": "turbo run test",
20 "test:desktop-smoke": "bun run --cwd apps/desktop smoke-test",
1421 "fmt": "oxfmt",
15 "smoke-test": "turbo run smoke-test",
1622 "build:contracts": "bun run --cwd packages/contracts build"
1723 },
1824 "devDependencies": {
packages/contracts/src/index.ts+2
......@@ -4,3 +4,5 @@ export * from "./agent";
44export * from "./terminal";
55export * from "./provider";
66export * from "./model";
7export * from "./ws";
8export * from "./project";
packages/contracts/src/ipc.ts+11-22
......@@ -9,6 +9,12 @@ import type {
99 ProviderStopSessionInput,
1010 ProviderTurnStartResult,
1111} from "./provider";
12import type {
13 ProjectAddInput,
14 ProjectAddResult,
15 ProjectListResult,
16 ProjectRemoveInput,
17} from "./project";
1218import type { TerminalCommandInput, TerminalCommandResult } from "./terminal";
1319import type { NewTodoInput, Todo } from "./todo";
1420
......@@ -19,28 +25,6 @@ export const EDITORS = [
1925
2026export type EditorId = (typeof EDITORS)[number]["id"];
2127
22export 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
4428export interface NativeApi {
4529 todos: {
4630 list: () => Promise<Todo[]>;
......@@ -70,6 +54,11 @@ export interface NativeApi {
7054 listSessions: () => Promise<ProviderSession[]>;
7155 onEvent: (callback: (event: ProviderEvent) => void) => () => void;
7256 };
57 projects: {
58 list: () => Promise<ProjectListResult>;
59 add: (input: ProjectAddInput) => Promise<ProjectAddResult>;
60 remove: (input: ProjectRemoveInput) => Promise<void>;
61 };
7362 shell: {
7463 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
7564 };
packages/contracts/src/project.test.ts created+49
......@@ -0,0 +1,49 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 projectAddInputSchema,
5 projectAddResultSchema,
6 projectListResultSchema,
7 projectRemoveInputSchema,
8} from "./project";
9
10describe("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 @@
1import { z } from "zod";
2
3export 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
11export const projectListResultSchema = z.array(projectRecordSchema);
12
13export const projectAddInputSchema = z.object({
14 cwd: z.string().trim().min(1),
15});
16
17export const projectAddResultSchema = z.object({
18 project: projectRecordSchema,
19 created: z.boolean(),
20});
21
22export const projectRemoveInputSchema = z.object({
23 id: z.string().min(1),
24});
25
26export type ProjectRecord = z.infer<typeof projectRecordSchema>;
27export type ProjectListResult = z.infer<typeof projectListResultSchema>;
28export type ProjectAddInput = z.input<typeof projectAddInputSchema>;
29export type ProjectAddResult = z.infer<typeof projectAddResultSchema>;
30export type ProjectRemoveInput = z.input<typeof projectRemoveInputSchema>;
packages/contracts/src/ws.ts created+78
......@@ -0,0 +1,78 @@
1import { z } from "zod";
2
3// ── WebSocket RPC Method Names ───────────────────────────────────────
4
5export 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
28export const WS_CHANNELS = {
29 providerEvent: "providers.event",
30 serverWelcome: "server.welcome",
31} as const;
32
33// ── Client → Server (request) ────────────────────────────────────────
34
35export const wsRequestSchema = z.object({
36 id: z.string().min(1),
37 method: z.string().min(1),
38 params: z.unknown().optional(),
39});
40
41export type WsRequest = z.infer<typeof wsRequestSchema>;
42
43// ── Server → Client (response to request) ────────────────────────────
44
45export 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
55export type WsResponse = z.infer<typeof wsResponseSchema>;
56
57// ── Server → Client (push event) ─────────────────────────────────────
58
59export const wsPushSchema = z.object({
60 type: z.literal("push"),
61 channel: z.string().min(1),
62 data: z.unknown(),
63});
64
65export type WsPush = z.infer<typeof wsPushSchema>;
66
67// ── Union of all server → client messages ─────────────────────────────
68
69export type WsServerMessage = WsResponse | WsPush;
70
71// ── Server welcome payload ───────────────────────────────────────────
72
73export const wsWelcomePayloadSchema = z.object({
74 cwd: z.string().min(1),
75 projectName: z.string().min(1),
76});
77
78export type WsWelcomePayload = z.infer<typeof wsWelcomePayloadSchema>;
turbo.json+13-6
......@@ -1,12 +1,24 @@
11{
22 "$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 ],
415 "tasks": {
516 "build": {
617 "dependsOn": ["^build"],
718 "outputs": ["dist/**", "dist-electron/**"]
819 },
920 "dev": {
21 "dependsOn": ["@acme/contracts#build"],
1022 "cache": false,
1123 "persistent": true
1224 },
......@@ -17,11 +29,6 @@
1729 "test": {
1830 "dependsOn": ["^build"],
1931 "outputs": []
20 },
21 "smoke-test": {
22 "dependsOn": ["build"],
23 "cache": false,
24 "outputs": []
2532 }
2633 }
2734}