group conversations with models and local files
0

Configure Feed

Select the types of activity you want to include in your feed.

Port Centrosome: Electron + Automerge + iCloud → Web + SQLite + WebSocket

The Electron version (initial commit) shipped feature-complete but its
invite/discovery flow was fundamentally broken — adding a userId to the
project doc was meaningless to the invitee until someone manually shared
the iCloud folder with them in Finder. This port replaces the whole sync
substrate with a small server-backed web app:

* Per-project SQLite: /data/projects/<id>.sqlite (FS-level isolation)
* Global SQLite: /data/global.sqlite (users / sessions / invites)
* Fastify + ws gateway (TypeScript, better-sqlite3)
* React frontend (mostly unchanged components) over fetch + WebSocket
* Anthropic API direct from browser using user-supplied key (IndexedDB)
* Invites: owner generates URL, shares out of band

Prototype-grade trade-offs (see PORT-PLAN.md):
* Plaintext API key in IndexedDB (acceptable trust boundary; same as
a logged-in browser is to a local attacker)
* Auth is email-only with no verification (session cookie)
* No Automerge — server is authoritative writer; concurrent file edits
use LWW with the existing "peer updated, reset to current?" warning
* 10 MB cap on binary uploads; 256 KB on text files
* 400k char (~100k token) budget on the system-prompt file context

What stays from the Electron build: every renderer component
(ProjectList, ProjectView, ConversationView with branching DAG, FileViewer
with rename + follow-live + binary preview, InviteControl, MemberList with
presence dots, etc.), the agent loop, tool defs (update_main_file,
create_file, update_file, patch_file), buildSystemPrompt with token
budget, auto-name on first reply, dark-mode theme tokens.

What got deleted: Electron, electron-builder, electron-vite, Automerge,
keytar, chokidar; all of src/main and src/preload; iCloud sync logic and
file-per-actor scheme; conflict-copy reconciliation; presence-via-fs
heartbeat; native-module rebuild dance.

+6361 -3934
+4
.gitignore
··· 1 1 node_modules 2 2 out 3 3 dist 4 + dist-web 5 + dist-server 6 + release 7 + data 4 8 .DS_Store 5 9 *.log 6 10 *.tsbuildinfo
+457
PORT-PLAN.md
··· 1 + # Centrosome port plan — Electron + Automerge + iCloud → Web + SQLite + WebSocket 2 + 3 + > Working doc. Replaces TODO.md (which is a snapshot of the pre-port Electron 4 + > version). Once the port is done, fold lessons learned back into a fresh TODO. 5 + 6 + ## Why 7 + 8 + The Electron + Automerge + iCloud architecture worked as an architectural 9 + experiment but the invite/discovery flow is fundamentally broken: a userId 10 + in the project doc is meaningless to the invitee until someone shares the 11 + iCloud folder with them in Finder. We were polishing UI on top of a hole. 12 + 13 + Other forcing factors: 14 + 15 + - Distribution pain (DMG, signing, notarization, native-module rebuilds per arch). 16 + - Mac-only with no realistic Windows/Linux/mobile/browser path. 17 + - iCloud sync latency (seconds to minutes) makes "real-time" aspirational. 18 + - Membership is cosmetic — anyone with bytes can read the Automerge doc. 19 + - Schema migrations are ad-hoc (we already had to backfill `files`/`mainFileId`). 20 + 21 + Web app on Railway + PWA wrapper gives us cross-platform, real-time 22 + WebSocket sync, sane invites (URL shared out of band), and a much smaller 23 + moving-parts surface. 24 + 25 + ## Decided 26 + 27 + 1. **API key storage**: plaintext in IndexedDB. Same trust boundary as 28 + today's Keychain on a logged-in Mac. Passphrase-encryption is "more 29 + correct" but not worth the friction in prototype. 30 + 2. **Invites**: owner generates a URL, shares out-of-band themselves. No 31 + email integration. 32 + 3. **Anthropic calls**: direct from browser using the user's stored key. 33 + Server never sees or proxies it. 34 + 4. **No Automerge**. Server is the authoritative writer. Concurrent edits 35 + use LWW with the existing "peer updated, reset to current?" warning. 36 + 5. **Rewrite in place** on `main`. Old commit is in git history; nothing 37 + gets branched. 38 + 39 + ## Target architecture 40 + 41 + ``` 42 + ┌─────────────────────────── browser (PWA) ───────────────────────────┐ 43 + │ React UI (most of current src/renderer/src/) │ 44 + │ ↳ Anthropic SDK call w/ user key (IndexedDB, plaintext v1) │ 45 + │ ↳ WebSocket to server for sync + persistence │ 46 + │ ↳ HTTP for auth + project list + blob download │ 47 + │ ↳ Service worker for offline app shell + recent-data cache │ 48 + └─────────────────────────────────────────────────────────────────────┘ 49 + ▲ WebSocket + REST 50 + 51 + ┌─────────────────────────── server (Node) ───────────────────────────┐ 52 + │ Fastify (HTTP + static) │ 53 + │ ws (WebSocket gateway, one project per subscription) │ 54 + │ better-sqlite3 (sync API, per-project DB connection pool) │ 55 + │ Static frontend served from same process │ 56 + └─────────────────────────────────────────────────────────────────────┘ 57 + ▲ filesystem 58 + 59 + ┌────────────────────────────── /data ────────────────────────────────┐ 60 + │ global.sqlite users, sessions, invite tokens, projects │ 61 + │ projects/<id>.sqlite one DB per project (FS-level isolation) │ 62 + │ projects/<id>.blobs/ binary uploads, content-addressed sha256 │ 63 + └─────────────────────────────────────────────────────────────────────┘ 64 + ``` 65 + 66 + ## Stack 67 + 68 + - **Server**: Node 20+, TypeScript, Fastify, `@fastify/websocket` (or raw 69 + `ws`), `better-sqlite3`. Synchronous SQLite is fine — single process, 70 + per-project locking is naturally serialized via the connection. 71 + - **Frontend**: Same React 18 + ReactMarkdown + remark-gfm + 72 + react-resizable-panels + Vite that we have today. Add `vite-plugin-pwa` 73 + for the service worker / manifest. 74 + - **Anthropic SDK**: `@anthropic-ai/sdk` works in the browser since v0.20+. 75 + Use `dangerouslyAllowBrowser: true`. 76 + - **Deploy**: Railway. Single service. Volume mounted at `/data`. 77 + 78 + ## Filesystem layout 79 + 80 + ``` 81 + /data/ 82 + global.sqlite 83 + projects/ 84 + <projectId>.sqlite 85 + <projectId>.blobs/ 86 + <sha256> 87 + <sha256> 88 + ... 89 + ``` 90 + 91 + One SQLite per project means: 92 + 93 + - Backup / restore / delete a project = move one file (+ its blobs dir). 94 + - Cross-project queries are impossible without explicit joining — that's 95 + the point of the isolation. 96 + - WAL mode per project; concurrency only an issue within a project, and 97 + better-sqlite3 serializes naturally. 98 + - Cap on DB count: 50–100 active projects fine. Beyond that we'd want a 99 + connection pool with LRU eviction; not needed for prototype. 100 + 101 + ## Schemas 102 + 103 + ### `global.sqlite` 104 + 105 + ```sql 106 + PRAGMA journal_mode=WAL; 107 + PRAGMA foreign_keys=ON; 108 + 109 + CREATE TABLE users ( 110 + id TEXT PRIMARY KEY, -- sha256(email).slice(0,16) — same as today 111 + email TEXT UNIQUE NOT NULL, 112 + display_name TEXT NOT NULL, 113 + created_at INTEGER NOT NULL 114 + ); 115 + 116 + CREATE TABLE projects ( 117 + id TEXT PRIMARY KEY, 118 + name TEXT NOT NULL, 119 + created_at INTEGER NOT NULL, 120 + created_by TEXT NOT NULL REFERENCES users(id) 121 + ); 122 + 123 + CREATE TABLE project_members ( 124 + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, 125 + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 126 + role TEXT NOT NULL CHECK (role IN ('owner', 'member')), 127 + joined_at INTEGER NOT NULL, 128 + PRIMARY KEY (project_id, user_id) 129 + ); 130 + 131 + -- Invite tokens. One-shot URL the owner sends out of band. 132 + CREATE TABLE invites ( 133 + token TEXT PRIMARY KEY, -- 32 url-safe bytes 134 + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, 135 + created_at INTEGER NOT NULL, 136 + created_by TEXT NOT NULL REFERENCES users(id), 137 + used_by TEXT REFERENCES users(id), -- null until claimed 138 + used_at INTEGER 139 + ); 140 + 141 + CREATE TABLE sessions ( 142 + token TEXT PRIMARY KEY, -- bearer cookie 143 + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 144 + created_at INTEGER NOT NULL, 145 + last_seen INTEGER NOT NULL 146 + ); 147 + ``` 148 + 149 + ### `<projectId>.sqlite` (per project) 150 + 151 + ```sql 152 + PRAGMA journal_mode=WAL; 153 + PRAGMA foreign_keys=ON; 154 + 155 + CREATE TABLE meta ( 156 + k TEXT PRIMARY KEY, 157 + v TEXT NOT NULL 158 + ); 159 + -- rows: 'name', 'description', 'system_prompt', 'default_model', 'main_file_id' 160 + 161 + CREATE TABLE files ( 162 + id TEXT PRIMARY KEY, 163 + name TEXT NOT NULL, 164 + content TEXT NOT NULL DEFAULT '', 165 + binary_sha256 TEXT, -- null for text files 166 + binary_mime TEXT, 167 + binary_size INTEGER, 168 + created_at INTEGER NOT NULL, 169 + created_by TEXT NOT NULL, 170 + updated_at INTEGER NOT NULL, 171 + updated_by TEXT NOT NULL 172 + ); 173 + 174 + CREATE TABLE file_history ( 175 + file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, 176 + ver INTEGER NOT NULL, 177 + content TEXT NOT NULL, 178 + updated_at INTEGER NOT NULL, 179 + updated_by TEXT NOT NULL, 180 + PRIMARY KEY (file_id, ver) 181 + ); 182 + 183 + CREATE TABLE conversations ( 184 + id TEXT PRIMARY KEY, 185 + title TEXT NOT NULL DEFAULT 'Untitled', 186 + archived INTEGER NOT NULL DEFAULT 0, 187 + created_at INTEGER NOT NULL, 188 + created_by TEXT NOT NULL 189 + ); 190 + 191 + CREATE TABLE turns ( 192 + id TEXT PRIMARY KEY, 193 + conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, 194 + parent_id TEXT REFERENCES turns(id), 195 + author TEXT NOT NULL, 196 + role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), 197 + created_at INTEGER NOT NULL, 198 + text TEXT NOT NULL DEFAULT '', 199 + status TEXT CHECK (status IS NULL OR status IN ('streaming', 'complete', 'error')), 200 + model_id TEXT, 201 + input_tokens INTEGER, 202 + output_tokens INTEGER, 203 + paid_by TEXT 204 + ); 205 + CREATE INDEX idx_turns_conv ON turns(conv_id, created_at); 206 + CREATE INDEX idx_turns_parent ON turns(parent_id); 207 + 208 + -- Mirror of members from global.sqlite, kept in sync so single-DB reads 209 + -- can render the sidebar without cross-DB joins. 210 + CREATE TABLE members ( 211 + user_id TEXT PRIMARY KEY, 212 + email TEXT NOT NULL, 213 + display_name TEXT NOT NULL, 214 + role TEXT NOT NULL, 215 + joined_at INTEGER NOT NULL, 216 + last_seen INTEGER NOT NULL DEFAULT 0 217 + ); 218 + ``` 219 + 220 + ## Wire protocol 221 + 222 + ### HTTP 223 + 224 + ``` 225 + POST /auth/login { email } → { sessionToken } (no real verification in v1 — same trust boundary as desktop) 226 + POST /auth/logout → {} 227 + GET /auth/me → { user } 228 + 229 + GET /projects → [ { id, name, role } ] 230 + POST /projects { name } → { project } 231 + DELETE /projects/:id → {} 232 + 233 + POST /projects/:id/invites → { token, url } 234 + POST /invites/:token/accept → { project } 235 + 236 + GET /projects/:id/blobs/:sha256 → bytes 237 + POST /projects/:id/blobs multipart → { sha256, sizeBytes, mimeType } 238 + ``` 239 + 240 + ### WebSocket (`/ws`, bearer-authenticated via cookie) 241 + 242 + Inbound (client → server): 243 + ``` 244 + { type: 'subscribe', projectId } 245 + { type: 'unsubscribe', projectId } 246 + 247 + { type: 'conversation:create', title } 248 + { type: 'turn:create', convId, parentId, role, text, id?, status? } 249 + { type: 'turn:update', turnId, fields: { text?, status?, ... } } 250 + 251 + { type: 'file:create', name, content } 252 + { type: 'file:update', fileId, fields: { name?, content? } } 253 + { type: 'file:patch', fileId, search, replace } 254 + { type: 'file:delete', fileId } 255 + { type: 'main:set', fileId } 256 + 257 + { type: 'presence', status } // heartbeat 258 + ``` 259 + 260 + Outbound (server → all subscribers of a project): 261 + ``` 262 + { type: 'snapshot', project, files, conversations, members } // on subscribe 263 + { type: 'conversation', conversation } 264 + { type: 'turn', turn } // create or update; client merges 265 + { type: 'file', file } // create or update 266 + { type: 'file:deleted', fileId } 267 + { type: 'main', fileId } 268 + { type: 'member', member } 269 + { type: 'presence', userId, status, ts } 270 + ``` 271 + 272 + Server is authoritative — every mutation goes through it. Client applies 273 + optimistic state locally and reconciles when the server's broadcast comes 274 + back. 275 + 276 + ### Auth 277 + 278 + Phase 1 (prototype): user types their email, we hand back a session cookie 279 + with no verification. Same trust model as today's desktop app — we trust 280 + the device. Document as a known weakness. 281 + 282 + Phase 2 (later): magic-link email via Resend / Postmark, or sign-in-with- 283 + Google. Not in scope for the port. 284 + 285 + ### Invites 286 + 287 + Owner clicks "+ Invite" → server generates a 32-byte URL-safe token, stores 288 + it in `invites`, returns `{ url: https://centrosome.example/invite/<token> }`. 289 + Owner copies the URL, sends out of band (Slack, email, signal, whatever). 290 + 291 + Invitee opens the URL → if not logged in, prompted for email → on accept, 292 + server inserts a `project_members` row + (if needed) a `users` row + a 293 + `members` row in the project's SQLite. Returns the project. 294 + 295 + Tokens are one-shot (after `used_at` is set, can't be re-used). 296 + 297 + ## Streaming model 298 + 299 + 1. Browser holds the user's API key in memory (decrypted from IndexedDB on 300 + load). 301 + 2. User types a message. Browser sends `turn:create` for the user turn with 302 + a client-generated UUID. Server persists, broadcasts. 303 + 3. Browser sends `turn:create` for an assistant placeholder with 304 + `status: 'streaming'`. Server persists, broadcasts. 305 + 4. Browser calls Anthropic's `messages.stream()`. 306 + 5. Every ~250ms, browser sends `turn:update` with the current snapshot text. 307 + Server persists, broadcasts to peers. Originator skips applying its own 308 + broadcast (echo suppression by sender id). 309 + 6. On stream final, browser sends `turn:update` with the final text, 310 + `status: 'complete'`, token counts. Server persists, broadcasts. 311 + 7. Tool calls: same loop as today, but `file:update` / `file:create` / 312 + `file:patch` flow through WebSocket; the model's tool_use is executed 313 + in the browser by sending those messages and waiting for the server 314 + ack before continuing the agent loop. 315 + 316 + ## What stays from current repo 317 + 318 + - `src/renderer/src/App.tsx` — most of it, with IPC calls swapped for 319 + fetch + WebSocket. The component tree (Login, ProjectList, ProjectView, 320 + ConversationView, FileViewer, BinaryFileViewer, HistoryList, BranchNav, 321 + InviteControl, MemberList, PaneShell, SidebarSectionHeader, FileList, 322 + ProjectHeader) is reusable. 323 + - `src/renderer/src/main.tsx`. 324 + - `src/renderer/index.html` — minor edits (drop the CSP warning comments, 325 + add PWA manifest link). 326 + - `src/shared/schema.ts` — light edits: drop Automerge-specific notes, 327 + the types stay almost identical to the SQL rows. 328 + - The agent loop and tool defs — currently in `src/main/claudeApi.ts` and 329 + `src/main/index.ts` (`PROJECT_TOOLS`, `handleToolUse`, `buildSystemPrompt`, 330 + `maybeAutoNameConversation`, `findLatestLeaf`). All of this moves into 331 + the browser unchanged. 332 + - The `.mc-md` styles, CSS theme tokens, dark-mode media query. 333 + 334 + ## What gets deleted 335 + 336 + - `src/main/` entirely (index.ts, sync.ts, keychain.ts, claudeApi.ts gets 337 + relocated) 338 + - `src/preload/` 339 + - `electron.vite.config.ts` 340 + - `package.json` deps: `electron`, `electron-builder`, `electron-vite`, 341 + `keytar`, `chokidar`, `@automerge/automerge` 342 + - `package.json` scripts: `package`, `rebuild-native` 343 + - `release/` 344 + - `build/` (electron-builder buildResources) 345 + - `TODO.md` — replaced by this file 346 + 347 + ## New layout 348 + 349 + ``` 350 + centrosome/ 351 + ├── server/ 352 + │ ├── index.ts server entry 353 + │ ├── db.ts better-sqlite3 helpers, per-project pool 354 + │ ├── auth.ts session middleware 355 + │ ├── routes/ 356 + │ │ ├── auth.ts 357 + │ │ ├── projects.ts 358 + │ │ ├── invites.ts 359 + │ │ └── blobs.ts 360 + │ ├── ws.ts WebSocket gateway + project subscriptions 361 + │ ├── schemas/ 362 + │ │ ├── global.sql 363 + │ │ └── project.sql 364 + │ └── tsconfig.json 365 + ├── src/ (renamed from src/renderer) 366 + │ ├── App.tsx ↻ ported 367 + │ ├── main.tsx ↻ 368 + │ ├── index.html ↻ 369 + │ ├── shared/schema.ts ↻ 370 + │ ├── lib/ 371 + │ │ ├── api.ts fetch + websocket client (replaces window.mc) 372 + │ │ ├── keystore.ts IndexedDB API-key storage 373 + │ │ ├── claudeAgent.ts ported from src/main/claudeApi.ts 374 + │ │ └── systemPrompt.ts ported buildSystemPrompt + PROJECT_TOOLS 375 + │ └── pwa/ 376 + │ ├── manifest.webmanifest 377 + │ └── icons/... 378 + ├── vite.config.ts web-only 379 + ├── tsconfig.json 380 + ├── tsconfig.node.json (server) 381 + ├── tsconfig.web.json (browser) 382 + ├── package.json new scripts: dev / build / serve 383 + └── Dockerfile for Railway 384 + ``` 385 + 386 + ## Phased work plan 387 + 388 + ### Phase 1 — backbone (½ day) 389 + 390 + - Blow away `src/main/`, `src/preload/`, `electron.vite.config.ts`, 391 + electron deps. 392 + - `server/` scaffold: Fastify + static + WebSocket + better-sqlite3. 393 + - `vite.config.ts` for browser build (no electron-vite). 394 + - `global.sqlite` bootstrap on first server start. 395 + - Trivial auth: `POST /auth/login { email }` returns a session cookie. 396 + No verification. 397 + - Dev script: server on :3000 serves the vite-built static assets in 398 + production, proxies to vite dev server on :5173 in dev mode. 399 + 400 + ### Phase 2 — CRUD over WS (1 day) 401 + 402 + - Project create/list/open. Per-project SQLite materializes on first open. 403 + - Subscribe/unsubscribe protocol. On subscribe, server pushes snapshot 404 + (conversations + files + members). 405 + - Conversation create. 406 + - Turn create + update. Including the streaming-update path with rate 407 + limiting to ~10 writes/sec per turn. 408 + - File create/update/delete/patch + binary upload via HTTP POST. 409 + - Invite endpoint + accept flow. 410 + 411 + ### Phase 3 — frontend port (1 day) 412 + 413 + - Move `src/renderer/src/*` → `src/*`. 414 + - Replace `window.mc.*` with new `api` module. 415 + - Build the WebSocket client with a Zustand-or-similar store; renderer 416 + components stay as-is. 417 + - Browser-side API key: simple IndexedDB store, plaintext. 418 + - Port the agent loop into the browser. Anthropic SDK with 419 + `dangerouslyAllowBrowser: true`. 420 + - Tool calls fan out via WebSocket; await server ack before continuing 421 + the agent round. 422 + 423 + ### Phase 4 — PWA (½ day) 424 + 425 + - `vite-plugin-pwa` for service worker + manifest. 426 + - Offline app shell (static assets). 427 + - Read-only offline: cached last snapshot in IndexedDB renders if the 428 + WebSocket can't connect. 429 + 430 + ### Phase 5 — packaging + Railway (½ day) 431 + 432 + - `Dockerfile`: multistage — build frontend, build server, copy artifacts. 433 + - Volume at `/data`. Health check at `/health`. 434 + - Railway config: single service, volume, custom domain. 435 + 436 + ### Phase 6 (optional) — migrate the Greece iCloud project 437 + 438 + - One-off script: read the Automerge file, walk the doc, INSERT rows 439 + into a new per-project SQLite, copy blobs. 440 + - Throwaway code, not part of the runtime. 441 + 442 + Total: ~3–4 focused days. 443 + 444 + ## Open questions / known unknowns 445 + 446 + - **Auth phase 2.** When do we add real email verification? Probably not 447 + until there's a non-trivial user count. 448 + - **Rate limiting / abuse.** Public Railway URL with anyone able to 449 + register? Need at least an allow-list or invite-only signup. 450 + - **Per-project DB count.** Need a connection pool eventually. LRU 451 + eviction with idle timeout. Not for prototype. 452 + - **Migration story for schema changes.** Per-project DBs mean each one 453 + needs migrations applied lazily on first open. Pattern: a `schema_version` 454 + meta row + an ordered migration script list applied on `openProjectDb()`. 455 + - **PWA install on iOS.** Real but quirky. Verify it works once we ship. 456 + - **Service worker invalidation.** Vite-plugin-pwa handles this but worth 457 + smoke-testing.
-131
TODO.md
··· 1 - # TODO 2 - 3 - Known gaps, ordered roughly by how visible/blocking each is. None are required 4 - to make the app work for a small group, but each will eventually bite. 5 - 6 - ## UX gaps 7 - 8 - ### Branching UI 9 - The conversation is a DAG under the hood (edits and concurrent replies create 10 - sibling turns) but the renderer only shows the linear chain to the most recent 11 - leaf. Sibling branches exist in the data and are silently invisible. 12 - 13 - - Show a `↓ 2 branches` affordance under turns with multiple children. 14 - - Let the user switch between branches (and persist the choice per 15 - conversation, not globally). 16 - 17 - ### Presence indicators 18 - No "alice is typing…" / "bob is viewing main.md" / per-cursor presence. 19 - Concurrent edits already work; this is just awareness. 20 - 21 - - Drop ephemeral `presence/<userId>.json` files in the project dir with a 22 - short TTL. Don't put presence in the Automerge doc — it churns too fast. 23 - 24 - ### File rename UI 25 - Files can be renamed via the agent's `update_file` tool *only* if we extend 26 - its schema, or via delete+recreate. There's no UI for it. 27 - 28 - - Inline editable filename in the right-pane header. 29 - - Add `file:rename` IPC + preload binding (rename doesn't need agent tool 30 - changes since the model can be told the new name via system prompt next 31 - turn). 32 - 33 - ### Streaming view in the file editor 34 - While you're previewing a file (not editing it), peer writes show up 35 - immediately — good. While you *are* editing it, the existing stale-write 36 - warning fires but the textarea doesn't update. Sometimes you want the live 37 - view of an agent rewriting the file as it streams. 38 - 39 - - Add a "follow live" toggle in the editor. 40 - 41 - ## Robustness / scale 42 - 43 - ### Token-budget guard 44 - Every API call ships the full content of every project file in the system 45 - prompt. A 256 KB upload + a few smaller files easily blows past comfortable 46 - context sizes and burns money silently. 47 - 48 - - Estimate tokens (rough: chars/4) and either truncate the tail of files past 49 - a budget, or skip files entirely with a `[file omitted: too large for 50 - context]` note. 51 - - Surface "context: 12k tokens, 4 files included" in the UI so the cost is 52 - visible. 53 - 54 - ### Orphan streaming turns 55 - If a client closes the window mid-stream, the assistant turn stays at 56 - `status: 'streaming'` in the saved doc forever. Other clients see a 57 - permanently-blinking cursor. 58 - 59 - - On `project:open`, scan turns where `status === 'streaming'` and 60 - `author === currentUserId`, mark them as `error` with a note. (Only sweep 61 - *our own* orphans, since a peer's might still be streaming on their machine.) 62 - 63 - ### Conflict-copy reconciliation 64 - If iCloud Drive produces conflict copies (`doc.alice 2.automerge`) despite the 65 - per-actor scheme, we never read them. Rare in practice but possible if iCloud 66 - ever loses the rename-on-write ordering. 67 - 68 - - Periodic sweeper that globs for `doc.* [0-9]*.automerge`, merges them in, 69 - and deletes the dupes. 70 - 71 - ### Membership-check cost 72 - `listProjects` loads + merges every Automerge doc in every project folder on 73 - every call. Fine for <10 projects; sluggish past 50. 74 - 75 - - Cache the membership result keyed by the max mtime of the doc files in the 76 - project folder. Invalidate when an mtime changes. 77 - 78 - ### Full-doc save per stream tick 79 - During streaming we call `Automerge.save(this.doc)` every 250 ms and write the 80 - whole encoded doc to disk. For long-running projects (lots of conversation 81 - history), this scales linearly with total content. 82 - 83 - - Switch to `Automerge.saveIncremental` and a separate changelog file. Other 84 - clients merge changelogs. Standard pattern but a bit more code. 85 - 86 - ## Schema / data model 87 - 88 - ### Schema migration story 89 - The recent `files` / `mainFileId` addition needed a backfill in `ProjectSync.init` 90 - because older docs lacked the fields. That works ad-hoc but doesn't scale. 91 - 92 - - Settle on an approach: either always-on backfill from `seed` for every 93 - top-level field (current approach), or a versioned schema with explicit 94 - migration steps. 95 - 96 - ### `Automerge.Text` for file content 97 - File content is a plain string with LWW semantics. Two users editing the same 98 - file at the same time → one of them silently loses their changes (the 99 - stale-write warning helps but doesn't merge). 100 - 101 - - Upgrade `ProjectFile.content` to `Automerge.Text` for character-level 102 - collaborative editing. Renderer would need a small adapter to read it as a 103 - string. 104 - 105 - ### Binary file uploads 106 - Uploads currently read as UTF-8 text only (256 KB cap, fails on binaries). 107 - Storing binaries in the Automerge doc is a bad idea — they belong in 108 - `blobs/<sha256>` with a reference from the doc. 109 - 110 - - New `BinaryFile` variant pointing to a blob; UI shows a download/preview 111 - link, not text content. 112 - 113 - ## Agent tooling 114 - 115 - ### Patch-based file edits 116 - `update_main_file` / `update_file` require the model to pass the COMPLETE new 117 - content. For a 5 KB main file that's ~1.5k tokens per edit. Manageable for 118 - now, expensive at scale. 119 - 120 - - Add a `patch_file` tool that takes a unified-diff or 121 - search/replace block. Match Claude Code's behavior so the model already 122 - knows the pattern. 123 - 124 - ### Tool-call audit trail 125 - The italic `_✓ Updated main.md_` annotation is friendly but lossy — you can't 126 - see *what* changed. Especially with `update_main_file` (whole-file replace), 127 - the prior content is gone from view. 128 - 129 - - Keep prior content reachable: write a short JSON entry into a `history/` 130 - sub-array on the file, or stash the pre-image as a blob keyed by the 131 - tool_use id.
-31
electron.vite.config.ts
··· 1 - import { defineConfig, externalizeDepsPlugin } from 'electron-vite' 2 - import react from '@vitejs/plugin-react' 3 - import { resolve } from 'path' 4 - 5 - export default defineConfig({ 6 - main: { 7 - plugins: [externalizeDepsPlugin()], 8 - build: { 9 - rollupOptions: { 10 - input: { index: resolve(__dirname, 'src/main/index.ts') }, 11 - }, 12 - }, 13 - }, 14 - preload: { 15 - plugins: [externalizeDepsPlugin()], 16 - build: { 17 - rollupOptions: { 18 - input: { index: resolve(__dirname, 'src/preload/index.ts') }, 19 - }, 20 - }, 21 - }, 22 - renderer: { 23 - root: resolve(__dirname, 'src/renderer'), 24 - plugins: [react()], 25 - build: { 26 - rollupOptions: { 27 - input: { index: resolve(__dirname, 'src/renderer/index.html') }, 28 - }, 29 - }, 30 - }, 31 - })
+1922 -864
package-lock.json
··· 1 1 { 2 - "name": "multiclaude", 3 - "version": "0.0.1", 2 + "name": "centrosome", 3 + "version": "0.1.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 - "name": "multiclaude", 9 - "version": "0.0.1", 8 + "name": "centrosome", 9 + "version": "0.1.0", 10 10 "dependencies": { 11 11 "@anthropic-ai/sdk": "^0.27.0", 12 - "@automerge/automerge": "^2.2.8", 13 - "chokidar": "^3.6.0", 14 - "keytar": "^7.9.0", 12 + "@fastify/cookie": "^9.4.0", 13 + "@fastify/static": "^7.0.4", 14 + "@fastify/websocket": "^10.0.1", 15 + "better-sqlite3": "^11.3.0", 16 + "fastify": "^4.28.1", 15 17 "react-markdown": "^10.1.0", 16 18 "react-resizable-panels": "^2.1.9", 17 - "remark-gfm": "^4.0.1" 19 + "remark-gfm": "^4.0.1", 20 + "zod": "^3.23.8" 18 21 }, 19 22 "devDependencies": { 23 + "@types/better-sqlite3": "^7.6.11", 20 24 "@types/node": "^20.12.0", 21 25 "@types/react": "^18.3.0", 22 26 "@types/react-dom": "^18.3.0", 27 + "@types/ws": "^8.18.1", 23 28 "@vitejs/plugin-react": "^4.3.0", 24 - "electron": "^31.0.0", 25 - "electron-vite": "^2.3.0", 29 + "concurrently": "^9.0.1", 26 30 "react": "^18.3.0", 27 31 "react-dom": "^18.3.0", 32 + "tsx": "^4.19.1", 28 33 "typescript": "^5.4.0", 29 34 "vite": "^5.3.0" 30 35 } ··· 58 63 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 59 64 "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", 60 65 "license": "MIT" 61 - }, 62 - "node_modules/@automerge/automerge": { 63 - "version": "2.2.9", 64 - "resolved": "https://registry.npmjs.org/@automerge/automerge/-/automerge-2.2.9.tgz", 65 - "integrity": "sha512-6HM52Ops79hAQBWMg/t0MNfGOdEiXyenQjO9F1hKZq0RWDsMLpPa1SzRy/C4/4UyX67sTHuA5CwBpH34SpfZlA==", 66 - "license": "MIT", 67 - "dependencies": { 68 - "uuid": "^9.0.0" 69 - } 70 66 }, 71 67 "node_modules/@babel/code-frame": { 72 68 "version": "7.29.0", ··· 270 266 "node": ">=6.0.0" 271 267 } 272 268 }, 273 - "node_modules/@babel/plugin-transform-arrow-functions": { 274 - "version": "7.27.1", 275 - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", 276 - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", 277 - "dev": true, 278 - "license": "MIT", 279 - "dependencies": { 280 - "@babel/helper-plugin-utils": "^7.27.1" 281 - }, 282 - "engines": { 283 - "node": ">=6.9.0" 284 - }, 285 - "peerDependencies": { 286 - "@babel/core": "^7.0.0-0" 287 - } 288 - }, 289 269 "node_modules/@babel/plugin-transform-react-jsx-self": { 290 270 "version": "7.27.1", 291 271 "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", ··· 364 344 }, 365 345 "engines": { 366 346 "node": ">=6.9.0" 367 - } 368 - }, 369 - "node_modules/@electron/get": { 370 - "version": "2.0.3", 371 - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", 372 - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", 373 - "dev": true, 374 - "license": "MIT", 375 - "dependencies": { 376 - "debug": "^4.1.1", 377 - "env-paths": "^2.2.0", 378 - "fs-extra": "^8.1.0", 379 - "got": "^11.8.5", 380 - "progress": "^2.0.3", 381 - "semver": "^6.2.0", 382 - "sumchecker": "^3.0.1" 383 - }, 384 - "engines": { 385 - "node": ">=12" 386 - }, 387 - "optionalDependencies": { 388 - "global-agent": "^3.0.0" 389 347 } 390 348 }, 391 349 "node_modules/@esbuild/aix-ppc64": { ··· 677 635 "node": ">=12" 678 636 } 679 637 }, 638 + "node_modules/@esbuild/netbsd-arm64": { 639 + "version": "0.28.0", 640 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", 641 + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", 642 + "cpu": [ 643 + "arm64" 644 + ], 645 + "dev": true, 646 + "license": "MIT", 647 + "optional": true, 648 + "os": [ 649 + "netbsd" 650 + ], 651 + "engines": { 652 + "node": ">=18" 653 + } 654 + }, 680 655 "node_modules/@esbuild/netbsd-x64": { 681 656 "version": "0.21.5", 682 657 "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", ··· 694 669 "node": ">=12" 695 670 } 696 671 }, 672 + "node_modules/@esbuild/openbsd-arm64": { 673 + "version": "0.28.0", 674 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", 675 + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", 676 + "cpu": [ 677 + "arm64" 678 + ], 679 + "dev": true, 680 + "license": "MIT", 681 + "optional": true, 682 + "os": [ 683 + "openbsd" 684 + ], 685 + "engines": { 686 + "node": ">=18" 687 + } 688 + }, 697 689 "node_modules/@esbuild/openbsd-x64": { 698 690 "version": "0.21.5", 699 691 "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", ··· 711 703 "node": ">=12" 712 704 } 713 705 }, 706 + "node_modules/@esbuild/openharmony-arm64": { 707 + "version": "0.28.0", 708 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", 709 + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", 710 + "cpu": [ 711 + "arm64" 712 + ], 713 + "dev": true, 714 + "license": "MIT", 715 + "optional": true, 716 + "os": [ 717 + "openharmony" 718 + ], 719 + "engines": { 720 + "node": ">=18" 721 + } 722 + }, 714 723 "node_modules/@esbuild/sunos-x64": { 715 724 "version": "0.21.5", 716 725 "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", ··· 779 788 "node": ">=12" 780 789 } 781 790 }, 791 + "node_modules/@fastify/accept-negotiator": { 792 + "version": "1.1.0", 793 + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", 794 + "integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==", 795 + "license": "MIT", 796 + "engines": { 797 + "node": ">=14" 798 + } 799 + }, 800 + "node_modules/@fastify/ajv-compiler": { 801 + "version": "3.6.0", 802 + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", 803 + "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", 804 + "license": "MIT", 805 + "dependencies": { 806 + "ajv": "^8.11.0", 807 + "ajv-formats": "^2.1.1", 808 + "fast-uri": "^2.0.0" 809 + } 810 + }, 811 + "node_modules/@fastify/cookie": { 812 + "version": "9.4.0", 813 + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-9.4.0.tgz", 814 + "integrity": "sha512-Th+pt3kEkh4MQD/Q2q1bMuJIB5NX/D5SwSpOKu3G/tjoGbwfpurIMJsWSPS0SJJ4eyjtmQ8OipDQspf8RbUOlg==", 815 + "license": "MIT", 816 + "dependencies": { 817 + "cookie-signature": "^1.1.0", 818 + "fastify-plugin": "^4.0.0" 819 + } 820 + }, 821 + "node_modules/@fastify/error": { 822 + "version": "3.4.1", 823 + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", 824 + "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", 825 + "license": "MIT" 826 + }, 827 + "node_modules/@fastify/fast-json-stringify-compiler": { 828 + "version": "4.3.0", 829 + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", 830 + "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", 831 + "license": "MIT", 832 + "dependencies": { 833 + "fast-json-stringify": "^5.7.0" 834 + } 835 + }, 836 + "node_modules/@fastify/merge-json-schemas": { 837 + "version": "0.1.1", 838 + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", 839 + "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", 840 + "license": "MIT", 841 + "dependencies": { 842 + "fast-deep-equal": "^3.1.3" 843 + } 844 + }, 845 + "node_modules/@fastify/send": { 846 + "version": "2.1.0", 847 + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz", 848 + "integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==", 849 + "license": "MIT", 850 + "dependencies": { 851 + "@lukeed/ms": "^2.0.1", 852 + "escape-html": "~1.0.3", 853 + "fast-decode-uri-component": "^1.0.1", 854 + "http-errors": "2.0.0", 855 + "mime": "^3.0.0" 856 + } 857 + }, 858 + "node_modules/@fastify/static": { 859 + "version": "7.0.4", 860 + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz", 861 + "integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==", 862 + "license": "MIT", 863 + "dependencies": { 864 + "@fastify/accept-negotiator": "^1.0.0", 865 + "@fastify/send": "^2.0.0", 866 + "content-disposition": "^0.5.3", 867 + "fastify-plugin": "^4.0.0", 868 + "fastq": "^1.17.0", 869 + "glob": "^10.3.4" 870 + } 871 + }, 872 + "node_modules/@fastify/websocket": { 873 + "version": "10.0.1", 874 + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-10.0.1.tgz", 875 + "integrity": "sha512-8/pQIxTPRD8U94aILTeJ+2O3el/r19+Ej5z1O1mXlqplsUH7KzCjAI0sgd5DM/NoPjAi5qLFNIjgM5+9/rGSNw==", 876 + "license": "MIT", 877 + "dependencies": { 878 + "duplexify": "^4.1.2", 879 + "fastify-plugin": "^4.0.0", 880 + "ws": "^8.0.0" 881 + } 882 + }, 883 + "node_modules/@isaacs/cliui": { 884 + "version": "8.0.2", 885 + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", 886 + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", 887 + "license": "ISC", 888 + "dependencies": { 889 + "string-width": "^5.1.2", 890 + "string-width-cjs": "npm:string-width@^4.2.0", 891 + "strip-ansi": "^7.0.1", 892 + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", 893 + "wrap-ansi": "^8.1.0", 894 + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" 895 + }, 896 + "engines": { 897 + "node": ">=12" 898 + } 899 + }, 782 900 "node_modules/@jridgewell/gen-mapping": { 783 901 "version": "0.3.13", 784 902 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", ··· 827 945 "dependencies": { 828 946 "@jridgewell/resolve-uri": "^3.1.0", 829 947 "@jridgewell/sourcemap-codec": "^1.4.14" 948 + } 949 + }, 950 + "node_modules/@lukeed/ms": { 951 + "version": "2.0.2", 952 + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", 953 + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", 954 + "license": "MIT", 955 + "engines": { 956 + "node": ">=8" 957 + } 958 + }, 959 + "node_modules/@pinojs/redact": { 960 + "version": "0.4.0", 961 + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", 962 + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", 963 + "license": "MIT" 964 + }, 965 + "node_modules/@pkgjs/parseargs": { 966 + "version": "0.11.0", 967 + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", 968 + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", 969 + "license": "MIT", 970 + "optional": true, 971 + "engines": { 972 + "node": ">=14" 830 973 } 831 974 }, 832 975 "node_modules/@rolldown/pluginutils": { ··· 1186 1329 "win32" 1187 1330 ] 1188 1331 }, 1189 - "node_modules/@sindresorhus/is": { 1190 - "version": "4.6.0", 1191 - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", 1192 - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", 1193 - "dev": true, 1194 - "license": "MIT", 1195 - "engines": { 1196 - "node": ">=10" 1197 - }, 1198 - "funding": { 1199 - "url": "https://github.com/sindresorhus/is?sponsor=1" 1200 - } 1201 - }, 1202 - "node_modules/@szmarczak/http-timer": { 1203 - "version": "4.0.6", 1204 - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", 1205 - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", 1206 - "dev": true, 1207 - "license": "MIT", 1208 - "dependencies": { 1209 - "defer-to-connect": "^2.0.0" 1210 - }, 1211 - "engines": { 1212 - "node": ">=10" 1213 - } 1214 - }, 1215 1332 "node_modules/@types/babel__core": { 1216 1333 "version": "7.20.5", 1217 1334 "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", ··· 1257 1374 "@babel/types": "^7.28.2" 1258 1375 } 1259 1376 }, 1260 - "node_modules/@types/cacheable-request": { 1261 - "version": "6.0.3", 1262 - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", 1263 - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", 1377 + "node_modules/@types/better-sqlite3": { 1378 + "version": "7.6.13", 1379 + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", 1380 + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", 1264 1381 "dev": true, 1265 1382 "license": "MIT", 1266 1383 "dependencies": { 1267 - "@types/http-cache-semantics": "*", 1268 - "@types/keyv": "^3.1.4", 1269 - "@types/node": "*", 1270 - "@types/responselike": "^1.0.0" 1384 + "@types/node": "*" 1271 1385 } 1272 1386 }, 1273 1387 "node_modules/@types/debug": { ··· 1303 1417 "@types/unist": "*" 1304 1418 } 1305 1419 }, 1306 - "node_modules/@types/http-cache-semantics": { 1307 - "version": "4.2.0", 1308 - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", 1309 - "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", 1310 - "dev": true, 1311 - "license": "MIT" 1312 - }, 1313 - "node_modules/@types/keyv": { 1314 - "version": "3.1.4", 1315 - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", 1316 - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", 1317 - "dev": true, 1318 - "license": "MIT", 1319 - "dependencies": { 1320 - "@types/node": "*" 1321 - } 1322 - }, 1323 1420 "node_modules/@types/mdast": { 1324 1421 "version": "4.0.4", 1325 1422 "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", ··· 1380 1477 "@types/react": "^18.0.0" 1381 1478 } 1382 1479 }, 1383 - "node_modules/@types/responselike": { 1384 - "version": "1.0.3", 1385 - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", 1386 - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", 1387 - "dev": true, 1388 - "license": "MIT", 1389 - "dependencies": { 1390 - "@types/node": "*" 1391 - } 1392 - }, 1393 1480 "node_modules/@types/unist": { 1394 1481 "version": "3.0.3", 1395 1482 "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", 1396 1483 "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", 1397 1484 "license": "MIT" 1398 1485 }, 1399 - "node_modules/@types/yauzl": { 1400 - "version": "2.10.3", 1401 - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", 1402 - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", 1486 + "node_modules/@types/ws": { 1487 + "version": "8.18.1", 1488 + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", 1489 + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", 1403 1490 "dev": true, 1404 1491 "license": "MIT", 1405 - "optional": true, 1406 1492 "dependencies": { 1407 1493 "@types/node": "*" 1408 1494 } ··· 1446 1532 "node": ">=6.5" 1447 1533 } 1448 1534 }, 1535 + "node_modules/abstract-logging": { 1536 + "version": "2.0.1", 1537 + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", 1538 + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", 1539 + "license": "MIT" 1540 + }, 1449 1541 "node_modules/agentkeepalive": { 1450 1542 "version": "4.6.0", 1451 1543 "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", ··· 1458 1550 "node": ">= 8.0.0" 1459 1551 } 1460 1552 }, 1461 - "node_modules/anymatch": { 1462 - "version": "3.1.3", 1463 - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", 1464 - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", 1465 - "license": "ISC", 1553 + "node_modules/ajv": { 1554 + "version": "8.20.0", 1555 + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", 1556 + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", 1557 + "license": "MIT", 1558 + "dependencies": { 1559 + "fast-deep-equal": "^3.1.3", 1560 + "fast-uri": "^3.0.1", 1561 + "json-schema-traverse": "^1.0.0", 1562 + "require-from-string": "^2.0.2" 1563 + }, 1564 + "funding": { 1565 + "type": "github", 1566 + "url": "https://github.com/sponsors/epoberezkin" 1567 + } 1568 + }, 1569 + "node_modules/ajv-formats": { 1570 + "version": "2.1.1", 1571 + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", 1572 + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", 1573 + "license": "MIT", 1574 + "dependencies": { 1575 + "ajv": "^8.0.0" 1576 + }, 1577 + "peerDependencies": { 1578 + "ajv": "^8.0.0" 1579 + }, 1580 + "peerDependenciesMeta": { 1581 + "ajv": { 1582 + "optional": true 1583 + } 1584 + } 1585 + }, 1586 + "node_modules/ajv/node_modules/fast-uri": { 1587 + "version": "3.1.2", 1588 + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", 1589 + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", 1590 + "funding": [ 1591 + { 1592 + "type": "github", 1593 + "url": "https://github.com/sponsors/fastify" 1594 + }, 1595 + { 1596 + "type": "opencollective", 1597 + "url": "https://opencollective.com/fastify" 1598 + } 1599 + ], 1600 + "license": "BSD-3-Clause" 1601 + }, 1602 + "node_modules/ansi-regex": { 1603 + "version": "6.2.2", 1604 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", 1605 + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", 1606 + "license": "MIT", 1607 + "engines": { 1608 + "node": ">=12" 1609 + }, 1610 + "funding": { 1611 + "url": "https://github.com/chalk/ansi-regex?sponsor=1" 1612 + } 1613 + }, 1614 + "node_modules/ansi-styles": { 1615 + "version": "4.3.0", 1616 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 1617 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 1618 + "license": "MIT", 1466 1619 "dependencies": { 1467 - "normalize-path": "^3.0.0", 1468 - "picomatch": "^2.0.4" 1620 + "color-convert": "^2.0.1" 1469 1621 }, 1470 1622 "engines": { 1471 - "node": ">= 8" 1623 + "node": ">=8" 1624 + }, 1625 + "funding": { 1626 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 1472 1627 } 1473 1628 }, 1474 1629 "node_modules/asynckit": { ··· 1476 1631 "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 1477 1632 "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", 1478 1633 "license": "MIT" 1634 + }, 1635 + "node_modules/atomic-sleep": { 1636 + "version": "1.0.0", 1637 + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", 1638 + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", 1639 + "license": "MIT", 1640 + "engines": { 1641 + "node": ">=8.0.0" 1642 + } 1643 + }, 1644 + "node_modules/avvio": { 1645 + "version": "8.4.0", 1646 + "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", 1647 + "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", 1648 + "license": "MIT", 1649 + "dependencies": { 1650 + "@fastify/error": "^3.3.0", 1651 + "fastq": "^1.17.1" 1652 + } 1479 1653 }, 1480 1654 "node_modules/bail": { 1481 1655 "version": "2.0.2", ··· 1487 1661 "url": "https://github.com/sponsors/wooorm" 1488 1662 } 1489 1663 }, 1664 + "node_modules/balanced-match": { 1665 + "version": "1.0.2", 1666 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 1667 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 1668 + "license": "MIT" 1669 + }, 1490 1670 "node_modules/base64-js": { 1491 1671 "version": "1.5.1", 1492 1672 "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", ··· 1520 1700 "node": ">=6.0.0" 1521 1701 } 1522 1702 }, 1523 - "node_modules/binary-extensions": { 1524 - "version": "2.3.0", 1525 - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", 1526 - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", 1703 + "node_modules/better-sqlite3": { 1704 + "version": "11.10.0", 1705 + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", 1706 + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", 1707 + "hasInstallScript": true, 1527 1708 "license": "MIT", 1528 - "engines": { 1529 - "node": ">=8" 1530 - }, 1531 - "funding": { 1532 - "url": "https://github.com/sponsors/sindresorhus" 1709 + "dependencies": { 1710 + "bindings": "^1.5.0", 1711 + "prebuild-install": "^7.1.1" 1712 + } 1713 + }, 1714 + "node_modules/bindings": { 1715 + "version": "1.5.0", 1716 + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", 1717 + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", 1718 + "license": "MIT", 1719 + "dependencies": { 1720 + "file-uri-to-path": "1.0.0" 1533 1721 } 1534 1722 }, 1535 1723 "node_modules/bl": { ··· 1543 1731 "readable-stream": "^3.4.0" 1544 1732 } 1545 1733 }, 1546 - "node_modules/boolean": { 1547 - "version": "3.2.0", 1548 - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", 1549 - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", 1550 - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", 1551 - "dev": true, 1552 - "license": "MIT", 1553 - "optional": true 1554 - }, 1555 - "node_modules/braces": { 1556 - "version": "3.0.3", 1557 - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", 1558 - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", 1734 + "node_modules/brace-expansion": { 1735 + "version": "2.1.1", 1736 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", 1737 + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", 1559 1738 "license": "MIT", 1560 1739 "dependencies": { 1561 - "fill-range": "^7.1.1" 1562 - }, 1563 - "engines": { 1564 - "node": ">=8" 1740 + "balanced-match": "^1.0.0" 1565 1741 } 1566 1742 }, 1567 1743 "node_modules/browserslist": { ··· 1622 1798 "ieee754": "^1.1.13" 1623 1799 } 1624 1800 }, 1625 - "node_modules/buffer-crc32": { 1626 - "version": "0.2.13", 1627 - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", 1628 - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", 1629 - "dev": true, 1630 - "license": "MIT", 1631 - "engines": { 1632 - "node": "*" 1633 - } 1634 - }, 1635 - "node_modules/cac": { 1636 - "version": "6.7.14", 1637 - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", 1638 - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", 1639 - "dev": true, 1640 - "license": "MIT", 1641 - "engines": { 1642 - "node": ">=8" 1643 - } 1644 - }, 1645 - "node_modules/cacheable-lookup": { 1646 - "version": "5.0.4", 1647 - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", 1648 - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", 1649 - "dev": true, 1650 - "license": "MIT", 1651 - "engines": { 1652 - "node": ">=10.6.0" 1653 - } 1654 - }, 1655 - "node_modules/cacheable-request": { 1656 - "version": "7.0.4", 1657 - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", 1658 - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", 1659 - "dev": true, 1660 - "license": "MIT", 1661 - "dependencies": { 1662 - "clone-response": "^1.0.2", 1663 - "get-stream": "^5.1.0", 1664 - "http-cache-semantics": "^4.0.0", 1665 - "keyv": "^4.0.0", 1666 - "lowercase-keys": "^2.0.0", 1667 - "normalize-url": "^6.0.1", 1668 - "responselike": "^2.0.0" 1669 - }, 1670 - "engines": { 1671 - "node": ">=8" 1672 - } 1673 - }, 1674 1801 "node_modules/call-bind-apply-helpers": { 1675 1802 "version": "1.0.2", 1676 1803 "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", ··· 1715 1842 "url": "https://github.com/sponsors/wooorm" 1716 1843 } 1717 1844 }, 1845 + "node_modules/chalk": { 1846 + "version": "4.1.2", 1847 + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 1848 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 1849 + "dev": true, 1850 + "license": "MIT", 1851 + "dependencies": { 1852 + "ansi-styles": "^4.1.0", 1853 + "supports-color": "^7.1.0" 1854 + }, 1855 + "engines": { 1856 + "node": ">=10" 1857 + }, 1858 + "funding": { 1859 + "url": "https://github.com/chalk/chalk?sponsor=1" 1860 + } 1861 + }, 1862 + "node_modules/chalk/node_modules/supports-color": { 1863 + "version": "7.2.0", 1864 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 1865 + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 1866 + "dev": true, 1867 + "license": "MIT", 1868 + "dependencies": { 1869 + "has-flag": "^4.0.0" 1870 + }, 1871 + "engines": { 1872 + "node": ">=8" 1873 + } 1874 + }, 1718 1875 "node_modules/character-entities": { 1719 1876 "version": "2.0.2", 1720 1877 "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", ··· 1755 1912 "url": "https://github.com/sponsors/wooorm" 1756 1913 } 1757 1914 }, 1758 - "node_modules/chokidar": { 1759 - "version": "3.6.0", 1760 - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", 1761 - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", 1762 - "license": "MIT", 1915 + "node_modules/chownr": { 1916 + "version": "1.1.4", 1917 + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", 1918 + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", 1919 + "license": "ISC" 1920 + }, 1921 + "node_modules/cliui": { 1922 + "version": "8.0.1", 1923 + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", 1924 + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", 1925 + "dev": true, 1926 + "license": "ISC", 1763 1927 "dependencies": { 1764 - "anymatch": "~3.1.2", 1765 - "braces": "~3.0.2", 1766 - "glob-parent": "~5.1.2", 1767 - "is-binary-path": "~2.1.0", 1768 - "is-glob": "~4.0.1", 1769 - "normalize-path": "~3.0.0", 1770 - "readdirp": "~3.6.0" 1928 + "string-width": "^4.2.0", 1929 + "strip-ansi": "^6.0.1", 1930 + "wrap-ansi": "^7.0.0" 1771 1931 }, 1772 1932 "engines": { 1773 - "node": ">= 8.10.0" 1774 - }, 1775 - "funding": { 1776 - "url": "https://paulmillr.com/funding/" 1933 + "node": ">=12" 1934 + } 1935 + }, 1936 + "node_modules/cliui/node_modules/ansi-regex": { 1937 + "version": "5.0.1", 1938 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 1939 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 1940 + "dev": true, 1941 + "license": "MIT", 1942 + "engines": { 1943 + "node": ">=8" 1944 + } 1945 + }, 1946 + "node_modules/cliui/node_modules/emoji-regex": { 1947 + "version": "8.0.0", 1948 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 1949 + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 1950 + "dev": true, 1951 + "license": "MIT" 1952 + }, 1953 + "node_modules/cliui/node_modules/string-width": { 1954 + "version": "4.2.3", 1955 + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 1956 + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 1957 + "dev": true, 1958 + "license": "MIT", 1959 + "dependencies": { 1960 + "emoji-regex": "^8.0.0", 1961 + "is-fullwidth-code-point": "^3.0.0", 1962 + "strip-ansi": "^6.0.1" 1777 1963 }, 1778 - "optionalDependencies": { 1779 - "fsevents": "~2.3.2" 1964 + "engines": { 1965 + "node": ">=8" 1780 1966 } 1781 1967 }, 1782 - "node_modules/chownr": { 1783 - "version": "1.1.4", 1784 - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", 1785 - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", 1786 - "license": "ISC" 1968 + "node_modules/cliui/node_modules/strip-ansi": { 1969 + "version": "6.0.1", 1970 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 1971 + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1972 + "dev": true, 1973 + "license": "MIT", 1974 + "dependencies": { 1975 + "ansi-regex": "^5.0.1" 1976 + }, 1977 + "engines": { 1978 + "node": ">=8" 1979 + } 1787 1980 }, 1788 - "node_modules/clone-response": { 1789 - "version": "1.0.3", 1790 - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", 1791 - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", 1981 + "node_modules/cliui/node_modules/wrap-ansi": { 1982 + "version": "7.0.0", 1983 + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", 1984 + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 1792 1985 "dev": true, 1793 1986 "license": "MIT", 1794 1987 "dependencies": { 1795 - "mimic-response": "^1.0.0" 1988 + "ansi-styles": "^4.0.0", 1989 + "string-width": "^4.1.0", 1990 + "strip-ansi": "^6.0.0" 1991 + }, 1992 + "engines": { 1993 + "node": ">=10" 1796 1994 }, 1797 1995 "funding": { 1798 - "url": "https://github.com/sponsors/sindresorhus" 1996 + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 1997 + } 1998 + }, 1999 + "node_modules/color-convert": { 2000 + "version": "2.0.1", 2001 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 2002 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 2003 + "license": "MIT", 2004 + "dependencies": { 2005 + "color-name": "~1.1.4" 2006 + }, 2007 + "engines": { 2008 + "node": ">=7.0.0" 1799 2009 } 2010 + }, 2011 + "node_modules/color-name": { 2012 + "version": "1.1.4", 2013 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 2014 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 2015 + "license": "MIT" 1800 2016 }, 1801 2017 "node_modules/combined-stream": { 1802 2018 "version": "1.0.8", ··· 1820 2036 "url": "https://github.com/sponsors/wooorm" 1821 2037 } 1822 2038 }, 2039 + "node_modules/concurrently": { 2040 + "version": "9.2.1", 2041 + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", 2042 + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", 2043 + "dev": true, 2044 + "license": "MIT", 2045 + "dependencies": { 2046 + "chalk": "4.1.2", 2047 + "rxjs": "7.8.2", 2048 + "shell-quote": "1.8.3", 2049 + "supports-color": "8.1.1", 2050 + "tree-kill": "1.2.2", 2051 + "yargs": "17.7.2" 2052 + }, 2053 + "bin": { 2054 + "conc": "dist/bin/concurrently.js", 2055 + "concurrently": "dist/bin/concurrently.js" 2056 + }, 2057 + "engines": { 2058 + "node": ">=18" 2059 + }, 2060 + "funding": { 2061 + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" 2062 + } 2063 + }, 2064 + "node_modules/content-disposition": { 2065 + "version": "0.5.4", 2066 + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", 2067 + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 2068 + "license": "MIT", 2069 + "dependencies": { 2070 + "safe-buffer": "5.2.1" 2071 + }, 2072 + "engines": { 2073 + "node": ">= 0.6" 2074 + } 2075 + }, 1823 2076 "node_modules/convert-source-map": { 1824 2077 "version": "2.0.0", 1825 2078 "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", ··· 1827 2080 "dev": true, 1828 2081 "license": "MIT" 1829 2082 }, 2083 + "node_modules/cookie": { 2084 + "version": "0.7.2", 2085 + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", 2086 + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", 2087 + "license": "MIT", 2088 + "engines": { 2089 + "node": ">= 0.6" 2090 + } 2091 + }, 2092 + "node_modules/cookie-signature": { 2093 + "version": "1.2.2", 2094 + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", 2095 + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", 2096 + "license": "MIT", 2097 + "engines": { 2098 + "node": ">=6.6.0" 2099 + } 2100 + }, 2101 + "node_modules/cross-spawn": { 2102 + "version": "7.0.6", 2103 + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", 2104 + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 2105 + "license": "MIT", 2106 + "dependencies": { 2107 + "path-key": "^3.1.0", 2108 + "shebang-command": "^2.0.0", 2109 + "which": "^2.0.1" 2110 + }, 2111 + "engines": { 2112 + "node": ">= 8" 2113 + } 2114 + }, 1830 2115 "node_modules/csstype": { 1831 2116 "version": "3.2.3", 1832 2117 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", ··· 1878 2163 "url": "https://github.com/sponsors/sindresorhus" 1879 2164 } 1880 2165 }, 1881 - "node_modules/decompress-response/node_modules/mimic-response": { 1882 - "version": "3.1.0", 1883 - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", 1884 - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", 1885 - "license": "MIT", 1886 - "engines": { 1887 - "node": ">=10" 1888 - }, 1889 - "funding": { 1890 - "url": "https://github.com/sponsors/sindresorhus" 1891 - } 1892 - }, 1893 2166 "node_modules/deep-extend": { 1894 2167 "version": "0.6.0", 1895 2168 "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", ··· 1899 2172 "node": ">=4.0.0" 1900 2173 } 1901 2174 }, 1902 - "node_modules/defer-to-connect": { 1903 - "version": "2.0.1", 1904 - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", 1905 - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", 1906 - "dev": true, 1907 - "license": "MIT", 1908 - "engines": { 1909 - "node": ">=10" 1910 - } 1911 - }, 1912 - "node_modules/define-data-property": { 1913 - "version": "1.1.4", 1914 - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", 1915 - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", 1916 - "dev": true, 1917 - "license": "MIT", 1918 - "optional": true, 1919 - "dependencies": { 1920 - "es-define-property": "^1.0.0", 1921 - "es-errors": "^1.3.0", 1922 - "gopd": "^1.0.1" 1923 - }, 1924 - "engines": { 1925 - "node": ">= 0.4" 1926 - }, 1927 - "funding": { 1928 - "url": "https://github.com/sponsors/ljharb" 1929 - } 1930 - }, 1931 - "node_modules/define-properties": { 1932 - "version": "1.2.1", 1933 - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", 1934 - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", 1935 - "dev": true, 1936 - "license": "MIT", 1937 - "optional": true, 1938 - "dependencies": { 1939 - "define-data-property": "^1.0.1", 1940 - "has-property-descriptors": "^1.0.0", 1941 - "object-keys": "^1.1.1" 1942 - }, 1943 - "engines": { 1944 - "node": ">= 0.4" 1945 - }, 1946 - "funding": { 1947 - "url": "https://github.com/sponsors/ljharb" 1948 - } 1949 - }, 1950 2175 "node_modules/delayed-stream": { 1951 2176 "version": "1.0.0", 1952 2177 "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", ··· 1954 2179 "license": "MIT", 1955 2180 "engines": { 1956 2181 "node": ">=0.4.0" 2182 + } 2183 + }, 2184 + "node_modules/depd": { 2185 + "version": "2.0.0", 2186 + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", 2187 + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", 2188 + "license": "MIT", 2189 + "engines": { 2190 + "node": ">= 0.8" 1957 2191 } 1958 2192 }, 1959 2193 "node_modules/dequal": { ··· 1974 2208 "node": ">=8" 1975 2209 } 1976 2210 }, 1977 - "node_modules/detect-node": { 1978 - "version": "2.1.0", 1979 - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", 1980 - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", 1981 - "dev": true, 1982 - "license": "MIT", 1983 - "optional": true 1984 - }, 1985 2211 "node_modules/devlop": { 1986 2212 "version": "1.1.0", 1987 2213 "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", ··· 2009 2235 "node": ">= 0.4" 2010 2236 } 2011 2237 }, 2012 - "node_modules/electron": { 2013 - "version": "31.7.7", 2014 - "resolved": "https://registry.npmjs.org/electron/-/electron-31.7.7.tgz", 2015 - "integrity": "sha512-HZtZg8EHsDGnswFt0QeV8If8B+et63uD6RJ7I4/xhcXqmTIbI08GoubX/wm+HdY0DwcuPe1/xsgqpmYvjdjRoA==", 2016 - "dev": true, 2017 - "hasInstallScript": true, 2238 + "node_modules/duplexify": { 2239 + "version": "4.1.3", 2240 + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", 2241 + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", 2018 2242 "license": "MIT", 2019 2243 "dependencies": { 2020 - "@electron/get": "^2.0.0", 2021 - "@types/node": "^20.9.0", 2022 - "extract-zip": "^2.0.1" 2023 - }, 2024 - "bin": { 2025 - "electron": "cli.js" 2026 - }, 2027 - "engines": { 2028 - "node": ">= 12.20.55" 2244 + "end-of-stream": "^1.4.1", 2245 + "inherits": "^2.0.3", 2246 + "readable-stream": "^3.1.1", 2247 + "stream-shift": "^1.0.2" 2029 2248 } 2249 + }, 2250 + "node_modules/eastasianwidth": { 2251 + "version": "0.2.0", 2252 + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", 2253 + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", 2254 + "license": "MIT" 2030 2255 }, 2031 2256 "node_modules/electron-to-chromium": { 2032 2257 "version": "1.5.361", ··· 2035 2260 "dev": true, 2036 2261 "license": "ISC" 2037 2262 }, 2038 - "node_modules/electron-vite": { 2039 - "version": "2.3.0", 2040 - "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-2.3.0.tgz", 2041 - "integrity": "sha512-lsN2FymgJlp4k6MrcsphGqZQ9fKRdJKasoaiwIrAewN1tapYI/KINLdfEL7n10LuF0pPSNf/IqjzZbB5VINctg==", 2042 - "dev": true, 2043 - "license": "MIT", 2044 - "dependencies": { 2045 - "@babel/core": "^7.24.7", 2046 - "@babel/plugin-transform-arrow-functions": "^7.24.7", 2047 - "cac": "^6.7.14", 2048 - "esbuild": "^0.21.5", 2049 - "magic-string": "^0.30.10", 2050 - "picocolors": "^1.0.1" 2051 - }, 2052 - "bin": { 2053 - "electron-vite": "bin/electron-vite.js" 2054 - }, 2055 - "engines": { 2056 - "node": "^18.0.0 || >=20.0.0" 2057 - }, 2058 - "peerDependencies": { 2059 - "@swc/core": "^1.0.0", 2060 - "vite": "^4.0.0 || ^5.0.0" 2061 - }, 2062 - "peerDependenciesMeta": { 2063 - "@swc/core": { 2064 - "optional": true 2065 - } 2066 - } 2263 + "node_modules/emoji-regex": { 2264 + "version": "9.2.2", 2265 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", 2266 + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", 2267 + "license": "MIT" 2067 2268 }, 2068 2269 "node_modules/end-of-stream": { 2069 2270 "version": "1.4.5", ··· 2074 2275 "once": "^1.4.0" 2075 2276 } 2076 2277 }, 2077 - "node_modules/env-paths": { 2078 - "version": "2.2.1", 2079 - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", 2080 - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", 2081 - "dev": true, 2082 - "license": "MIT", 2083 - "engines": { 2084 - "node": ">=6" 2085 - } 2086 - }, 2087 2278 "node_modules/es-define-property": { 2088 2279 "version": "1.0.1", 2089 2280 "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", ··· 2129 2320 "node": ">= 0.4" 2130 2321 } 2131 2322 }, 2132 - "node_modules/es6-error": { 2133 - "version": "4.1.1", 2134 - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", 2135 - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", 2136 - "dev": true, 2137 - "license": "MIT", 2138 - "optional": true 2139 - }, 2140 2323 "node_modules/esbuild": { 2141 2324 "version": "0.21.5", 2142 2325 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", ··· 2186 2369 "node": ">=6" 2187 2370 } 2188 2371 }, 2189 - "node_modules/escape-string-regexp": { 2190 - "version": "4.0.0", 2191 - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", 2192 - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", 2193 - "dev": true, 2194 - "license": "MIT", 2195 - "optional": true, 2196 - "engines": { 2197 - "node": ">=10" 2198 - }, 2199 - "funding": { 2200 - "url": "https://github.com/sponsors/sindresorhus" 2201 - } 2372 + "node_modules/escape-html": { 2373 + "version": "1.0.3", 2374 + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 2375 + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", 2376 + "license": "MIT" 2202 2377 }, 2203 2378 "node_modules/estree-util-is-identifier-name": { 2204 2379 "version": "3.0.0", ··· 2234 2409 "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", 2235 2410 "license": "MIT" 2236 2411 }, 2237 - "node_modules/extract-zip": { 2238 - "version": "2.0.1", 2239 - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", 2240 - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", 2241 - "dev": true, 2242 - "license": "BSD-2-Clause", 2412 + "node_modules/fast-content-type-parse": { 2413 + "version": "1.1.0", 2414 + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", 2415 + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", 2416 + "license": "MIT" 2417 + }, 2418 + "node_modules/fast-decode-uri-component": { 2419 + "version": "1.0.1", 2420 + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", 2421 + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", 2422 + "license": "MIT" 2423 + }, 2424 + "node_modules/fast-deep-equal": { 2425 + "version": "3.1.3", 2426 + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 2427 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", 2428 + "license": "MIT" 2429 + }, 2430 + "node_modules/fast-json-stringify": { 2431 + "version": "5.16.1", 2432 + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", 2433 + "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", 2434 + "license": "MIT", 2243 2435 "dependencies": { 2244 - "debug": "^4.1.1", 2245 - "get-stream": "^5.1.0", 2246 - "yauzl": "^2.10.0" 2436 + "@fastify/merge-json-schemas": "^0.1.0", 2437 + "ajv": "^8.10.0", 2438 + "ajv-formats": "^3.0.1", 2439 + "fast-deep-equal": "^3.1.3", 2440 + "fast-uri": "^2.1.0", 2441 + "json-schema-ref-resolver": "^1.0.1", 2442 + "rfdc": "^1.2.0" 2443 + } 2444 + }, 2445 + "node_modules/fast-json-stringify/node_modules/ajv-formats": { 2446 + "version": "3.0.1", 2447 + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", 2448 + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", 2449 + "license": "MIT", 2450 + "dependencies": { 2451 + "ajv": "^8.0.0" 2247 2452 }, 2453 + "peerDependencies": { 2454 + "ajv": "^8.0.0" 2455 + }, 2456 + "peerDependenciesMeta": { 2457 + "ajv": { 2458 + "optional": true 2459 + } 2460 + } 2461 + }, 2462 + "node_modules/fast-querystring": { 2463 + "version": "1.1.2", 2464 + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", 2465 + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", 2466 + "license": "MIT", 2467 + "dependencies": { 2468 + "fast-decode-uri-component": "^1.0.1" 2469 + } 2470 + }, 2471 + "node_modules/fast-uri": { 2472 + "version": "2.4.0", 2473 + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", 2474 + "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", 2475 + "license": "MIT" 2476 + }, 2477 + "node_modules/fastify": { 2478 + "version": "4.29.1", 2479 + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", 2480 + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", 2481 + "funding": [ 2482 + { 2483 + "type": "github", 2484 + "url": "https://github.com/sponsors/fastify" 2485 + }, 2486 + { 2487 + "type": "opencollective", 2488 + "url": "https://opencollective.com/fastify" 2489 + } 2490 + ], 2491 + "license": "MIT", 2492 + "dependencies": { 2493 + "@fastify/ajv-compiler": "^3.5.0", 2494 + "@fastify/error": "^3.4.0", 2495 + "@fastify/fast-json-stringify-compiler": "^4.3.0", 2496 + "abstract-logging": "^2.0.1", 2497 + "avvio": "^8.3.0", 2498 + "fast-content-type-parse": "^1.1.0", 2499 + "fast-json-stringify": "^5.8.0", 2500 + "find-my-way": "^8.0.0", 2501 + "light-my-request": "^5.11.0", 2502 + "pino": "^9.0.0", 2503 + "process-warning": "^3.0.0", 2504 + "proxy-addr": "^2.0.7", 2505 + "rfdc": "^1.3.0", 2506 + "secure-json-parse": "^2.7.0", 2507 + "semver": "^7.5.4", 2508 + "toad-cache": "^3.3.0" 2509 + } 2510 + }, 2511 + "node_modules/fastify-plugin": { 2512 + "version": "4.5.1", 2513 + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", 2514 + "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", 2515 + "license": "MIT" 2516 + }, 2517 + "node_modules/fastify/node_modules/semver": { 2518 + "version": "7.8.1", 2519 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", 2520 + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", 2521 + "license": "ISC", 2248 2522 "bin": { 2249 - "extract-zip": "cli.js" 2523 + "semver": "bin/semver.js" 2250 2524 }, 2251 2525 "engines": { 2252 - "node": ">= 10.17.0" 2253 - }, 2254 - "optionalDependencies": { 2255 - "@types/yauzl": "^2.9.1" 2526 + "node": ">=10" 2256 2527 } 2257 2528 }, 2258 - "node_modules/fd-slicer": { 2259 - "version": "1.1.0", 2260 - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", 2261 - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", 2262 - "dev": true, 2529 + "node_modules/fastq": { 2530 + "version": "1.20.1", 2531 + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", 2532 + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", 2533 + "license": "ISC", 2534 + "dependencies": { 2535 + "reusify": "^1.0.4" 2536 + } 2537 + }, 2538 + "node_modules/file-uri-to-path": { 2539 + "version": "1.0.0", 2540 + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", 2541 + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", 2542 + "license": "MIT" 2543 + }, 2544 + "node_modules/find-my-way": { 2545 + "version": "8.2.2", 2546 + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", 2547 + "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", 2263 2548 "license": "MIT", 2264 2549 "dependencies": { 2265 - "pend": "~1.2.0" 2550 + "fast-deep-equal": "^3.1.3", 2551 + "fast-querystring": "^1.0.0", 2552 + "safe-regex2": "^3.1.0" 2553 + }, 2554 + "engines": { 2555 + "node": ">=14" 2266 2556 } 2267 2557 }, 2268 - "node_modules/fill-range": { 2269 - "version": "7.1.1", 2270 - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", 2271 - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", 2272 - "license": "MIT", 2558 + "node_modules/foreground-child": { 2559 + "version": "3.3.1", 2560 + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", 2561 + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", 2562 + "license": "ISC", 2273 2563 "dependencies": { 2274 - "to-regex-range": "^5.0.1" 2564 + "cross-spawn": "^7.0.6", 2565 + "signal-exit": "^4.0.1" 2275 2566 }, 2276 2567 "engines": { 2277 - "node": ">=8" 2568 + "node": ">=14" 2569 + }, 2570 + "funding": { 2571 + "url": "https://github.com/sponsors/isaacs" 2278 2572 } 2279 2573 }, 2280 2574 "node_modules/form-data": { ··· 2312 2606 "node": ">= 12.20" 2313 2607 } 2314 2608 }, 2609 + "node_modules/forwarded": { 2610 + "version": "0.2.0", 2611 + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", 2612 + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", 2613 + "license": "MIT", 2614 + "engines": { 2615 + "node": ">= 0.6" 2616 + } 2617 + }, 2315 2618 "node_modules/fs-constants": { 2316 2619 "version": "1.0.0", 2317 2620 "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", 2318 2621 "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", 2319 2622 "license": "MIT" 2320 2623 }, 2321 - "node_modules/fs-extra": { 2322 - "version": "8.1.0", 2323 - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", 2324 - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", 2325 - "dev": true, 2326 - "license": "MIT", 2327 - "dependencies": { 2328 - "graceful-fs": "^4.2.0", 2329 - "jsonfile": "^4.0.0", 2330 - "universalify": "^0.1.0" 2331 - }, 2332 - "engines": { 2333 - "node": ">=6 <7 || >=8" 2334 - } 2335 - }, 2336 2624 "node_modules/fsevents": { 2337 2625 "version": "2.3.3", 2338 2626 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 2339 2627 "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 2628 + "dev": true, 2340 2629 "hasInstallScript": true, 2341 2630 "license": "MIT", 2342 2631 "optional": true, ··· 2366 2655 "node": ">=6.9.0" 2367 2656 } 2368 2657 }, 2658 + "node_modules/get-caller-file": { 2659 + "version": "2.0.5", 2660 + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 2661 + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 2662 + "dev": true, 2663 + "license": "ISC", 2664 + "engines": { 2665 + "node": "6.* || 8.* || >= 10.*" 2666 + } 2667 + }, 2369 2668 "node_modules/get-intrinsic": { 2370 2669 "version": "1.3.0", 2371 2670 "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", ··· 2403 2702 "node": ">= 0.4" 2404 2703 } 2405 2704 }, 2406 - "node_modules/get-stream": { 2407 - "version": "5.2.0", 2408 - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", 2409 - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", 2410 - "dev": true, 2411 - "license": "MIT", 2412 - "dependencies": { 2413 - "pump": "^3.0.0" 2414 - }, 2415 - "engines": { 2416 - "node": ">=8" 2417 - }, 2418 - "funding": { 2419 - "url": "https://github.com/sponsors/sindresorhus" 2420 - } 2421 - }, 2422 2705 "node_modules/github-from-package": { 2423 2706 "version": "0.0.0", 2424 2707 "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", 2425 2708 "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", 2426 2709 "license": "MIT" 2427 2710 }, 2428 - "node_modules/glob-parent": { 2429 - "version": "5.1.2", 2430 - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", 2431 - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", 2711 + "node_modules/glob": { 2712 + "version": "10.5.0", 2713 + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", 2714 + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", 2715 + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", 2432 2716 "license": "ISC", 2433 2717 "dependencies": { 2434 - "is-glob": "^4.0.1" 2718 + "foreground-child": "^3.1.0", 2719 + "jackspeak": "^3.1.2", 2720 + "minimatch": "^9.0.4", 2721 + "minipass": "^7.1.2", 2722 + "package-json-from-dist": "^1.0.0", 2723 + "path-scurry": "^1.11.1" 2435 2724 }, 2436 - "engines": { 2437 - "node": ">= 6" 2438 - } 2439 - }, 2440 - "node_modules/global-agent": { 2441 - "version": "3.0.0", 2442 - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", 2443 - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", 2444 - "dev": true, 2445 - "license": "BSD-3-Clause", 2446 - "optional": true, 2447 - "dependencies": { 2448 - "boolean": "^3.0.1", 2449 - "es6-error": "^4.1.1", 2450 - "matcher": "^3.0.0", 2451 - "roarr": "^2.15.3", 2452 - "semver": "^7.3.2", 2453 - "serialize-error": "^7.0.1" 2454 - }, 2455 - "engines": { 2456 - "node": ">=10.0" 2457 - } 2458 - }, 2459 - "node_modules/global-agent/node_modules/semver": { 2460 - "version": "7.8.1", 2461 - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", 2462 - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", 2463 - "dev": true, 2464 - "license": "ISC", 2465 - "optional": true, 2466 2725 "bin": { 2467 - "semver": "bin/semver.js" 2468 - }, 2469 - "engines": { 2470 - "node": ">=10" 2471 - } 2472 - }, 2473 - "node_modules/globalthis": { 2474 - "version": "1.0.4", 2475 - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", 2476 - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", 2477 - "dev": true, 2478 - "license": "MIT", 2479 - "optional": true, 2480 - "dependencies": { 2481 - "define-properties": "^1.2.1", 2482 - "gopd": "^1.0.1" 2483 - }, 2484 - "engines": { 2485 - "node": ">= 0.4" 2726 + "glob": "dist/esm/bin.mjs" 2486 2727 }, 2487 2728 "funding": { 2488 - "url": "https://github.com/sponsors/ljharb" 2729 + "url": "https://github.com/sponsors/isaacs" 2489 2730 } 2490 2731 }, 2491 2732 "node_modules/gopd": { ··· 2500 2741 "url": "https://github.com/sponsors/ljharb" 2501 2742 } 2502 2743 }, 2503 - "node_modules/got": { 2504 - "version": "11.8.6", 2505 - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", 2506 - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", 2744 + "node_modules/has-flag": { 2745 + "version": "4.0.0", 2746 + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 2747 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 2507 2748 "dev": true, 2508 2749 "license": "MIT", 2509 - "dependencies": { 2510 - "@sindresorhus/is": "^4.0.0", 2511 - "@szmarczak/http-timer": "^4.0.5", 2512 - "@types/cacheable-request": "^6.0.1", 2513 - "@types/responselike": "^1.0.0", 2514 - "cacheable-lookup": "^5.0.3", 2515 - "cacheable-request": "^7.0.2", 2516 - "decompress-response": "^6.0.0", 2517 - "http2-wrapper": "^1.0.0-beta.5.2", 2518 - "lowercase-keys": "^2.0.0", 2519 - "p-cancelable": "^2.0.0", 2520 - "responselike": "^2.0.0" 2521 - }, 2522 2750 "engines": { 2523 - "node": ">=10.19.0" 2524 - }, 2525 - "funding": { 2526 - "url": "https://github.com/sindresorhus/got?sponsor=1" 2527 - } 2528 - }, 2529 - "node_modules/graceful-fs": { 2530 - "version": "4.2.11", 2531 - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", 2532 - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", 2533 - "dev": true, 2534 - "license": "ISC" 2535 - }, 2536 - "node_modules/has-property-descriptors": { 2537 - "version": "1.0.2", 2538 - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", 2539 - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", 2540 - "dev": true, 2541 - "license": "MIT", 2542 - "optional": true, 2543 - "dependencies": { 2544 - "es-define-property": "^1.0.0" 2545 - }, 2546 - "funding": { 2547 - "url": "https://github.com/sponsors/ljharb" 2751 + "node": ">=8" 2548 2752 } 2549 2753 }, 2550 2754 "node_modules/has-symbols": { ··· 2636 2840 "url": "https://opencollective.com/unified" 2637 2841 } 2638 2842 }, 2639 - "node_modules/http-cache-semantics": { 2640 - "version": "4.2.0", 2641 - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", 2642 - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", 2643 - "dev": true, 2644 - "license": "BSD-2-Clause" 2645 - }, 2646 - "node_modules/http2-wrapper": { 2647 - "version": "1.0.3", 2648 - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", 2649 - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", 2650 - "dev": true, 2843 + "node_modules/http-errors": { 2844 + "version": "2.0.0", 2845 + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", 2846 + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 2651 2847 "license": "MIT", 2652 2848 "dependencies": { 2653 - "quick-lru": "^5.1.1", 2654 - "resolve-alpn": "^1.0.0" 2849 + "depd": "2.0.0", 2850 + "inherits": "2.0.4", 2851 + "setprototypeof": "1.2.0", 2852 + "statuses": "2.0.1", 2853 + "toidentifier": "1.0.1" 2655 2854 }, 2656 2855 "engines": { 2657 - "node": ">=10.19.0" 2856 + "node": ">= 0.8" 2658 2857 } 2659 2858 }, 2660 2859 "node_modules/humanize-ms": { ··· 2704 2903 "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", 2705 2904 "license": "MIT" 2706 2905 }, 2906 + "node_modules/ipaddr.js": { 2907 + "version": "1.9.1", 2908 + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", 2909 + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", 2910 + "license": "MIT", 2911 + "engines": { 2912 + "node": ">= 0.10" 2913 + } 2914 + }, 2707 2915 "node_modules/is-alphabetical": { 2708 2916 "version": "2.0.1", 2709 2917 "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", ··· 2728 2936 "url": "https://github.com/sponsors/wooorm" 2729 2937 } 2730 2938 }, 2731 - "node_modules/is-binary-path": { 2732 - "version": "2.1.0", 2733 - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", 2734 - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", 2735 - "license": "MIT", 2736 - "dependencies": { 2737 - "binary-extensions": "^2.0.0" 2738 - }, 2739 - "engines": { 2740 - "node": ">=8" 2741 - } 2742 - }, 2743 2939 "node_modules/is-decimal": { 2744 2940 "version": "2.0.1", 2745 2941 "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", ··· 2750 2946 "url": "https://github.com/sponsors/wooorm" 2751 2947 } 2752 2948 }, 2753 - "node_modules/is-extglob": { 2754 - "version": "2.1.1", 2755 - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", 2756 - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", 2757 - "license": "MIT", 2758 - "engines": { 2759 - "node": ">=0.10.0" 2760 - } 2761 - }, 2762 - "node_modules/is-glob": { 2763 - "version": "4.0.3", 2764 - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", 2765 - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", 2949 + "node_modules/is-fullwidth-code-point": { 2950 + "version": "3.0.0", 2951 + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", 2952 + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", 2766 2953 "license": "MIT", 2767 - "dependencies": { 2768 - "is-extglob": "^2.1.1" 2769 - }, 2770 2954 "engines": { 2771 - "node": ">=0.10.0" 2955 + "node": ">=8" 2772 2956 } 2773 2957 }, 2774 2958 "node_modules/is-hexadecimal": { ··· 2781 2965 "url": "https://github.com/sponsors/wooorm" 2782 2966 } 2783 2967 }, 2784 - "node_modules/is-number": { 2785 - "version": "7.0.0", 2786 - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", 2787 - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", 2788 - "license": "MIT", 2789 - "engines": { 2790 - "node": ">=0.12.0" 2791 - } 2792 - }, 2793 2968 "node_modules/is-plain-obj": { 2794 2969 "version": "4.1.0", 2795 2970 "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", ··· 2802 2977 "url": "https://github.com/sponsors/sindresorhus" 2803 2978 } 2804 2979 }, 2980 + "node_modules/isexe": { 2981 + "version": "2.0.0", 2982 + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 2983 + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", 2984 + "license": "ISC" 2985 + }, 2986 + "node_modules/jackspeak": { 2987 + "version": "3.4.3", 2988 + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", 2989 + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", 2990 + "license": "BlueOak-1.0.0", 2991 + "dependencies": { 2992 + "@isaacs/cliui": "^8.0.2" 2993 + }, 2994 + "funding": { 2995 + "url": "https://github.com/sponsors/isaacs" 2996 + }, 2997 + "optionalDependencies": { 2998 + "@pkgjs/parseargs": "^0.11.0" 2999 + } 3000 + }, 2805 3001 "node_modules/js-tokens": { 2806 3002 "version": "4.0.0", 2807 3003 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", ··· 2821 3017 "node": ">=6" 2822 3018 } 2823 3019 }, 2824 - "node_modules/json-buffer": { 2825 - "version": "3.0.1", 2826 - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", 2827 - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", 2828 - "dev": true, 2829 - "license": "MIT" 3020 + "node_modules/json-schema-ref-resolver": { 3021 + "version": "1.0.1", 3022 + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", 3023 + "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", 3024 + "license": "MIT", 3025 + "dependencies": { 3026 + "fast-deep-equal": "^3.1.3" 3027 + } 2830 3028 }, 2831 - "node_modules/json-stringify-safe": { 2832 - "version": "5.0.1", 2833 - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", 2834 - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", 2835 - "dev": true, 2836 - "license": "ISC", 2837 - "optional": true 3029 + "node_modules/json-schema-traverse": { 3030 + "version": "1.0.0", 3031 + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", 3032 + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", 3033 + "license": "MIT" 2838 3034 }, 2839 3035 "node_modules/json5": { 2840 3036 "version": "2.2.3", ··· 2849 3045 "node": ">=6" 2850 3046 } 2851 3047 }, 2852 - "node_modules/jsonfile": { 2853 - "version": "4.0.0", 2854 - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", 2855 - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", 2856 - "dev": true, 2857 - "license": "MIT", 2858 - "optionalDependencies": { 2859 - "graceful-fs": "^4.1.6" 2860 - } 2861 - }, 2862 - "node_modules/keytar": { 2863 - "version": "7.9.0", 2864 - "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", 2865 - "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", 2866 - "hasInstallScript": true, 2867 - "license": "MIT", 3048 + "node_modules/light-my-request": { 3049 + "version": "5.14.0", 3050 + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", 3051 + "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", 3052 + "license": "BSD-3-Clause", 2868 3053 "dependencies": { 2869 - "node-addon-api": "^4.3.0", 2870 - "prebuild-install": "^7.0.1" 2871 - } 2872 - }, 2873 - "node_modules/keyv": { 2874 - "version": "4.5.4", 2875 - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", 2876 - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", 2877 - "dev": true, 2878 - "license": "MIT", 2879 - "dependencies": { 2880 - "json-buffer": "3.0.1" 3054 + "cookie": "^0.7.0", 3055 + "process-warning": "^3.0.0", 3056 + "set-cookie-parser": "^2.4.1" 2881 3057 } 2882 3058 }, 2883 3059 "node_modules/longest-streak": { ··· 2902 3078 "loose-envify": "cli.js" 2903 3079 } 2904 3080 }, 2905 - "node_modules/lowercase-keys": { 2906 - "version": "2.0.0", 2907 - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", 2908 - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", 2909 - "dev": true, 2910 - "license": "MIT", 2911 - "engines": { 2912 - "node": ">=8" 2913 - } 2914 - }, 2915 3081 "node_modules/lru-cache": { 2916 3082 "version": "5.1.1", 2917 3083 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", ··· 2922 3088 "yallist": "^3.0.2" 2923 3089 } 2924 3090 }, 2925 - "node_modules/magic-string": { 2926 - "version": "0.30.21", 2927 - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", 2928 - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", 2929 - "dev": true, 2930 - "license": "MIT", 2931 - "dependencies": { 2932 - "@jridgewell/sourcemap-codec": "^1.5.5" 2933 - } 2934 - }, 2935 3091 "node_modules/markdown-table": { 2936 3092 "version": "3.0.4", 2937 3093 "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", ··· 2940 3096 "funding": { 2941 3097 "type": "github", 2942 3098 "url": "https://github.com/sponsors/wooorm" 2943 - } 2944 - }, 2945 - "node_modules/matcher": { 2946 - "version": "3.0.0", 2947 - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", 2948 - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", 2949 - "dev": true, 2950 - "license": "MIT", 2951 - "optional": true, 2952 - "dependencies": { 2953 - "escape-string-regexp": "^4.0.0" 2954 - }, 2955 - "engines": { 2956 - "node": ">=10" 2957 3099 } 2958 3100 }, 2959 3101 "node_modules/math-intrinsics": { ··· 3810 3952 ], 3811 3953 "license": "MIT" 3812 3954 }, 3955 + "node_modules/mime": { 3956 + "version": "3.0.0", 3957 + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", 3958 + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", 3959 + "license": "MIT", 3960 + "bin": { 3961 + "mime": "cli.js" 3962 + }, 3963 + "engines": { 3964 + "node": ">=10.0.0" 3965 + } 3966 + }, 3813 3967 "node_modules/mime-db": { 3814 3968 "version": "1.52.0", 3815 3969 "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", ··· 3832 3986 } 3833 3987 }, 3834 3988 "node_modules/mimic-response": { 3835 - "version": "1.0.1", 3836 - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", 3837 - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", 3838 - "dev": true, 3989 + "version": "3.1.0", 3990 + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", 3991 + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", 3839 3992 "license": "MIT", 3840 3993 "engines": { 3841 - "node": ">=4" 3994 + "node": ">=10" 3995 + }, 3996 + "funding": { 3997 + "url": "https://github.com/sponsors/sindresorhus" 3998 + } 3999 + }, 4000 + "node_modules/minimatch": { 4001 + "version": "9.0.9", 4002 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", 4003 + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", 4004 + "license": "ISC", 4005 + "dependencies": { 4006 + "brace-expansion": "^2.0.2" 4007 + }, 4008 + "engines": { 4009 + "node": ">=16 || 14 >=14.17" 4010 + }, 4011 + "funding": { 4012 + "url": "https://github.com/sponsors/isaacs" 3842 4013 } 3843 4014 }, 3844 4015 "node_modules/minimist": { ··· 3848 4019 "license": "MIT", 3849 4020 "funding": { 3850 4021 "url": "https://github.com/sponsors/ljharb" 4022 + } 4023 + }, 4024 + "node_modules/minipass": { 4025 + "version": "7.1.3", 4026 + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", 4027 + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", 4028 + "license": "BlueOak-1.0.0", 4029 + "engines": { 4030 + "node": ">=16 || 14 >=14.17" 3851 4031 } 3852 4032 }, 3853 4033 "node_modules/mkdirp-classic": { ··· 3911 4091 "node": ">=10" 3912 4092 } 3913 4093 }, 3914 - "node_modules/node-addon-api": { 3915 - "version": "4.3.0", 3916 - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", 3917 - "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", 3918 - "license": "MIT" 3919 - }, 3920 4094 "node_modules/node-domexception": { 3921 4095 "version": "1.0.0", 3922 4096 "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", ··· 3967 4141 "node": ">=18" 3968 4142 } 3969 4143 }, 3970 - "node_modules/normalize-path": { 3971 - "version": "3.0.0", 3972 - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", 3973 - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", 4144 + "node_modules/on-exit-leak-free": { 4145 + "version": "2.1.2", 4146 + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", 4147 + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", 3974 4148 "license": "MIT", 3975 4149 "engines": { 3976 - "node": ">=0.10.0" 3977 - } 3978 - }, 3979 - "node_modules/normalize-url": { 3980 - "version": "6.1.0", 3981 - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", 3982 - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", 3983 - "dev": true, 3984 - "license": "MIT", 3985 - "engines": { 3986 - "node": ">=10" 3987 - }, 3988 - "funding": { 3989 - "url": "https://github.com/sponsors/sindresorhus" 3990 - } 3991 - }, 3992 - "node_modules/object-keys": { 3993 - "version": "1.1.1", 3994 - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", 3995 - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", 3996 - "dev": true, 3997 - "license": "MIT", 3998 - "optional": true, 3999 - "engines": { 4000 - "node": ">= 0.4" 4150 + "node": ">=14.0.0" 4001 4151 } 4002 4152 }, 4003 4153 "node_modules/once": { ··· 4009 4159 "wrappy": "1" 4010 4160 } 4011 4161 }, 4012 - "node_modules/p-cancelable": { 4013 - "version": "2.1.1", 4014 - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", 4015 - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", 4016 - "dev": true, 4017 - "license": "MIT", 4018 - "engines": { 4019 - "node": ">=8" 4020 - } 4162 + "node_modules/package-json-from-dist": { 4163 + "version": "1.0.1", 4164 + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", 4165 + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", 4166 + "license": "BlueOak-1.0.0" 4021 4167 }, 4022 4168 "node_modules/parse-entities": { 4023 4169 "version": "4.0.2", ··· 4044 4190 "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", 4045 4191 "license": "MIT" 4046 4192 }, 4047 - "node_modules/pend": { 4048 - "version": "1.2.0", 4049 - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", 4050 - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", 4051 - "dev": true, 4052 - "license": "MIT" 4193 + "node_modules/path-key": { 4194 + "version": "3.1.1", 4195 + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 4196 + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 4197 + "license": "MIT", 4198 + "engines": { 4199 + "node": ">=8" 4200 + } 4201 + }, 4202 + "node_modules/path-scurry": { 4203 + "version": "1.11.1", 4204 + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", 4205 + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", 4206 + "license": "BlueOak-1.0.0", 4207 + "dependencies": { 4208 + "lru-cache": "^10.2.0", 4209 + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" 4210 + }, 4211 + "engines": { 4212 + "node": ">=16 || 14 >=14.18" 4213 + }, 4214 + "funding": { 4215 + "url": "https://github.com/sponsors/isaacs" 4216 + } 4217 + }, 4218 + "node_modules/path-scurry/node_modules/lru-cache": { 4219 + "version": "10.4.3", 4220 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 4221 + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 4222 + "license": "ISC" 4053 4223 }, 4054 4224 "node_modules/picocolors": { 4055 4225 "version": "1.1.1", ··· 4058 4228 "dev": true, 4059 4229 "license": "ISC" 4060 4230 }, 4061 - "node_modules/picomatch": { 4062 - "version": "2.3.2", 4063 - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", 4064 - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", 4231 + "node_modules/pino": { 4232 + "version": "9.14.0", 4233 + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", 4234 + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", 4065 4235 "license": "MIT", 4066 - "engines": { 4067 - "node": ">=8.6" 4236 + "dependencies": { 4237 + "@pinojs/redact": "^0.4.0", 4238 + "atomic-sleep": "^1.0.0", 4239 + "on-exit-leak-free": "^2.1.0", 4240 + "pino-abstract-transport": "^2.0.0", 4241 + "pino-std-serializers": "^7.0.0", 4242 + "process-warning": "^5.0.0", 4243 + "quick-format-unescaped": "^4.0.3", 4244 + "real-require": "^0.2.0", 4245 + "safe-stable-stringify": "^2.3.1", 4246 + "sonic-boom": "^4.0.1", 4247 + "thread-stream": "^3.0.0" 4068 4248 }, 4069 - "funding": { 4070 - "url": "https://github.com/sponsors/jonschlinkert" 4249 + "bin": { 4250 + "pino": "bin.js" 4071 4251 } 4252 + }, 4253 + "node_modules/pino-abstract-transport": { 4254 + "version": "2.0.0", 4255 + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", 4256 + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", 4257 + "license": "MIT", 4258 + "dependencies": { 4259 + "split2": "^4.0.0" 4260 + } 4261 + }, 4262 + "node_modules/pino-std-serializers": { 4263 + "version": "7.1.0", 4264 + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", 4265 + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", 4266 + "license": "MIT" 4267 + }, 4268 + "node_modules/pino/node_modules/process-warning": { 4269 + "version": "5.0.0", 4270 + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", 4271 + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", 4272 + "funding": [ 4273 + { 4274 + "type": "github", 4275 + "url": "https://github.com/sponsors/fastify" 4276 + }, 4277 + { 4278 + "type": "opencollective", 4279 + "url": "https://opencollective.com/fastify" 4280 + } 4281 + ], 4282 + "license": "MIT" 4072 4283 }, 4073 4284 "node_modules/postcss": { 4074 4285 "version": "8.5.15", ··· 4126 4337 "node": ">=10" 4127 4338 } 4128 4339 }, 4129 - "node_modules/progress": { 4130 - "version": "2.0.3", 4131 - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", 4132 - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", 4133 - "dev": true, 4134 - "license": "MIT", 4135 - "engines": { 4136 - "node": ">=0.4.0" 4137 - } 4340 + "node_modules/process-warning": { 4341 + "version": "3.0.0", 4342 + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", 4343 + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", 4344 + "license": "MIT" 4138 4345 }, 4139 4346 "node_modules/property-information": { 4140 4347 "version": "7.1.0", ··· 4146 4353 "url": "https://github.com/sponsors/wooorm" 4147 4354 } 4148 4355 }, 4356 + "node_modules/proxy-addr": { 4357 + "version": "2.0.7", 4358 + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", 4359 + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 4360 + "license": "MIT", 4361 + "dependencies": { 4362 + "forwarded": "0.2.0", 4363 + "ipaddr.js": "1.9.1" 4364 + }, 4365 + "engines": { 4366 + "node": ">= 0.10" 4367 + } 4368 + }, 4149 4369 "node_modules/pump": { 4150 4370 "version": "3.0.4", 4151 4371 "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", ··· 4156 4376 "once": "^1.3.1" 4157 4377 } 4158 4378 }, 4159 - "node_modules/quick-lru": { 4160 - "version": "5.1.1", 4161 - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", 4162 - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", 4163 - "dev": true, 4164 - "license": "MIT", 4165 - "engines": { 4166 - "node": ">=10" 4167 - }, 4168 - "funding": { 4169 - "url": "https://github.com/sponsors/sindresorhus" 4170 - } 4379 + "node_modules/quick-format-unescaped": { 4380 + "version": "4.0.4", 4381 + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", 4382 + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", 4383 + "license": "MIT" 4171 4384 }, 4172 4385 "node_modules/rc": { 4173 4386 "version": "1.2.8", ··· 4270 4483 "node": ">= 6" 4271 4484 } 4272 4485 }, 4273 - "node_modules/readdirp": { 4274 - "version": "3.6.0", 4275 - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", 4276 - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", 4486 + "node_modules/real-require": { 4487 + "version": "0.2.0", 4488 + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", 4489 + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", 4277 4490 "license": "MIT", 4278 - "dependencies": { 4279 - "picomatch": "^2.2.1" 4280 - }, 4281 4491 "engines": { 4282 - "node": ">=8.10.0" 4492 + "node": ">= 12.13.0" 4283 4493 } 4284 4494 }, 4285 4495 "node_modules/remark-gfm": { ··· 4348 4558 "url": "https://opencollective.com/unified" 4349 4559 } 4350 4560 }, 4351 - "node_modules/resolve-alpn": { 4352 - "version": "1.2.1", 4353 - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", 4354 - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", 4561 + "node_modules/require-directory": { 4562 + "version": "2.1.1", 4563 + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 4564 + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", 4355 4565 "dev": true, 4356 - "license": "MIT" 4566 + "license": "MIT", 4567 + "engines": { 4568 + "node": ">=0.10.0" 4569 + } 4357 4570 }, 4358 - "node_modules/responselike": { 4359 - "version": "2.0.1", 4360 - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", 4361 - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", 4362 - "dev": true, 4571 + "node_modules/require-from-string": { 4572 + "version": "2.0.2", 4573 + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", 4574 + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", 4575 + "license": "MIT", 4576 + "engines": { 4577 + "node": ">=0.10.0" 4578 + } 4579 + }, 4580 + "node_modules/ret": { 4581 + "version": "0.4.3", 4582 + "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", 4583 + "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", 4363 4584 "license": "MIT", 4364 - "dependencies": { 4365 - "lowercase-keys": "^2.0.0" 4366 - }, 4367 - "funding": { 4368 - "url": "https://github.com/sponsors/sindresorhus" 4585 + "engines": { 4586 + "node": ">=10" 4369 4587 } 4370 4588 }, 4371 - "node_modules/roarr": { 4372 - "version": "2.15.4", 4373 - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", 4374 - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", 4375 - "dev": true, 4376 - "license": "BSD-3-Clause", 4377 - "optional": true, 4378 - "dependencies": { 4379 - "boolean": "^3.0.1", 4380 - "detect-node": "^2.0.4", 4381 - "globalthis": "^1.0.1", 4382 - "json-stringify-safe": "^5.0.1", 4383 - "semver-compare": "^1.0.0", 4384 - "sprintf-js": "^1.1.2" 4385 - }, 4589 + "node_modules/reusify": { 4590 + "version": "1.1.0", 4591 + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", 4592 + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", 4593 + "license": "MIT", 4386 4594 "engines": { 4387 - "node": ">=8.0" 4595 + "iojs": ">=1.0.0", 4596 + "node": ">=0.10.0" 4388 4597 } 4598 + }, 4599 + "node_modules/rfdc": { 4600 + "version": "1.4.1", 4601 + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", 4602 + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", 4603 + "license": "MIT" 4389 4604 }, 4390 4605 "node_modules/rollup": { 4391 4606 "version": "4.60.4", ··· 4432 4647 "fsevents": "~2.3.2" 4433 4648 } 4434 4649 }, 4650 + "node_modules/rxjs": { 4651 + "version": "7.8.2", 4652 + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", 4653 + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", 4654 + "dev": true, 4655 + "license": "Apache-2.0", 4656 + "dependencies": { 4657 + "tslib": "^2.1.0" 4658 + } 4659 + }, 4435 4660 "node_modules/safe-buffer": { 4436 4661 "version": "5.2.1", 4437 4662 "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", ··· 4452 4677 ], 4453 4678 "license": "MIT" 4454 4679 }, 4680 + "node_modules/safe-regex2": { 4681 + "version": "3.1.0", 4682 + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", 4683 + "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", 4684 + "license": "MIT", 4685 + "dependencies": { 4686 + "ret": "~0.4.0" 4687 + } 4688 + }, 4689 + "node_modules/safe-stable-stringify": { 4690 + "version": "2.5.0", 4691 + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", 4692 + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", 4693 + "license": "MIT", 4694 + "engines": { 4695 + "node": ">=10" 4696 + } 4697 + }, 4455 4698 "node_modules/scheduler": { 4456 4699 "version": "0.23.2", 4457 4700 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", ··· 4461 4704 "loose-envify": "^1.1.0" 4462 4705 } 4463 4706 }, 4707 + "node_modules/secure-json-parse": { 4708 + "version": "2.7.0", 4709 + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", 4710 + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", 4711 + "license": "BSD-3-Clause" 4712 + }, 4464 4713 "node_modules/semver": { 4465 4714 "version": "6.3.1", 4466 4715 "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", ··· 4471 4720 "semver": "bin/semver.js" 4472 4721 } 4473 4722 }, 4474 - "node_modules/semver-compare": { 4475 - "version": "1.0.0", 4476 - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", 4477 - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", 4478 - "dev": true, 4723 + "node_modules/set-cookie-parser": { 4724 + "version": "2.7.2", 4725 + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", 4726 + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", 4727 + "license": "MIT" 4728 + }, 4729 + "node_modules/setprototypeof": { 4730 + "version": "1.2.0", 4731 + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", 4732 + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", 4733 + "license": "ISC" 4734 + }, 4735 + "node_modules/shebang-command": { 4736 + "version": "2.0.0", 4737 + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 4738 + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 4479 4739 "license": "MIT", 4480 - "optional": true 4740 + "dependencies": { 4741 + "shebang-regex": "^3.0.0" 4742 + }, 4743 + "engines": { 4744 + "node": ">=8" 4745 + } 4481 4746 }, 4482 - "node_modules/serialize-error": { 4483 - "version": "7.0.1", 4484 - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", 4485 - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", 4747 + "node_modules/shebang-regex": { 4748 + "version": "3.0.0", 4749 + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 4750 + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 4751 + "license": "MIT", 4752 + "engines": { 4753 + "node": ">=8" 4754 + } 4755 + }, 4756 + "node_modules/shell-quote": { 4757 + "version": "1.8.3", 4758 + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", 4759 + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", 4486 4760 "dev": true, 4487 4761 "license": "MIT", 4488 - "optional": true, 4489 - "dependencies": { 4490 - "type-fest": "^0.13.1" 4762 + "engines": { 4763 + "node": ">= 0.4" 4491 4764 }, 4765 + "funding": { 4766 + "url": "https://github.com/sponsors/ljharb" 4767 + } 4768 + }, 4769 + "node_modules/signal-exit": { 4770 + "version": "4.1.0", 4771 + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", 4772 + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", 4773 + "license": "ISC", 4492 4774 "engines": { 4493 - "node": ">=10" 4775 + "node": ">=14" 4494 4776 }, 4495 4777 "funding": { 4496 - "url": "https://github.com/sponsors/sindresorhus" 4778 + "url": "https://github.com/sponsors/isaacs" 4497 4779 } 4498 4780 }, 4499 4781 "node_modules/simple-concat": { ··· 4541 4823 "simple-concat": "^1.0.0" 4542 4824 } 4543 4825 }, 4826 + "node_modules/sonic-boom": { 4827 + "version": "4.2.1", 4828 + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", 4829 + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", 4830 + "license": "MIT", 4831 + "dependencies": { 4832 + "atomic-sleep": "^1.0.0" 4833 + } 4834 + }, 4544 4835 "node_modules/source-map-js": { 4545 4836 "version": "1.2.1", 4546 4837 "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", ··· 4561 4852 "url": "https://github.com/sponsors/wooorm" 4562 4853 } 4563 4854 }, 4564 - "node_modules/sprintf-js": { 4565 - "version": "1.1.3", 4566 - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", 4567 - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", 4568 - "dev": true, 4569 - "license": "BSD-3-Clause", 4570 - "optional": true 4855 + "node_modules/split2": { 4856 + "version": "4.2.0", 4857 + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", 4858 + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", 4859 + "license": "ISC", 4860 + "engines": { 4861 + "node": ">= 10.x" 4862 + } 4863 + }, 4864 + "node_modules/statuses": { 4865 + "version": "2.0.1", 4866 + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", 4867 + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", 4868 + "license": "MIT", 4869 + "engines": { 4870 + "node": ">= 0.8" 4871 + } 4872 + }, 4873 + "node_modules/stream-shift": { 4874 + "version": "1.0.3", 4875 + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", 4876 + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", 4877 + "license": "MIT" 4571 4878 }, 4572 4879 "node_modules/string_decoder": { 4573 4880 "version": "1.3.0", ··· 4578 4885 "safe-buffer": "~5.2.0" 4579 4886 } 4580 4887 }, 4888 + "node_modules/string-width": { 4889 + "version": "5.1.2", 4890 + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", 4891 + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", 4892 + "license": "MIT", 4893 + "dependencies": { 4894 + "eastasianwidth": "^0.2.0", 4895 + "emoji-regex": "^9.2.2", 4896 + "strip-ansi": "^7.0.1" 4897 + }, 4898 + "engines": { 4899 + "node": ">=12" 4900 + }, 4901 + "funding": { 4902 + "url": "https://github.com/sponsors/sindresorhus" 4903 + } 4904 + }, 4905 + "node_modules/string-width-cjs": { 4906 + "name": "string-width", 4907 + "version": "4.2.3", 4908 + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 4909 + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 4910 + "license": "MIT", 4911 + "dependencies": { 4912 + "emoji-regex": "^8.0.0", 4913 + "is-fullwidth-code-point": "^3.0.0", 4914 + "strip-ansi": "^6.0.1" 4915 + }, 4916 + "engines": { 4917 + "node": ">=8" 4918 + } 4919 + }, 4920 + "node_modules/string-width-cjs/node_modules/ansi-regex": { 4921 + "version": "5.0.1", 4922 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 4923 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 4924 + "license": "MIT", 4925 + "engines": { 4926 + "node": ">=8" 4927 + } 4928 + }, 4929 + "node_modules/string-width-cjs/node_modules/emoji-regex": { 4930 + "version": "8.0.0", 4931 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 4932 + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 4933 + "license": "MIT" 4934 + }, 4935 + "node_modules/string-width-cjs/node_modules/strip-ansi": { 4936 + "version": "6.0.1", 4937 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 4938 + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 4939 + "license": "MIT", 4940 + "dependencies": { 4941 + "ansi-regex": "^5.0.1" 4942 + }, 4943 + "engines": { 4944 + "node": ">=8" 4945 + } 4946 + }, 4581 4947 "node_modules/stringify-entities": { 4582 4948 "version": "4.0.4", 4583 4949 "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", ··· 4592 4958 "url": "https://github.com/sponsors/wooorm" 4593 4959 } 4594 4960 }, 4961 + "node_modules/strip-ansi": { 4962 + "version": "7.2.0", 4963 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", 4964 + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", 4965 + "license": "MIT", 4966 + "dependencies": { 4967 + "ansi-regex": "^6.2.2" 4968 + }, 4969 + "engines": { 4970 + "node": ">=12" 4971 + }, 4972 + "funding": { 4973 + "url": "https://github.com/chalk/strip-ansi?sponsor=1" 4974 + } 4975 + }, 4976 + "node_modules/strip-ansi-cjs": { 4977 + "name": "strip-ansi", 4978 + "version": "6.0.1", 4979 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 4980 + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 4981 + "license": "MIT", 4982 + "dependencies": { 4983 + "ansi-regex": "^5.0.1" 4984 + }, 4985 + "engines": { 4986 + "node": ">=8" 4987 + } 4988 + }, 4989 + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { 4990 + "version": "5.0.1", 4991 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 4992 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 4993 + "license": "MIT", 4994 + "engines": { 4995 + "node": ">=8" 4996 + } 4997 + }, 4595 4998 "node_modules/strip-json-comments": { 4596 4999 "version": "2.0.1", 4597 5000 "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", ··· 4619 5022 "inline-style-parser": "0.2.7" 4620 5023 } 4621 5024 }, 4622 - "node_modules/sumchecker": { 4623 - "version": "3.0.1", 4624 - "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", 4625 - "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", 5025 + "node_modules/supports-color": { 5026 + "version": "8.1.1", 5027 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", 5028 + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", 4626 5029 "dev": true, 4627 - "license": "Apache-2.0", 5030 + "license": "MIT", 4628 5031 "dependencies": { 4629 - "debug": "^4.1.0" 5032 + "has-flag": "^4.0.0" 4630 5033 }, 4631 5034 "engines": { 4632 - "node": ">= 8.0" 5035 + "node": ">=10" 5036 + }, 5037 + "funding": { 5038 + "url": "https://github.com/chalk/supports-color?sponsor=1" 4633 5039 } 4634 5040 }, 4635 5041 "node_modules/tar-fs": { ··· 4660 5066 "node": ">=6" 4661 5067 } 4662 5068 }, 4663 - "node_modules/to-regex-range": { 4664 - "version": "5.0.1", 4665 - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", 4666 - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", 5069 + "node_modules/thread-stream": { 5070 + "version": "3.1.0", 5071 + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", 5072 + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", 4667 5073 "license": "MIT", 4668 5074 "dependencies": { 4669 - "is-number": "^7.0.0" 4670 - }, 5075 + "real-require": "^0.2.0" 5076 + } 5077 + }, 5078 + "node_modules/toad-cache": { 5079 + "version": "3.7.1", 5080 + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", 5081 + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", 5082 + "license": "MIT", 4671 5083 "engines": { 4672 - "node": ">=8.0" 5084 + "node": ">=20" 5085 + } 5086 + }, 5087 + "node_modules/toidentifier": { 5088 + "version": "1.0.1", 5089 + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", 5090 + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", 5091 + "license": "MIT", 5092 + "engines": { 5093 + "node": ">=0.6" 4673 5094 } 4674 5095 }, 4675 5096 "node_modules/tr46": { ··· 4678 5099 "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", 4679 5100 "license": "MIT" 4680 5101 }, 5102 + "node_modules/tree-kill": { 5103 + "version": "1.2.2", 5104 + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", 5105 + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", 5106 + "dev": true, 5107 + "license": "MIT", 5108 + "bin": { 5109 + "tree-kill": "cli.js" 5110 + } 5111 + }, 4681 5112 "node_modules/trim-lines": { 4682 5113 "version": "3.0.1", 4683 5114 "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", ··· 4698 5129 "url": "https://github.com/sponsors/wooorm" 4699 5130 } 4700 5131 }, 5132 + "node_modules/tslib": { 5133 + "version": "2.8.1", 5134 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 5135 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 5136 + "dev": true, 5137 + "license": "0BSD" 5138 + }, 5139 + "node_modules/tsx": { 5140 + "version": "4.22.3", 5141 + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", 5142 + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", 5143 + "dev": true, 5144 + "license": "MIT", 5145 + "dependencies": { 5146 + "esbuild": "~0.28.0" 5147 + }, 5148 + "bin": { 5149 + "tsx": "dist/cli.mjs" 5150 + }, 5151 + "engines": { 5152 + "node": ">=18.0.0" 5153 + }, 5154 + "optionalDependencies": { 5155 + "fsevents": "~2.3.3" 5156 + } 5157 + }, 5158 + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { 5159 + "version": "0.28.0", 5160 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", 5161 + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", 5162 + "cpu": [ 5163 + "ppc64" 5164 + ], 5165 + "dev": true, 5166 + "license": "MIT", 5167 + "optional": true, 5168 + "os": [ 5169 + "aix" 5170 + ], 5171 + "engines": { 5172 + "node": ">=18" 5173 + } 5174 + }, 5175 + "node_modules/tsx/node_modules/@esbuild/android-arm": { 5176 + "version": "0.28.0", 5177 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", 5178 + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", 5179 + "cpu": [ 5180 + "arm" 5181 + ], 5182 + "dev": true, 5183 + "license": "MIT", 5184 + "optional": true, 5185 + "os": [ 5186 + "android" 5187 + ], 5188 + "engines": { 5189 + "node": ">=18" 5190 + } 5191 + }, 5192 + "node_modules/tsx/node_modules/@esbuild/android-arm64": { 5193 + "version": "0.28.0", 5194 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", 5195 + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", 5196 + "cpu": [ 5197 + "arm64" 5198 + ], 5199 + "dev": true, 5200 + "license": "MIT", 5201 + "optional": true, 5202 + "os": [ 5203 + "android" 5204 + ], 5205 + "engines": { 5206 + "node": ">=18" 5207 + } 5208 + }, 5209 + "node_modules/tsx/node_modules/@esbuild/android-x64": { 5210 + "version": "0.28.0", 5211 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", 5212 + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", 5213 + "cpu": [ 5214 + "x64" 5215 + ], 5216 + "dev": true, 5217 + "license": "MIT", 5218 + "optional": true, 5219 + "os": [ 5220 + "android" 5221 + ], 5222 + "engines": { 5223 + "node": ">=18" 5224 + } 5225 + }, 5226 + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { 5227 + "version": "0.28.0", 5228 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", 5229 + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", 5230 + "cpu": [ 5231 + "arm64" 5232 + ], 5233 + "dev": true, 5234 + "license": "MIT", 5235 + "optional": true, 5236 + "os": [ 5237 + "darwin" 5238 + ], 5239 + "engines": { 5240 + "node": ">=18" 5241 + } 5242 + }, 5243 + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { 5244 + "version": "0.28.0", 5245 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", 5246 + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", 5247 + "cpu": [ 5248 + "x64" 5249 + ], 5250 + "dev": true, 5251 + "license": "MIT", 5252 + "optional": true, 5253 + "os": [ 5254 + "darwin" 5255 + ], 5256 + "engines": { 5257 + "node": ">=18" 5258 + } 5259 + }, 5260 + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { 5261 + "version": "0.28.0", 5262 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", 5263 + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", 5264 + "cpu": [ 5265 + "arm64" 5266 + ], 5267 + "dev": true, 5268 + "license": "MIT", 5269 + "optional": true, 5270 + "os": [ 5271 + "freebsd" 5272 + ], 5273 + "engines": { 5274 + "node": ">=18" 5275 + } 5276 + }, 5277 + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { 5278 + "version": "0.28.0", 5279 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", 5280 + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", 5281 + "cpu": [ 5282 + "x64" 5283 + ], 5284 + "dev": true, 5285 + "license": "MIT", 5286 + "optional": true, 5287 + "os": [ 5288 + "freebsd" 5289 + ], 5290 + "engines": { 5291 + "node": ">=18" 5292 + } 5293 + }, 5294 + "node_modules/tsx/node_modules/@esbuild/linux-arm": { 5295 + "version": "0.28.0", 5296 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", 5297 + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", 5298 + "cpu": [ 5299 + "arm" 5300 + ], 5301 + "dev": true, 5302 + "license": "MIT", 5303 + "optional": true, 5304 + "os": [ 5305 + "linux" 5306 + ], 5307 + "engines": { 5308 + "node": ">=18" 5309 + } 5310 + }, 5311 + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { 5312 + "version": "0.28.0", 5313 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", 5314 + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", 5315 + "cpu": [ 5316 + "arm64" 5317 + ], 5318 + "dev": true, 5319 + "license": "MIT", 5320 + "optional": true, 5321 + "os": [ 5322 + "linux" 5323 + ], 5324 + "engines": { 5325 + "node": ">=18" 5326 + } 5327 + }, 5328 + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { 5329 + "version": "0.28.0", 5330 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", 5331 + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", 5332 + "cpu": [ 5333 + "ia32" 5334 + ], 5335 + "dev": true, 5336 + "license": "MIT", 5337 + "optional": true, 5338 + "os": [ 5339 + "linux" 5340 + ], 5341 + "engines": { 5342 + "node": ">=18" 5343 + } 5344 + }, 5345 + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { 5346 + "version": "0.28.0", 5347 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", 5348 + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", 5349 + "cpu": [ 5350 + "loong64" 5351 + ], 5352 + "dev": true, 5353 + "license": "MIT", 5354 + "optional": true, 5355 + "os": [ 5356 + "linux" 5357 + ], 5358 + "engines": { 5359 + "node": ">=18" 5360 + } 5361 + }, 5362 + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { 5363 + "version": "0.28.0", 5364 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", 5365 + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", 5366 + "cpu": [ 5367 + "mips64el" 5368 + ], 5369 + "dev": true, 5370 + "license": "MIT", 5371 + "optional": true, 5372 + "os": [ 5373 + "linux" 5374 + ], 5375 + "engines": { 5376 + "node": ">=18" 5377 + } 5378 + }, 5379 + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { 5380 + "version": "0.28.0", 5381 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", 5382 + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", 5383 + "cpu": [ 5384 + "ppc64" 5385 + ], 5386 + "dev": true, 5387 + "license": "MIT", 5388 + "optional": true, 5389 + "os": [ 5390 + "linux" 5391 + ], 5392 + "engines": { 5393 + "node": ">=18" 5394 + } 5395 + }, 5396 + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { 5397 + "version": "0.28.0", 5398 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", 5399 + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", 5400 + "cpu": [ 5401 + "riscv64" 5402 + ], 5403 + "dev": true, 5404 + "license": "MIT", 5405 + "optional": true, 5406 + "os": [ 5407 + "linux" 5408 + ], 5409 + "engines": { 5410 + "node": ">=18" 5411 + } 5412 + }, 5413 + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { 5414 + "version": "0.28.0", 5415 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", 5416 + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", 5417 + "cpu": [ 5418 + "s390x" 5419 + ], 5420 + "dev": true, 5421 + "license": "MIT", 5422 + "optional": true, 5423 + "os": [ 5424 + "linux" 5425 + ], 5426 + "engines": { 5427 + "node": ">=18" 5428 + } 5429 + }, 5430 + "node_modules/tsx/node_modules/@esbuild/linux-x64": { 5431 + "version": "0.28.0", 5432 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", 5433 + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", 5434 + "cpu": [ 5435 + "x64" 5436 + ], 5437 + "dev": true, 5438 + "license": "MIT", 5439 + "optional": true, 5440 + "os": [ 5441 + "linux" 5442 + ], 5443 + "engines": { 5444 + "node": ">=18" 5445 + } 5446 + }, 5447 + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { 5448 + "version": "0.28.0", 5449 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", 5450 + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", 5451 + "cpu": [ 5452 + "x64" 5453 + ], 5454 + "dev": true, 5455 + "license": "MIT", 5456 + "optional": true, 5457 + "os": [ 5458 + "netbsd" 5459 + ], 5460 + "engines": { 5461 + "node": ">=18" 5462 + } 5463 + }, 5464 + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { 5465 + "version": "0.28.0", 5466 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", 5467 + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", 5468 + "cpu": [ 5469 + "x64" 5470 + ], 5471 + "dev": true, 5472 + "license": "MIT", 5473 + "optional": true, 5474 + "os": [ 5475 + "openbsd" 5476 + ], 5477 + "engines": { 5478 + "node": ">=18" 5479 + } 5480 + }, 5481 + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { 5482 + "version": "0.28.0", 5483 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", 5484 + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", 5485 + "cpu": [ 5486 + "x64" 5487 + ], 5488 + "dev": true, 5489 + "license": "MIT", 5490 + "optional": true, 5491 + "os": [ 5492 + "sunos" 5493 + ], 5494 + "engines": { 5495 + "node": ">=18" 5496 + } 5497 + }, 5498 + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { 5499 + "version": "0.28.0", 5500 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", 5501 + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", 5502 + "cpu": [ 5503 + "arm64" 5504 + ], 5505 + "dev": true, 5506 + "license": "MIT", 5507 + "optional": true, 5508 + "os": [ 5509 + "win32" 5510 + ], 5511 + "engines": { 5512 + "node": ">=18" 5513 + } 5514 + }, 5515 + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { 5516 + "version": "0.28.0", 5517 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", 5518 + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", 5519 + "cpu": [ 5520 + "ia32" 5521 + ], 5522 + "dev": true, 5523 + "license": "MIT", 5524 + "optional": true, 5525 + "os": [ 5526 + "win32" 5527 + ], 5528 + "engines": { 5529 + "node": ">=18" 5530 + } 5531 + }, 5532 + "node_modules/tsx/node_modules/@esbuild/win32-x64": { 5533 + "version": "0.28.0", 5534 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", 5535 + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", 5536 + "cpu": [ 5537 + "x64" 5538 + ], 5539 + "dev": true, 5540 + "license": "MIT", 5541 + "optional": true, 5542 + "os": [ 5543 + "win32" 5544 + ], 5545 + "engines": { 5546 + "node": ">=18" 5547 + } 5548 + }, 5549 + "node_modules/tsx/node_modules/esbuild": { 5550 + "version": "0.28.0", 5551 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", 5552 + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", 5553 + "dev": true, 5554 + "hasInstallScript": true, 5555 + "license": "MIT", 5556 + "bin": { 5557 + "esbuild": "bin/esbuild" 5558 + }, 5559 + "engines": { 5560 + "node": ">=18" 5561 + }, 5562 + "optionalDependencies": { 5563 + "@esbuild/aix-ppc64": "0.28.0", 5564 + "@esbuild/android-arm": "0.28.0", 5565 + "@esbuild/android-arm64": "0.28.0", 5566 + "@esbuild/android-x64": "0.28.0", 5567 + "@esbuild/darwin-arm64": "0.28.0", 5568 + "@esbuild/darwin-x64": "0.28.0", 5569 + "@esbuild/freebsd-arm64": "0.28.0", 5570 + "@esbuild/freebsd-x64": "0.28.0", 5571 + "@esbuild/linux-arm": "0.28.0", 5572 + "@esbuild/linux-arm64": "0.28.0", 5573 + "@esbuild/linux-ia32": "0.28.0", 5574 + "@esbuild/linux-loong64": "0.28.0", 5575 + "@esbuild/linux-mips64el": "0.28.0", 5576 + "@esbuild/linux-ppc64": "0.28.0", 5577 + "@esbuild/linux-riscv64": "0.28.0", 5578 + "@esbuild/linux-s390x": "0.28.0", 5579 + "@esbuild/linux-x64": "0.28.0", 5580 + "@esbuild/netbsd-arm64": "0.28.0", 5581 + "@esbuild/netbsd-x64": "0.28.0", 5582 + "@esbuild/openbsd-arm64": "0.28.0", 5583 + "@esbuild/openbsd-x64": "0.28.0", 5584 + "@esbuild/openharmony-arm64": "0.28.0", 5585 + "@esbuild/sunos-x64": "0.28.0", 5586 + "@esbuild/win32-arm64": "0.28.0", 5587 + "@esbuild/win32-ia32": "0.28.0", 5588 + "@esbuild/win32-x64": "0.28.0" 5589 + } 5590 + }, 4701 5591 "node_modules/tunnel-agent": { 4702 5592 "version": "0.6.0", 4703 5593 "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", ··· 4708 5598 }, 4709 5599 "engines": { 4710 5600 "node": "*" 4711 - } 4712 - }, 4713 - "node_modules/type-fest": { 4714 - "version": "0.13.1", 4715 - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", 4716 - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", 4717 - "dev": true, 4718 - "license": "(MIT OR CC0-1.0)", 4719 - "optional": true, 4720 - "engines": { 4721 - "node": ">=10" 4722 - }, 4723 - "funding": { 4724 - "url": "https://github.com/sponsors/sindresorhus" 4725 5601 } 4726 5602 }, 4727 5603 "node_modules/typescript": { ··· 4831 5707 "url": "https://opencollective.com/unified" 4832 5708 } 4833 5709 }, 4834 - "node_modules/universalify": { 4835 - "version": "0.1.2", 4836 - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", 4837 - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", 4838 - "dev": true, 4839 - "license": "MIT", 4840 - "engines": { 4841 - "node": ">= 4.0.0" 4842 - } 4843 - }, 4844 5710 "node_modules/update-browserslist-db": { 4845 5711 "version": "1.2.3", 4846 5712 "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", ··· 4877 5743 "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 4878 5744 "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 4879 5745 "license": "MIT" 4880 - }, 4881 - "node_modules/uuid": { 4882 - "version": "9.0.1", 4883 - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", 4884 - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", 4885 - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", 4886 - "funding": [ 4887 - "https://github.com/sponsors/broofa", 4888 - "https://github.com/sponsors/ctavan" 4889 - ], 4890 - "license": "MIT", 4891 - "bin": { 4892 - "uuid": "dist/bin/uuid" 4893 - } 4894 5746 }, 4895 5747 "node_modules/vfile": { 4896 5748 "version": "6.0.3", ··· 5005 5857 "webidl-conversions": "^3.0.0" 5006 5858 } 5007 5859 }, 5860 + "node_modules/which": { 5861 + "version": "2.0.2", 5862 + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 5863 + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 5864 + "license": "ISC", 5865 + "dependencies": { 5866 + "isexe": "^2.0.0" 5867 + }, 5868 + "bin": { 5869 + "node-which": "bin/node-which" 5870 + }, 5871 + "engines": { 5872 + "node": ">= 8" 5873 + } 5874 + }, 5875 + "node_modules/wrap-ansi": { 5876 + "version": "8.1.0", 5877 + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", 5878 + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", 5879 + "license": "MIT", 5880 + "dependencies": { 5881 + "ansi-styles": "^6.1.0", 5882 + "string-width": "^5.0.1", 5883 + "strip-ansi": "^7.0.1" 5884 + }, 5885 + "engines": { 5886 + "node": ">=12" 5887 + }, 5888 + "funding": { 5889 + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 5890 + } 5891 + }, 5892 + "node_modules/wrap-ansi-cjs": { 5893 + "name": "wrap-ansi", 5894 + "version": "7.0.0", 5895 + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", 5896 + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 5897 + "license": "MIT", 5898 + "dependencies": { 5899 + "ansi-styles": "^4.0.0", 5900 + "string-width": "^4.1.0", 5901 + "strip-ansi": "^6.0.0" 5902 + }, 5903 + "engines": { 5904 + "node": ">=10" 5905 + }, 5906 + "funding": { 5907 + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 5908 + } 5909 + }, 5910 + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { 5911 + "version": "5.0.1", 5912 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 5913 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 5914 + "license": "MIT", 5915 + "engines": { 5916 + "node": ">=8" 5917 + } 5918 + }, 5919 + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { 5920 + "version": "8.0.0", 5921 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 5922 + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 5923 + "license": "MIT" 5924 + }, 5925 + "node_modules/wrap-ansi-cjs/node_modules/string-width": { 5926 + "version": "4.2.3", 5927 + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 5928 + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 5929 + "license": "MIT", 5930 + "dependencies": { 5931 + "emoji-regex": "^8.0.0", 5932 + "is-fullwidth-code-point": "^3.0.0", 5933 + "strip-ansi": "^6.0.1" 5934 + }, 5935 + "engines": { 5936 + "node": ">=8" 5937 + } 5938 + }, 5939 + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { 5940 + "version": "6.0.1", 5941 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 5942 + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 5943 + "license": "MIT", 5944 + "dependencies": { 5945 + "ansi-regex": "^5.0.1" 5946 + }, 5947 + "engines": { 5948 + "node": ">=8" 5949 + } 5950 + }, 5951 + "node_modules/wrap-ansi/node_modules/ansi-styles": { 5952 + "version": "6.2.3", 5953 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", 5954 + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", 5955 + "license": "MIT", 5956 + "engines": { 5957 + "node": ">=12" 5958 + }, 5959 + "funding": { 5960 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 5961 + } 5962 + }, 5008 5963 "node_modules/wrappy": { 5009 5964 "version": "1.0.2", 5010 5965 "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 5011 5966 "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", 5012 5967 "license": "ISC" 5013 5968 }, 5969 + "node_modules/ws": { 5970 + "version": "8.21.0", 5971 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", 5972 + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", 5973 + "license": "MIT", 5974 + "engines": { 5975 + "node": ">=10.0.0" 5976 + }, 5977 + "peerDependencies": { 5978 + "bufferutil": "^4.0.1", 5979 + "utf-8-validate": ">=5.0.2" 5980 + }, 5981 + "peerDependenciesMeta": { 5982 + "bufferutil": { 5983 + "optional": true 5984 + }, 5985 + "utf-8-validate": { 5986 + "optional": true 5987 + } 5988 + } 5989 + }, 5990 + "node_modules/y18n": { 5991 + "version": "5.0.8", 5992 + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", 5993 + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", 5994 + "dev": true, 5995 + "license": "ISC", 5996 + "engines": { 5997 + "node": ">=10" 5998 + } 5999 + }, 5014 6000 "node_modules/yallist": { 5015 6001 "version": "3.1.1", 5016 6002 "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", ··· 5018 6004 "dev": true, 5019 6005 "license": "ISC" 5020 6006 }, 5021 - "node_modules/yauzl": { 5022 - "version": "2.10.0", 5023 - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", 5024 - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", 6007 + "node_modules/yargs": { 6008 + "version": "17.7.2", 6009 + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", 6010 + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", 6011 + "dev": true, 6012 + "license": "MIT", 6013 + "dependencies": { 6014 + "cliui": "^8.0.1", 6015 + "escalade": "^3.1.1", 6016 + "get-caller-file": "^2.0.5", 6017 + "require-directory": "^2.1.1", 6018 + "string-width": "^4.2.3", 6019 + "y18n": "^5.0.5", 6020 + "yargs-parser": "^21.1.1" 6021 + }, 6022 + "engines": { 6023 + "node": ">=12" 6024 + } 6025 + }, 6026 + "node_modules/yargs-parser": { 6027 + "version": "21.1.1", 6028 + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", 6029 + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", 6030 + "dev": true, 6031 + "license": "ISC", 6032 + "engines": { 6033 + "node": ">=12" 6034 + } 6035 + }, 6036 + "node_modules/yargs/node_modules/ansi-regex": { 6037 + "version": "5.0.1", 6038 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 6039 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 6040 + "dev": true, 6041 + "license": "MIT", 6042 + "engines": { 6043 + "node": ">=8" 6044 + } 6045 + }, 6046 + "node_modules/yargs/node_modules/emoji-regex": { 6047 + "version": "8.0.0", 6048 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 6049 + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 6050 + "dev": true, 6051 + "license": "MIT" 6052 + }, 6053 + "node_modules/yargs/node_modules/string-width": { 6054 + "version": "4.2.3", 6055 + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 6056 + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 6057 + "dev": true, 6058 + "license": "MIT", 6059 + "dependencies": { 6060 + "emoji-regex": "^8.0.0", 6061 + "is-fullwidth-code-point": "^3.0.0", 6062 + "strip-ansi": "^6.0.1" 6063 + }, 6064 + "engines": { 6065 + "node": ">=8" 6066 + } 6067 + }, 6068 + "node_modules/yargs/node_modules/strip-ansi": { 6069 + "version": "6.0.1", 6070 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 6071 + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 5025 6072 "dev": true, 5026 6073 "license": "MIT", 5027 6074 "dependencies": { 5028 - "buffer-crc32": "~0.2.3", 5029 - "fd-slicer": "~1.1.0" 6075 + "ansi-regex": "^5.0.1" 6076 + }, 6077 + "engines": { 6078 + "node": ">=8" 6079 + } 6080 + }, 6081 + "node_modules/zod": { 6082 + "version": "3.25.76", 6083 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 6084 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 6085 + "license": "MIT", 6086 + "funding": { 6087 + "url": "https://github.com/sponsors/colinhacks" 5030 6088 } 5031 6089 }, 5032 6090 "node_modules/zwitch": {
+20 -12
package.json
··· 1 1 { 2 2 "name": "centrosome", 3 - "version": "0.0.1", 3 + "version": "0.1.0", 4 4 "private": true, 5 5 "type": "module", 6 - "main": "./out/main/index.js", 7 6 "scripts": { 8 - "dev": "electron-vite dev", 9 - "build": "electron-vite build", 10 - "start": "electron-vite preview", 11 - "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json" 7 + "dev": "concurrently -k -n web,api -c blue,green \"vite\" \"tsx watch server/index.ts\"", 8 + "dev:web": "vite", 9 + "dev:api": "tsx watch server/index.ts", 10 + "build:web": "vite build", 11 + "build:api": "tsc -p tsconfig.server.json", 12 + "build": "npm run build:web && npm run build:api", 13 + "start": "NODE_ENV=production node dist-server/server/index.js", 14 + "typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.web.json" 12 15 }, 13 16 "dependencies": { 14 17 "@anthropic-ai/sdk": "^0.27.0", 15 - "@automerge/automerge": "^2.2.8", 16 - "chokidar": "^3.6.0", 17 - "keytar": "^7.9.0", 18 + "@fastify/cookie": "^9.4.0", 19 + "@fastify/static": "^7.0.4", 20 + "@fastify/websocket": "^10.0.1", 21 + "better-sqlite3": "^11.3.0", 22 + "fastify": "^4.28.1", 18 23 "react-markdown": "^10.1.0", 19 24 "react-resizable-panels": "^2.1.9", 20 - "remark-gfm": "^4.0.1" 25 + "remark-gfm": "^4.0.1", 26 + "zod": "^3.23.8" 21 27 }, 22 28 "devDependencies": { 29 + "@types/better-sqlite3": "^7.6.11", 23 30 "@types/node": "^20.12.0", 24 31 "@types/react": "^18.3.0", 25 32 "@types/react-dom": "^18.3.0", 33 + "@types/ws": "^8.18.1", 26 34 "@vitejs/plugin-react": "^4.3.0", 27 - "electron": "^31.0.0", 28 - "electron-vite": "^2.3.0", 35 + "concurrently": "^9.0.1", 29 36 "react": "^18.3.0", 30 37 "react-dom": "^18.3.0", 38 + "tsx": "^4.19.1", 31 39 "typescript": "^5.4.0", 32 40 "vite": "^5.3.0" 33 41 }
+71
server/auth.ts
··· 1 + // Session + user identity helpers. Auth is intentionally trivial for the 2 + // prototype: user types their email, gets a session cookie, no verification. 3 + // Same trust boundary as the original desktop app (we trust the device). 4 + // 5 + // Replace with real email verification or OAuth before this is public. 6 + 7 + import { createHash, randomBytes } from 'node:crypto' 8 + import * as db from './db.js' 9 + import type { Identity, UserId } from '../src/shared/schema.js' 10 + 11 + export function userIdFromEmail(email: string): UserId { 12 + return createHash('sha256') 13 + .update(email.trim().toLowerCase()) 14 + .digest('hex') 15 + .slice(0, 16) 16 + } 17 + 18 + export function randomToken(bytes = 32): string { 19 + return randomBytes(bytes).toString('base64url') 20 + } 21 + 22 + export function ensureUser(email: string): Identity { 23 + const normalized = email.trim().toLowerCase() 24 + const id = userIdFromEmail(normalized) 25 + const displayName = normalized.split('@')[0] 26 + const g = db.global() 27 + const existing = g.prepare('SELECT id, email, display_name FROM users WHERE id = ?').get(id) as 28 + | { id: string; email: string; display_name: string } 29 + | undefined 30 + if (existing) { 31 + return { userId: existing.id, email: existing.email, displayName: existing.display_name } 32 + } 33 + g.prepare( 34 + `INSERT INTO users (id, email, display_name, created_at) VALUES (?, ?, ?, ?)`, 35 + ).run(id, normalized, displayName, Date.now()) 36 + return { userId: id, email: normalized, displayName } 37 + } 38 + 39 + export function createSession(userId: UserId): string { 40 + const token = randomToken() 41 + const now = Date.now() 42 + db.global() 43 + .prepare( 44 + 'INSERT INTO sessions (token, user_id, created_at, last_seen) VALUES (?, ?, ?, ?)', 45 + ) 46 + .run(token, userId, now, now) 47 + return token 48 + } 49 + 50 + export function getIdentityFromSession(token: string | undefined): Identity | null { 51 + if (!token) return null 52 + const row = db 53 + .global() 54 + .prepare( 55 + `SELECT u.id, u.email, u.display_name 56 + FROM sessions s JOIN users u ON u.id = s.user_id 57 + WHERE s.token = ?`, 58 + ) 59 + .get(token) as 60 + | { id: string; email: string; display_name: string } 61 + | undefined 62 + if (!row) return null 63 + db.global() 64 + .prepare('UPDATE sessions SET last_seen = ? WHERE token = ?') 65 + .run(Date.now(), token) 66 + return { userId: row.id, email: row.email, displayName: row.display_name } 67 + } 68 + 69 + export function deleteSession(token: string): void { 70 + db.global().prepare('DELETE FROM sessions WHERE token = ?').run(token) 71 + }
+66
server/db.ts
··· 1 + // SQLite access. Two physical separations: 2 + // global.sqlite users, projects directory, sessions, invites 3 + // projects/<projectId>.sqlite one DB per project 4 + // 5 + // Per-project connections are cached in memory (no eviction yet — fine for 6 + // prototype scale). better-sqlite3 is synchronous, single-threaded, and 7 + // connection-per-file: opening a per-project DB is cheap and concurrency 8 + // within a project is naturally serialized. 9 + 10 + import Database from 'better-sqlite3' 11 + import * as fs from 'node:fs' 12 + import * as path from 'node:path' 13 + import { GLOBAL_SCHEMA, PROJECT_SCHEMA } from './schemas/index.js' 14 + 15 + export const DATA_DIR = process.env.CENTROSOME_DATA ?? path.join(process.cwd(), 'data') 16 + const PROJECTS_DIR = path.join(DATA_DIR, 'projects') 17 + 18 + fs.mkdirSync(PROJECTS_DIR, { recursive: true }) 19 + 20 + const globalDb = new Database(path.join(DATA_DIR, 'global.sqlite')) 21 + globalDb.exec(GLOBAL_SCHEMA) 22 + 23 + export function global(): Database.Database { 24 + return globalDb 25 + } 26 + 27 + // Per-project connection cache. 28 + const projectDbCache = new Map<string, Database.Database>() 29 + 30 + export function projectDbPath(projectId: string): string { 31 + return path.join(PROJECTS_DIR, `${projectId}.sqlite`) 32 + } 33 + 34 + export function projectBlobDir(projectId: string): string { 35 + return path.join(PROJECTS_DIR, `${projectId}.blobs`) 36 + } 37 + 38 + export function openProject(projectId: string): Database.Database { 39 + let db = projectDbCache.get(projectId) 40 + if (db) return db 41 + const filePath = projectDbPath(projectId) 42 + db = new Database(filePath) 43 + db.exec(PROJECT_SCHEMA) 44 + projectDbCache.set(projectId, db) 45 + return db 46 + } 47 + 48 + export function projectExists(projectId: string): boolean { 49 + return fs.existsSync(projectDbPath(projectId)) 50 + } 51 + 52 + export function deleteProject(projectId: string): void { 53 + const cached = projectDbCache.get(projectId) 54 + if (cached) { 55 + cached.close() 56 + projectDbCache.delete(projectId) 57 + } 58 + try { 59 + fs.rmSync(projectDbPath(projectId), { force: true }) 60 + fs.rmSync(projectDbPath(projectId) + '-wal', { force: true }) 61 + fs.rmSync(projectDbPath(projectId) + '-shm', { force: true }) 62 + fs.rmSync(projectBlobDir(projectId), { recursive: true, force: true }) 63 + } catch { 64 + // Best-effort. 65 + } 66 + }
+236
server/index.ts
··· 1 + // Centrosome server. 2 + // 3 + // GET /api/auth/me - current session user 4 + // POST /api/auth/login - { email } → sets cookie 5 + // POST /api/auth/logout - clears cookie 6 + // GET /api/projects - projects the current user is a member of 7 + // POST /api/projects - { name } → creates a new project, owner = current user 8 + // POST /api/projects/:id/invites - owner only; returns { token } 9 + // POST /api/invites/:token/accept - claims the invite for the current user 10 + // GET /api/projects/:id/blobs/:sha256 - download a binary upload 11 + // POST /api/projects/:id/blobs - { name, base64, mimeType } → upload + create file row 12 + // GET /ws - WebSocket gateway, cookie-authenticated 13 + // 14 + // In dev, vite serves the frontend on :5173 and proxies /api + /ws here. 15 + // In prod, this process also serves the built static assets. 16 + 17 + import Fastify from 'fastify' 18 + import fastifyCookie from '@fastify/cookie' 19 + import fastifyStatic from '@fastify/static' 20 + import fastifyWebsocket from '@fastify/websocket' 21 + import * as crypto from 'node:crypto' 22 + import * as fs from 'node:fs' 23 + import * as path from 'node:path' 24 + import { fileURLToPath } from 'node:url' 25 + import * as db from './db.js' 26 + import { 27 + createSession, 28 + deleteSession, 29 + ensureUser, 30 + getIdentityFromSession, 31 + } from './auth.js' 32 + import { 33 + acceptInvite, 34 + createInvite, 35 + createProject, 36 + getRole, 37 + isMember, 38 + listProjectsForUser, 39 + } from './projects.js' 40 + import { registerWebSocket } from './ws.js' 41 + import type { Identity } from '../src/shared/schema.js' 42 + 43 + const __dirname = path.dirname(fileURLToPath(import.meta.url)) 44 + const PORT = Number(process.env.PORT ?? 3001) 45 + const COOKIE_NAME = 'cs' 46 + const MAX_BLOB_BYTES = 10 * 1024 * 1024 47 + 48 + const app = Fastify({ 49 + logger: { level: process.env.LOG_LEVEL ?? 'info' }, 50 + bodyLimit: 12 * 1024 * 1024, // 12MB, enough for one 10MB binary upload encoded as base64 51 + }) 52 + 53 + await app.register(fastifyCookie) 54 + await app.register(fastifyWebsocket) 55 + 56 + function currentIdentity(req: import('fastify').FastifyRequest): Identity | null { 57 + const token = req.cookies[COOKIE_NAME] 58 + return getIdentityFromSession(token) 59 + } 60 + 61 + function requireAuth( 62 + req: import('fastify').FastifyRequest, 63 + reply: import('fastify').FastifyReply, 64 + ): Identity | null { 65 + const id = currentIdentity(req) 66 + if (!id) { 67 + reply.code(401).send({ error: 'unauthenticated' }) 68 + return null 69 + } 70 + return id 71 + } 72 + 73 + // ---- auth ----------------------------------------------------------------- 74 + 75 + app.get('/api/auth/me', async (req) => { 76 + const id = currentIdentity(req) 77 + return id ? { user: id } : { user: null } 78 + }) 79 + 80 + app.post<{ Body: { email?: string } }>('/api/auth/login', async (req, reply) => { 81 + const email = (req.body?.email ?? '').trim().toLowerCase() 82 + if (!email.includes('@')) { 83 + return reply.code(400).send({ error: 'invalid email' }) 84 + } 85 + const user = ensureUser(email) 86 + const token = createSession(user.userId) 87 + reply.setCookie(COOKIE_NAME, token, { 88 + httpOnly: true, 89 + sameSite: 'lax', 90 + path: '/', 91 + maxAge: 60 * 60 * 24 * 365, // 1y 92 + }) 93 + return { user } 94 + }) 95 + 96 + app.post('/api/auth/logout', async (req, reply) => { 97 + const token = req.cookies[COOKIE_NAME] 98 + if (token) deleteSession(token) 99 + reply.clearCookie(COOKIE_NAME, { path: '/' }) 100 + return { ok: true } 101 + }) 102 + 103 + // ---- projects ------------------------------------------------------------- 104 + 105 + app.get('/api/projects', async (req, reply) => { 106 + const id = requireAuth(req, reply) 107 + if (!id) return 108 + return { projects: listProjectsForUser(id.userId) } 109 + }) 110 + 111 + app.post<{ Body: { name?: string } }>('/api/projects', async (req, reply) => { 112 + const id = requireAuth(req, reply) 113 + if (!id) return 114 + const name = (req.body?.name ?? '').trim() 115 + if (!name) return reply.code(400).send({ error: 'name required' }) 116 + return { project: createProject(name, id) } 117 + }) 118 + 119 + // ---- invites -------------------------------------------------------------- 120 + 121 + app.post<{ Params: { id: string } }>( 122 + '/api/projects/:id/invites', 123 + async (req, reply) => { 124 + const id = requireAuth(req, reply) 125 + if (!id) return 126 + if (getRole(req.params.id, id.userId) !== 'owner') { 127 + return reply.code(403).send({ error: 'only owners can invite' }) 128 + } 129 + const { token } = createInvite(req.params.id, id) 130 + return { token } 131 + }, 132 + ) 133 + 134 + app.post<{ Params: { token: string } }>( 135 + '/api/invites/:token/accept', 136 + async (req, reply) => { 137 + const id = requireAuth(req, reply) 138 + if (!id) return 139 + const result = acceptInvite(req.params.token, id) 140 + if (!result.ok) return reply.code(400).send({ error: result.error }) 141 + return { projectId: result.projectId } 142 + }, 143 + ) 144 + 145 + // ---- blobs ---------------------------------------------------------------- 146 + 147 + app.get<{ Params: { id: string; sha256: string } }>( 148 + '/api/projects/:id/blobs/:sha256', 149 + async (req, reply) => { 150 + const id = requireAuth(req, reply) 151 + if (!id) return 152 + const { id: projectId, sha256 } = req.params 153 + if (!isMember(projectId, id.userId)) 154 + return reply.code(403).send({ error: 'not a member' }) 155 + if (!/^[0-9a-f]{64}$/.test(sha256)) 156 + return reply.code(400).send({ error: 'invalid sha256' }) 157 + const p = path.join(db.projectBlobDir(projectId), sha256) 158 + if (!fs.existsSync(p)) return reply.code(404).send({ error: 'not found' }) 159 + return reply.send(fs.createReadStream(p)) 160 + }, 161 + ) 162 + 163 + app.post<{ 164 + Params: { id: string } 165 + Body: { name?: string; base64?: string; mimeType?: string } 166 + }>('/api/projects/:id/blobs', async (req, reply) => { 167 + const id = requireAuth(req, reply) 168 + if (!id) return 169 + const { id: projectId } = req.params 170 + if (!isMember(projectId, id.userId)) 171 + return reply.code(403).send({ error: 'not a member' }) 172 + 173 + const name = (req.body?.name ?? '').trim() 174 + const base64 = req.body?.base64 ?? '' 175 + const mimeType = req.body?.mimeType ?? 'application/octet-stream' 176 + if (!name) return reply.code(400).send({ error: 'name required' }) 177 + if (!base64) return reply.code(400).send({ error: 'base64 required' }) 178 + 179 + const bytes = Buffer.from(base64, 'base64') 180 + if (bytes.length > MAX_BLOB_BYTES) { 181 + return reply.code(413).send({ 182 + error: `blob too large: ${bytes.length} bytes (cap ${MAX_BLOB_BYTES})`, 183 + }) 184 + } 185 + const sha256 = crypto.createHash('sha256').update(bytes).digest('hex') 186 + 187 + const dir = db.projectBlobDir(projectId) 188 + fs.mkdirSync(dir, { recursive: true }) 189 + const blobPath = path.join(dir, sha256) 190 + if (!fs.existsSync(blobPath)) fs.writeFileSync(blobPath, bytes) 191 + 192 + const fileId = crypto.randomUUID() 193 + const now = Date.now() 194 + const pdb = db.openProject(projectId) 195 + pdb 196 + .prepare( 197 + `INSERT INTO files (id, name, content, binary_sha256, binary_mime, binary_size, 198 + created_at, created_by, updated_at, updated_by) 199 + VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, ?)`, 200 + ) 201 + .run(fileId, name, sha256, mimeType, bytes.length, now, id.userId, now, id.userId) 202 + 203 + return { fileId, sha256, sizeBytes: bytes.length, mimeType } 204 + }) 205 + 206 + // ---- WebSocket ------------------------------------------------------------ 207 + 208 + await registerWebSocket(app) 209 + 210 + // ---- health + static ------------------------------------------------------ 211 + 212 + app.get('/health', async () => ({ ok: true })) 213 + 214 + if (process.env.NODE_ENV === 'production') { 215 + // Serve the built frontend from dist-web alongside the API. 216 + const distWeb = path.resolve(__dirname, '..', 'dist-web') 217 + if (fs.existsSync(distWeb)) { 218 + await app.register(fastifyStatic, { 219 + root: distWeb, 220 + prefix: '/', 221 + wildcard: false, 222 + }) 223 + // SPA fallback: anything that isn't /api or /ws falls back to index.html. 224 + app.setNotFoundHandler((req, reply) => { 225 + if (req.url.startsWith('/api') || req.url.startsWith('/ws')) { 226 + reply.code(404).send({ error: 'not found' }) 227 + return 228 + } 229 + reply.sendFile('index.html') 230 + }) 231 + } 232 + } 233 + 234 + const host = process.env.HOST ?? '127.0.0.1' 235 + await app.listen({ port: PORT, host }) 236 + app.log.info(`Centrosome data dir: ${db.DATA_DIR}`)
+159
server/projects.ts
··· 1 + // Project lifecycle: create, list, membership, invite acceptance. 2 + // All membership info is stored authoritatively in global.sqlite; the 3 + // per-project `members` table is a denormalized mirror so the snapshot 4 + // query stays single-DB. 5 + 6 + import { randomUUID } from 'node:crypto' 7 + import * as db from './db.js' 8 + import { ensureUser, randomToken } from './auth.js' 9 + import { setMeta } from './snapshot.js' 10 + import type { Identity, ProjectMeta, UserId } from '../src/shared/schema.js' 11 + 12 + export function listProjectsForUser(userId: UserId): ProjectMeta[] { 13 + const rows = db 14 + .global() 15 + .prepare( 16 + `SELECT p.id, p.name, pm.role 17 + FROM project_members pm 18 + JOIN projects p ON p.id = pm.project_id 19 + WHERE pm.user_id = ? 20 + ORDER BY p.created_at DESC`, 21 + ) 22 + .all(userId) as Array<{ id: string; name: string; role: 'owner' | 'member' }> 23 + return rows.map((r) => ({ id: r.id, name: r.name, role: r.role })) 24 + } 25 + 26 + export function isMember(projectId: string, userId: UserId): boolean { 27 + const row = db 28 + .global() 29 + .prepare( 30 + 'SELECT 1 AS x FROM project_members WHERE project_id = ? AND user_id = ?', 31 + ) 32 + .get(projectId, userId) 33 + return !!row 34 + } 35 + 36 + export function getRole( 37 + projectId: string, 38 + userId: UserId, 39 + ): 'owner' | 'member' | null { 40 + const row = db 41 + .global() 42 + .prepare( 43 + 'SELECT role FROM project_members WHERE project_id = ? AND user_id = ?', 44 + ) 45 + .get(projectId, userId) as { role: 'owner' | 'member' } | undefined 46 + return row?.role ?? null 47 + } 48 + 49 + export function createProject(name: string, owner: Identity): ProjectMeta { 50 + const id = randomUUID() 51 + const now = Date.now() 52 + 53 + db.global().transaction(() => { 54 + db.global() 55 + .prepare( 56 + 'INSERT INTO projects (id, name, created_at, created_by) VALUES (?, ?, ?, ?)', 57 + ) 58 + .run(id, name, now, owner.userId) 59 + db.global() 60 + .prepare( 61 + `INSERT INTO project_members (project_id, user_id, role, joined_at) 62 + VALUES (?, ?, 'owner', ?)`, 63 + ) 64 + .run(id, owner.userId, now) 65 + })() 66 + 67 + const pdb = db.openProject(id) 68 + pdb.transaction(() => { 69 + setMeta(pdb, 'name', name) 70 + setMeta(pdb, 'description', '') 71 + setMeta(pdb, 'system_prompt', '') 72 + setMeta(pdb, 'default_model', 'claude-sonnet-4-6') 73 + 74 + pdb 75 + .prepare( 76 + `INSERT INTO members (user_id, email, display_name, role, joined_at, last_seen) 77 + VALUES (?, ?, ?, 'owner', ?, ?)`, 78 + ) 79 + .run(owner.userId, owner.email, owner.displayName, now, now) 80 + 81 + // Auto-create the main file (same UX as the Electron version). 82 + const mainId = randomUUID() 83 + const mainContent = 84 + `# ${name}\n\n` + 85 + `Project overview. The agent keeps this file updated with known facts, ` + 86 + `current state, decisions made, and anything the team has asked to be remembered.\n` 87 + pdb 88 + .prepare( 89 + `INSERT INTO files (id, name, content, created_at, created_by, updated_at, updated_by) 90 + VALUES (?, 'main.md', ?, ?, ?, ?, ?)`, 91 + ) 92 + .run(mainId, mainContent, now, owner.userId, now, owner.userId) 93 + setMeta(pdb, 'main_file_id', mainId) 94 + })() 95 + 96 + return { id, name, role: 'owner' } 97 + } 98 + 99 + export function getProject( 100 + projectId: string, 101 + ): { id: string; name: string } | null { 102 + const row = db 103 + .global() 104 + .prepare('SELECT id, name FROM projects WHERE id = ?') 105 + .get(projectId) as { id: string; name: string } | undefined 106 + return row ?? null 107 + } 108 + 109 + export function createInvite( 110 + projectId: string, 111 + creator: Identity, 112 + ): { token: string } { 113 + const token = randomToken() 114 + db.global() 115 + .prepare( 116 + `INSERT INTO invites (token, project_id, created_at, created_by) 117 + VALUES (?, ?, ?, ?)`, 118 + ) 119 + .run(token, projectId, Date.now(), creator.userId) 120 + return { token } 121 + } 122 + 123 + export function acceptInvite( 124 + token: string, 125 + user: Identity, 126 + ): { ok: true; projectId: string } | { ok: false; error: string } { 127 + const g = db.global() 128 + const row = g 129 + .prepare( 130 + 'SELECT project_id, used_at FROM invites WHERE token = ?', 131 + ) 132 + .get(token) as { project_id: string; used_at: number | null } | undefined 133 + if (!row) return { ok: false, error: 'invalid invite token' } 134 + if (row.used_at) return { ok: false, error: 'invite already used' } 135 + 136 + const projectId = row.project_id 137 + const now = Date.now() 138 + 139 + g.transaction(() => { 140 + g.prepare( 141 + `INSERT OR IGNORE INTO project_members (project_id, user_id, role, joined_at) 142 + VALUES (?, ?, 'member', ?)`, 143 + ).run(projectId, user.userId, now) 144 + g.prepare( 145 + 'UPDATE invites SET used_by = ?, used_at = ? WHERE token = ?', 146 + ).run(user.userId, now, token) 147 + })() 148 + 149 + // Mirror into per-project members table. 150 + const pdb = db.openProject(projectId) 151 + pdb 152 + .prepare( 153 + `INSERT OR IGNORE INTO members (user_id, email, display_name, role, joined_at, last_seen) 154 + VALUES (?, ?, ?, 'member', ?, 0)`, 155 + ) 156 + .run(user.userId, user.email, user.displayName, now) 157 + 158 + return { ok: true, projectId } 159 + }
+113
server/schemas/index.ts
··· 1 + // SQL schemas inlined as TypeScript modules so they survive `tsc` without 2 + // needing a copy step or runtime path resolution. 3 + 4 + export const GLOBAL_SCHEMA = ` 5 + PRAGMA journal_mode = WAL; 6 + PRAGMA foreign_keys = ON; 7 + 8 + CREATE TABLE IF NOT EXISTS users ( 9 + id TEXT PRIMARY KEY, 10 + email TEXT UNIQUE NOT NULL, 11 + display_name TEXT NOT NULL, 12 + created_at INTEGER NOT NULL 13 + ); 14 + 15 + CREATE TABLE IF NOT EXISTS projects ( 16 + id TEXT PRIMARY KEY, 17 + name TEXT NOT NULL, 18 + created_at INTEGER NOT NULL, 19 + created_by TEXT NOT NULL REFERENCES users(id) 20 + ); 21 + 22 + CREATE TABLE IF NOT EXISTS project_members ( 23 + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, 24 + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 25 + role TEXT NOT NULL CHECK (role IN ('owner', 'member')), 26 + joined_at INTEGER NOT NULL, 27 + PRIMARY KEY (project_id, user_id) 28 + ); 29 + CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id); 30 + 31 + CREATE TABLE IF NOT EXISTS invites ( 32 + token TEXT PRIMARY KEY, 33 + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, 34 + created_at INTEGER NOT NULL, 35 + created_by TEXT NOT NULL REFERENCES users(id), 36 + used_by TEXT REFERENCES users(id), 37 + used_at INTEGER 38 + ); 39 + 40 + CREATE TABLE IF NOT EXISTS sessions ( 41 + token TEXT PRIMARY KEY, 42 + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 43 + created_at INTEGER NOT NULL, 44 + last_seen INTEGER NOT NULL 45 + ); 46 + CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); 47 + ` 48 + 49 + export const PROJECT_SCHEMA = ` 50 + PRAGMA journal_mode = WAL; 51 + PRAGMA foreign_keys = ON; 52 + 53 + CREATE TABLE IF NOT EXISTS meta ( 54 + k TEXT PRIMARY KEY, 55 + v TEXT NOT NULL 56 + ); 57 + 58 + CREATE TABLE IF NOT EXISTS files ( 59 + id TEXT PRIMARY KEY, 60 + name TEXT NOT NULL, 61 + content TEXT NOT NULL DEFAULT '', 62 + binary_sha256 TEXT, 63 + binary_mime TEXT, 64 + binary_size INTEGER, 65 + created_at INTEGER NOT NULL, 66 + created_by TEXT NOT NULL, 67 + updated_at INTEGER NOT NULL, 68 + updated_by TEXT NOT NULL 69 + ); 70 + 71 + CREATE TABLE IF NOT EXISTS file_history ( 72 + file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, 73 + ver INTEGER NOT NULL, 74 + content TEXT NOT NULL, 75 + updated_at INTEGER NOT NULL, 76 + updated_by TEXT NOT NULL, 77 + PRIMARY KEY (file_id, ver) 78 + ); 79 + 80 + CREATE TABLE IF NOT EXISTS conversations ( 81 + id TEXT PRIMARY KEY, 82 + title TEXT NOT NULL DEFAULT 'Untitled', 83 + archived INTEGER NOT NULL DEFAULT 0, 84 + created_at INTEGER NOT NULL, 85 + created_by TEXT NOT NULL 86 + ); 87 + 88 + CREATE TABLE IF NOT EXISTS turns ( 89 + id TEXT PRIMARY KEY, 90 + conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, 91 + parent_id TEXT REFERENCES turns(id), 92 + author TEXT NOT NULL, 93 + role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), 94 + created_at INTEGER NOT NULL, 95 + text TEXT NOT NULL DEFAULT '', 96 + status TEXT CHECK (status IS NULL OR status IN ('streaming', 'complete', 'error')), 97 + model_id TEXT, 98 + input_tokens INTEGER, 99 + output_tokens INTEGER, 100 + paid_by TEXT 101 + ); 102 + CREATE INDEX IF NOT EXISTS idx_turns_conv ON turns(conv_id, created_at); 103 + CREATE INDEX IF NOT EXISTS idx_turns_parent ON turns(parent_id); 104 + 105 + CREATE TABLE IF NOT EXISTS members ( 106 + user_id TEXT PRIMARY KEY, 107 + email TEXT NOT NULL, 108 + display_name TEXT NOT NULL, 109 + role TEXT NOT NULL, 110 + joined_at INTEGER NOT NULL, 111 + last_seen INTEGER NOT NULL DEFAULT 0 112 + ); 113 + `
+172
server/snapshot.ts
··· 1 + // Build a full project snapshot from the per-project DB. Used on WebSocket 2 + // subscribe and after invite-accept. 3 + 4 + import * as db from './db.js' 5 + import type { 6 + Conversation, 7 + Member, 8 + ProjectFile, 9 + ProjectSnapshot, 10 + Turn, 11 + } from '../src/shared/schema.js' 12 + 13 + export function buildSnapshot(projectId: string): ProjectSnapshot | null { 14 + if (!db.projectExists(projectId)) return null 15 + const pdb = db.openProject(projectId) 16 + const meta = readMeta(pdb) 17 + const project = { 18 + id: projectId, 19 + name: meta.name ?? '', 20 + description: meta.description ?? '', 21 + systemPrompt: meta.system_prompt ?? '', 22 + defaultModel: meta.default_model ?? 'claude-sonnet-4-6', 23 + mainFileId: meta.main_file_id ?? null, 24 + } 25 + 26 + const members: Member[] = ( 27 + pdb 28 + .prepare( 29 + 'SELECT user_id, email, display_name, role, joined_at, last_seen FROM members', 30 + ) 31 + .all() as Array<{ 32 + user_id: string 33 + email: string 34 + display_name: string 35 + role: 'owner' | 'member' 36 + joined_at: number 37 + last_seen: number 38 + }> 39 + ).map((r) => ({ 40 + userId: r.user_id, 41 + email: r.email, 42 + displayName: r.display_name, 43 + role: r.role, 44 + joinedAt: r.joined_at, 45 + lastSeen: r.last_seen, 46 + })) 47 + 48 + const files: ProjectFile[] = ( 49 + pdb 50 + .prepare( 51 + `SELECT id, name, content, binary_sha256, binary_mime, binary_size, 52 + created_at, created_by, updated_at, updated_by 53 + FROM files`, 54 + ) 55 + .all() as Array<{ 56 + id: string 57 + name: string 58 + content: string 59 + binary_sha256: string | null 60 + binary_mime: string | null 61 + binary_size: number | null 62 + created_at: number 63 + created_by: string 64 + updated_at: number 65 + updated_by: string 66 + }> 67 + ).map((r) => ({ 68 + id: r.id, 69 + name: r.name, 70 + content: r.content, 71 + createdAt: r.created_at, 72 + createdBy: r.created_by, 73 + updatedAt: r.updated_at, 74 + updatedBy: r.updated_by, 75 + binary: r.binary_sha256 76 + ? { 77 + sha256: r.binary_sha256, 78 + mimeType: r.binary_mime ?? 'application/octet-stream', 79 + sizeBytes: r.binary_size ?? 0, 80 + } 81 + : undefined, 82 + })) 83 + 84 + const conversations: Conversation[] = ( 85 + pdb 86 + .prepare( 87 + 'SELECT id, title, archived, created_at, created_by FROM conversations', 88 + ) 89 + .all() as Array<{ 90 + id: string 91 + title: string 92 + archived: number 93 + created_at: number 94 + created_by: string 95 + }> 96 + ).map((r) => ({ 97 + id: r.id, 98 + title: r.title, 99 + archived: !!r.archived, 100 + createdAt: r.created_at, 101 + createdBy: r.created_by, 102 + })) 103 + 104 + const turns: Turn[] = ( 105 + pdb 106 + .prepare( 107 + `SELECT id, conv_id, parent_id, author, role, created_at, text, status, 108 + model_id, input_tokens, output_tokens, paid_by 109 + FROM turns`, 110 + ) 111 + .all() as Array<{ 112 + id: string 113 + conv_id: string 114 + parent_id: string | null 115 + author: string 116 + role: 'user' | 'assistant' 117 + created_at: number 118 + text: string 119 + status: 'streaming' | 'complete' | 'error' | null 120 + model_id: string | null 121 + input_tokens: number | null 122 + output_tokens: number | null 123 + paid_by: string | null 124 + }> 125 + ).map((r) => ({ 126 + id: r.id, 127 + convId: r.conv_id, 128 + parentId: r.parent_id, 129 + author: r.author, 130 + role: r.role, 131 + createdAt: r.created_at, 132 + text: r.text, 133 + status: r.status ?? undefined, 134 + modelId: r.model_id ?? undefined, 135 + inputTokens: r.input_tokens ?? undefined, 136 + outputTokens: r.output_tokens ?? undefined, 137 + paidBy: r.paid_by ?? undefined, 138 + })) 139 + 140 + return { project, members, files, conversations, turns } 141 + } 142 + 143 + export function readMeta(pdb: import('better-sqlite3').Database): Record<string, string> { 144 + const rows = pdb.prepare('SELECT k, v FROM meta').all() as Array<{ 145 + k: string 146 + v: string 147 + }> 148 + const out: Record<string, string> = {} 149 + for (const r of rows) out[r.k] = r.v 150 + return out 151 + } 152 + 153 + export function setMeta(pdb: import('better-sqlite3').Database, k: string, v: string): void { 154 + pdb 155 + .prepare( 156 + 'INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v', 157 + ) 158 + .run(k, v) 159 + } 160 + 161 + // Look up a file row by name (case-sensitive). Returns the row or null. 162 + export function findFileByName( 163 + pdb: import('better-sqlite3').Database, 164 + name: string, 165 + ): { id: string; content: string; binary_sha256: string | null } | null { 166 + const row = pdb 167 + .prepare('SELECT id, content, binary_sha256 FROM files WHERE name = ?') 168 + .get(name) as 169 + | { id: string; content: string; binary_sha256: string | null } 170 + | undefined 171 + return row ?? null 172 + }
+514
server/ws.ts
··· 1 + // WebSocket gateway. Each connection is bound to one authenticated user 2 + // (via session cookie). A connection subscribes to a single project at a 3 + // time; on subscribe we send a full snapshot, then stream deltas as 4 + // mutations land. 5 + // 6 + // Mutations are applied to the per-project SQLite *first*, then broadcast. 7 + // Server is the authoritative writer — clients send intents, server is the 8 + // source of truth. 9 + 10 + import type { FastifyInstance } from 'fastify' 11 + import { randomUUID } from 'node:crypto' 12 + import * as fs from 'node:fs' 13 + import * as path from 'node:path' 14 + import * as db from './db.js' 15 + import { getIdentityFromSession } from './auth.js' 16 + import { isMember } from './projects.js' 17 + import { buildSnapshot, findFileByName, setMeta } from './snapshot.js' 18 + import type { 19 + Conversation, 20 + Identity, 21 + ProjectFile, 22 + Turn, 23 + WsClientEvent, 24 + WsServerEvent, 25 + } from '../src/shared/schema.js' 26 + 27 + interface WsConnection { 28 + socket: import('ws').WebSocket 29 + user: Identity 30 + projectId: string | null 31 + } 32 + 33 + const subscriptions = new Map<string, Set<WsConnection>>() 34 + const MAX_FILE_HISTORY = 5 35 + 36 + function broadcast(projectId: string, event: WsServerEvent): void { 37 + const subs = subscriptions.get(projectId) 38 + if (!subs) return 39 + const payload = JSON.stringify(event) 40 + for (const c of subs) { 41 + if (c.socket.readyState === c.socket.OPEN) c.socket.send(payload) 42 + } 43 + } 44 + 45 + function sendTo(conn: WsConnection, event: WsServerEvent): void { 46 + if (conn.socket.readyState === conn.socket.OPEN) { 47 + conn.socket.send(JSON.stringify(event)) 48 + } 49 + } 50 + 51 + function attachSubscription(conn: WsConnection, projectId: string): void { 52 + detachSubscription(conn) 53 + let set = subscriptions.get(projectId) 54 + if (!set) { 55 + set = new Set() 56 + subscriptions.set(projectId, set) 57 + } 58 + set.add(conn) 59 + conn.projectId = projectId 60 + } 61 + 62 + function detachSubscription(conn: WsConnection): void { 63 + if (!conn.projectId) return 64 + const set = subscriptions.get(conn.projectId) 65 + set?.delete(conn) 66 + if (set && set.size === 0) subscriptions.delete(conn.projectId) 67 + conn.projectId = null 68 + } 69 + 70 + // ---- mutation implementations --------------------------------------------- 71 + 72 + function pushHistoryAndUpdateContent( 73 + pdb: import('better-sqlite3').Database, 74 + fileId: string, 75 + newContent: string, 76 + by: string, 77 + ts: number, 78 + ): void { 79 + const row = pdb 80 + .prepare( 81 + 'SELECT content, updated_at, updated_by FROM files WHERE id = ?', 82 + ) 83 + .get(fileId) as 84 + | { content: string; updated_at: number; updated_by: string } 85 + | undefined 86 + if (!row) return 87 + if (row.content === newContent) return 88 + const next = (pdb.prepare( 89 + 'SELECT COALESCE(MAX(ver), 0) + 1 AS n FROM file_history WHERE file_id = ?', 90 + ).get(fileId) as { n: number }).n 91 + pdb 92 + .prepare( 93 + `INSERT INTO file_history (file_id, ver, content, updated_at, updated_by) 94 + VALUES (?, ?, ?, ?, ?)`, 95 + ) 96 + .run(fileId, next, row.content, row.updated_at, row.updated_by) 97 + // Trim to the latest MAX_FILE_HISTORY entries. 98 + pdb 99 + .prepare( 100 + `DELETE FROM file_history WHERE file_id = ? 101 + AND ver <= ( 102 + SELECT MIN(ver) FROM ( 103 + SELECT ver FROM file_history WHERE file_id = ? ORDER BY ver DESC LIMIT ${MAX_FILE_HISTORY} 104 + ) 105 + ) - 1`, 106 + ) 107 + .run(fileId, fileId) 108 + pdb 109 + .prepare( 110 + 'UPDATE files SET content = ?, updated_by = ?, updated_at = ? WHERE id = ?', 111 + ) 112 + .run(newContent, by, ts, fileId) 113 + } 114 + 115 + function readFile( 116 + pdb: import('better-sqlite3').Database, 117 + fileId: string, 118 + ): ProjectFile | null { 119 + const row = pdb 120 + .prepare( 121 + `SELECT id, name, content, binary_sha256, binary_mime, binary_size, 122 + created_at, created_by, updated_at, updated_by 123 + FROM files WHERE id = ?`, 124 + ) 125 + .get(fileId) as 126 + | { 127 + id: string 128 + name: string 129 + content: string 130 + binary_sha256: string | null 131 + binary_mime: string | null 132 + binary_size: number | null 133 + created_at: number 134 + created_by: string 135 + updated_at: number 136 + updated_by: string 137 + } 138 + | undefined 139 + if (!row) return null 140 + return { 141 + id: row.id, 142 + name: row.name, 143 + content: row.content, 144 + createdAt: row.created_at, 145 + createdBy: row.created_by, 146 + updatedAt: row.updated_at, 147 + updatedBy: row.updated_by, 148 + binary: row.binary_sha256 149 + ? { 150 + sha256: row.binary_sha256, 151 + mimeType: row.binary_mime ?? 'application/octet-stream', 152 + sizeBytes: row.binary_size ?? 0, 153 + } 154 + : undefined, 155 + } 156 + } 157 + 158 + function readTurn( 159 + pdb: import('better-sqlite3').Database, 160 + turnId: string, 161 + ): Turn | null { 162 + const r = pdb 163 + .prepare( 164 + `SELECT id, conv_id, parent_id, author, role, created_at, text, status, 165 + model_id, input_tokens, output_tokens, paid_by 166 + FROM turns WHERE id = ?`, 167 + ) 168 + .get(turnId) as 169 + | { 170 + id: string 171 + conv_id: string 172 + parent_id: string | null 173 + author: string 174 + role: 'user' | 'assistant' 175 + created_at: number 176 + text: string 177 + status: 'streaming' | 'complete' | 'error' | null 178 + model_id: string | null 179 + input_tokens: number | null 180 + output_tokens: number | null 181 + paid_by: string | null 182 + } 183 + | undefined 184 + if (!r) return null 185 + return { 186 + id: r.id, 187 + convId: r.conv_id, 188 + parentId: r.parent_id, 189 + author: r.author, 190 + role: r.role, 191 + createdAt: r.created_at, 192 + text: r.text, 193 + status: r.status ?? undefined, 194 + modelId: r.model_id ?? undefined, 195 + inputTokens: r.input_tokens ?? undefined, 196 + outputTokens: r.output_tokens ?? undefined, 197 + paidBy: r.paid_by ?? undefined, 198 + } 199 + } 200 + 201 + function readConversation( 202 + pdb: import('better-sqlite3').Database, 203 + convId: string, 204 + ): Conversation | null { 205 + const r = pdb 206 + .prepare( 207 + 'SELECT id, title, archived, created_at, created_by FROM conversations WHERE id = ?', 208 + ) 209 + .get(convId) as 210 + | { id: string; title: string; archived: number; created_at: number; created_by: string } 211 + | undefined 212 + if (!r) return null 213 + return { 214 + id: r.id, 215 + title: r.title, 216 + archived: !!r.archived, 217 + createdAt: r.created_at, 218 + createdBy: r.created_by, 219 + } 220 + } 221 + 222 + // ---- event dispatch ------------------------------------------------------- 223 + 224 + function handleEvent(conn: WsConnection, event: WsClientEvent): void { 225 + switch (event.type) { 226 + case 'subscribe': { 227 + const { projectId } = event 228 + if (!isMember(projectId, conn.user.userId)) { 229 + sendTo(conn, { type: 'error', message: 'not a member of this project' }) 230 + return 231 + } 232 + attachSubscription(conn, projectId) 233 + const snap = buildSnapshot(projectId) 234 + if (snap) sendTo(conn, { type: 'snapshot', snapshot: snap }) 235 + return 236 + } 237 + case 'unsubscribe': { 238 + if (conn.projectId === event.projectId) detachSubscription(conn) 239 + return 240 + } 241 + case 'conversation:create': { 242 + if (!authForProject(conn, event.projectId)) return 243 + const pdb = db.openProject(event.projectId) 244 + const id = randomUUID() 245 + const now = Date.now() 246 + pdb 247 + .prepare( 248 + `INSERT INTO conversations (id, title, archived, created_at, created_by) 249 + VALUES (?, ?, 0, ?, ?)`, 250 + ) 251 + .run(id, event.title || 'Untitled', now, conn.user.userId) 252 + const conv = readConversation(pdb, id) 253 + if (conv) broadcast(event.projectId, { type: 'conversation', conversation: conv }) 254 + return 255 + } 256 + case 'conversation:update': { 257 + if (!authForProject(conn, event.projectId)) return 258 + const pdb = db.openProject(event.projectId) 259 + const f = event.fields 260 + const sets: string[] = [] 261 + const vals: unknown[] = [] 262 + if (f.title !== undefined) { 263 + sets.push('title = ?') 264 + vals.push(f.title) 265 + } 266 + if (f.archived !== undefined) { 267 + sets.push('archived = ?') 268 + vals.push(f.archived ? 1 : 0) 269 + } 270 + if (sets.length === 0) return 271 + vals.push(event.convId) 272 + pdb 273 + .prepare(`UPDATE conversations SET ${sets.join(', ')} WHERE id = ?`) 274 + .run(...vals) 275 + const conv = readConversation(pdb, event.convId) 276 + if (conv) broadcast(event.projectId, { type: 'conversation', conversation: conv }) 277 + return 278 + } 279 + case 'turn:create': { 280 + if (!authForProject(conn, event.projectId)) return 281 + const pdb = db.openProject(event.projectId) 282 + const t = event.turn 283 + pdb 284 + .prepare( 285 + `INSERT INTO turns (id, conv_id, parent_id, author, role, created_at, 286 + text, status, model_id, input_tokens, output_tokens, paid_by) 287 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 288 + ) 289 + .run( 290 + t.id, 291 + event.convId, 292 + t.parentId, 293 + t.author, 294 + t.role, 295 + t.createdAt, 296 + t.text ?? '', 297 + t.status ?? null, 298 + t.modelId ?? null, 299 + t.inputTokens ?? null, 300 + t.outputTokens ?? null, 301 + t.paidBy ?? null, 302 + ) 303 + const turn = readTurn(pdb, t.id) 304 + if (turn) broadcast(event.projectId, { type: 'turn', turn }) 305 + return 306 + } 307 + case 'turn:update': { 308 + if (!authForProject(conn, event.projectId)) return 309 + const pdb = db.openProject(event.projectId) 310 + const f = event.fields 311 + const sets: string[] = [] 312 + const vals: unknown[] = [] 313 + if (f.text !== undefined) { 314 + sets.push('text = ?') 315 + vals.push(f.text) 316 + } 317 + if (f.status !== undefined) { 318 + sets.push('status = ?') 319 + vals.push(f.status) 320 + } 321 + if (f.modelId !== undefined) { 322 + sets.push('model_id = ?') 323 + vals.push(f.modelId) 324 + } 325 + if (f.inputTokens !== undefined) { 326 + sets.push('input_tokens = ?') 327 + vals.push(f.inputTokens) 328 + } 329 + if (f.outputTokens !== undefined) { 330 + sets.push('output_tokens = ?') 331 + vals.push(f.outputTokens) 332 + } 333 + if (sets.length === 0) return 334 + vals.push(event.turnId) 335 + pdb.prepare(`UPDATE turns SET ${sets.join(', ')} WHERE id = ?`).run(...vals) 336 + const turn = readTurn(pdb, event.turnId) 337 + if (turn) broadcast(event.projectId, { type: 'turn', turn }) 338 + return 339 + } 340 + case 'file:create': { 341 + if (!authForProject(conn, event.projectId)) return 342 + const pdb = db.openProject(event.projectId) 343 + const now = Date.now() 344 + const f = event.file 345 + pdb 346 + .prepare( 347 + `INSERT INTO files (id, name, content, binary_sha256, binary_mime, binary_size, 348 + created_at, created_by, updated_at, updated_by) 349 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 350 + ) 351 + .run( 352 + f.id, 353 + f.name, 354 + f.content ?? '', 355 + f.binary?.sha256 ?? null, 356 + f.binary?.mimeType ?? null, 357 + f.binary?.sizeBytes ?? null, 358 + now, 359 + conn.user.userId, 360 + now, 361 + conn.user.userId, 362 + ) 363 + const file = readFile(pdb, f.id) 364 + if (file) broadcast(event.projectId, { type: 'file', file }) 365 + return 366 + } 367 + case 'file:update': { 368 + if (!authForProject(conn, event.projectId)) return 369 + const pdb = db.openProject(event.projectId) 370 + const now = Date.now() 371 + const existing = readFile(pdb, event.fileId) 372 + if (!existing) return 373 + if (existing.binary) return // binary files are not editable 374 + if (event.fields.content !== undefined) { 375 + pushHistoryAndUpdateContent( 376 + pdb, 377 + event.fileId, 378 + event.fields.content, 379 + conn.user.userId, 380 + now, 381 + ) 382 + } 383 + if (event.fields.name !== undefined) { 384 + pdb 385 + .prepare( 386 + 'UPDATE files SET name = ?, updated_by = ?, updated_at = ? WHERE id = ?', 387 + ) 388 + .run(event.fields.name, conn.user.userId, now, event.fileId) 389 + } 390 + const file = readFile(pdb, event.fileId) 391 + if (file) broadcast(event.projectId, { type: 'file', file }) 392 + return 393 + } 394 + case 'file:patch': { 395 + if (!authForProject(conn, event.projectId)) return 396 + const pdb = db.openProject(event.projectId) 397 + const now = Date.now() 398 + const existing = readFile(pdb, event.fileId) 399 + if (!existing || existing.binary) return 400 + let content = existing.content 401 + for (const p of event.patches) { 402 + if (!p.search) continue 403 + const idx = content.indexOf(p.search) 404 + if (idx === -1) continue 405 + const idx2 = content.indexOf(p.search, idx + 1) 406 + if (idx2 !== -1) continue // ambiguous match → skip 407 + content = 408 + content.slice(0, idx) + 409 + p.replace + 410 + content.slice(idx + p.search.length) 411 + } 412 + if (content !== existing.content) { 413 + pushHistoryAndUpdateContent(pdb, event.fileId, content, conn.user.userId, now) 414 + const file = readFile(pdb, event.fileId) 415 + if (file) broadcast(event.projectId, { type: 'file', file }) 416 + } 417 + return 418 + } 419 + case 'file:delete': { 420 + if (!authForProject(conn, event.projectId)) return 421 + const pdb = db.openProject(event.projectId) 422 + const meta = pdb 423 + .prepare(`SELECT v FROM meta WHERE k = 'main_file_id'`) 424 + .get() as { v: string } | undefined 425 + if (meta?.v === event.fileId) return // refuse: cannot delete main 426 + const f = readFile(pdb, event.fileId) 427 + pdb.prepare('DELETE FROM files WHERE id = ?').run(event.fileId) 428 + broadcast(event.projectId, { type: 'file:deleted', fileId: event.fileId }) 429 + // GC blob if not referenced elsewhere. 430 + if (f?.binary) { 431 + const still = pdb 432 + .prepare('SELECT 1 FROM files WHERE binary_sha256 = ? LIMIT 1') 433 + .get(f.binary.sha256) 434 + if (!still) { 435 + try { 436 + fs.unlinkSync( 437 + path.join(db.projectBlobDir(event.projectId), f.binary.sha256), 438 + ) 439 + } catch { 440 + // Best-effort. 441 + } 442 + } 443 + } 444 + return 445 + } 446 + case 'main:set': { 447 + if (!authForProject(conn, event.projectId)) return 448 + const pdb = db.openProject(event.projectId) 449 + setMeta(pdb, 'main_file_id', event.fileId) 450 + broadcast(event.projectId, { type: 'main', fileId: event.fileId }) 451 + return 452 + } 453 + case 'presence': { 454 + if (!authForProject(conn, event.projectId)) return 455 + broadcast(event.projectId, { 456 + type: 'presence', 457 + userId: conn.user.userId, 458 + ts: Date.now(), 459 + }) 460 + return 461 + } 462 + } 463 + } 464 + 465 + function authForProject(conn: WsConnection, projectId: string): boolean { 466 + if (conn.projectId !== projectId) { 467 + sendTo(conn, { type: 'error', message: 'not subscribed to that project' }) 468 + return false 469 + } 470 + if (!isMember(projectId, conn.user.userId)) { 471 + sendTo(conn, { type: 'error', message: 'not a member' }) 472 + return false 473 + } 474 + return true 475 + } 476 + 477 + // ---- registration --------------------------------------------------------- 478 + 479 + export async function registerWebSocket(app: FastifyInstance): Promise<void> { 480 + app.get('/ws', { websocket: true }, (socket, req) => { 481 + // @fastify/cookie populates req.cookies on the underlying request. 482 + const token = (req as unknown as { cookies?: Record<string, string> }) 483 + .cookies?.cs ?? undefined 484 + const user = getIdentityFromSession(token) 485 + if (!user) { 486 + socket.send(JSON.stringify({ type: 'error', message: 'unauthenticated' })) 487 + socket.close() 488 + return 489 + } 490 + const conn: WsConnection = { socket, user, projectId: null } 491 + 492 + socket.on('message', (raw: Buffer) => { 493 + let event: WsClientEvent 494 + try { 495 + event = JSON.parse(raw.toString('utf8')) as WsClientEvent 496 + } catch { 497 + sendTo(conn, { type: 'error', message: 'invalid json' }) 498 + return 499 + } 500 + try { 501 + handleEvent(conn, event) 502 + } catch (err) { 503 + sendTo(conn, { 504 + type: 'error', 505 + message: err instanceof Error ? err.message : String(err), 506 + }) 507 + } 508 + }) 509 + 510 + socket.on('close', () => { 511 + detachSubscription(conn) 512 + }) 513 + }) 514 + }
+1944
src/App.tsx
··· 1 + import React, { 2 + useCallback, 3 + useEffect, 4 + useRef, 5 + useState, 6 + useSyncExternalStore, 7 + } from 'react' 8 + import ReactMarkdown from 'react-markdown' 9 + import remarkGfm from 'remark-gfm' 10 + import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels' 11 + import Anthropic from '@anthropic-ai/sdk' 12 + 13 + import { 14 + auth as authApi, 15 + blobs as blobsApi, 16 + invites as invitesApi, 17 + projects as projectsApi, 18 + ws, 19 + } from './lib/api' 20 + import { keystore } from './lib/keystore' 21 + import { store, type ProjectState } from './lib/store' 22 + import { 23 + childrenOf, 24 + latestLeafId, 25 + latestLeafInSubtree, 26 + linearizeFromLeaf, 27 + runAgentTurn, 28 + type ToolCall, 29 + type ToolResult, 30 + } from './lib/claudeAgent' 31 + import { PROJECT_TOOLS, buildSystemPrompt } from './lib/systemPrompt' 32 + import type { 33 + Conversation, 34 + Identity, 35 + Member, 36 + ProjectFile, 37 + ProjectMeta, 38 + Turn, 39 + } from './shared/schema' 40 + 41 + // ─── store hook ──────────────────────────────────────────────────────────── 42 + 43 + function useProjectState(): ProjectState | null { 44 + return useSyncExternalStore(store.subscribe, store.getState, store.getState) 45 + } 46 + 47 + // ─── App ─────────────────────────────────────────────────────────────────── 48 + 49 + export function App(): React.ReactElement { 50 + const [identity, setIdentity] = useState<Identity | null>(null) 51 + const [bootDone, setBootDone] = useState(false) 52 + const [pendingInvite, setPendingInvite] = useState<string | null>(null) 53 + const [activeProject, setActiveProject] = useState<ProjectMeta | null>(null) 54 + 55 + useEffect(() => { 56 + const url = new URL(location.href) 57 + const invite = url.searchParams.get('invite') 58 + if (invite) setPendingInvite(invite) 59 + authApi.me().then((r) => { 60 + setIdentity(r.user) 61 + setBootDone(true) 62 + }) 63 + }, []) 64 + 65 + useEffect(() => { 66 + if (!identity) return 67 + ws.connect() 68 + return ws.on((event) => store.apply(event)) 69 + }, [identity?.userId]) 70 + 71 + useEffect(() => { 72 + if (!identity || !pendingInvite) return 73 + invitesApi 74 + .accept(pendingInvite) 75 + .then(({ projectId }) => { 76 + setPendingInvite(null) 77 + const url = new URL(location.href) 78 + url.searchParams.delete('invite') 79 + history.replaceState({}, '', url.toString()) 80 + return projectsApi.list().then((list) => { 81 + const p = list.find((x) => x.id === projectId) 82 + if (p) { 83 + setActiveProject(p) 84 + ws.subscribe(p.id) 85 + } 86 + }) 87 + }) 88 + .catch((err) => { 89 + window.alert(`Could not accept invite: ${err.message}`) 90 + setPendingInvite(null) 91 + }) 92 + }, [identity?.userId, pendingInvite]) 93 + 94 + const openProject = useCallback((p: ProjectMeta): void => { 95 + store.clear() 96 + setActiveProject(p) 97 + ws.subscribe(p.id) 98 + }, []) 99 + 100 + const backToList = useCallback((): void => { 101 + ws.unsubscribe() 102 + store.clear() 103 + setActiveProject(null) 104 + }, []) 105 + 106 + const signOut = useCallback(async (): Promise<void> => { 107 + await authApi.logout() 108 + ws.unsubscribe() 109 + store.clear() 110 + setActiveProject(null) 111 + setIdentity(null) 112 + }, []) 113 + 114 + if (!bootDone) return <Splash /> 115 + if (!identity) return <Login onLogin={setIdentity} /> 116 + if (!activeProject) 117 + return ( 118 + <ProjectList 119 + identity={identity} 120 + onOpen={openProject} 121 + onSignOut={signOut} 122 + /> 123 + ) 124 + return ( 125 + <ProjectView 126 + identity={identity} 127 + meta={activeProject} 128 + onBack={backToList} 129 + /> 130 + ) 131 + } 132 + 133 + function Splash(): React.ReactElement { 134 + return ( 135 + <div style={{ padding: 40, color: 'var(--mc-fg-faint)' }}>Loading…</div> 136 + ) 137 + } 138 + 139 + // ─── Login ───────────────────────────────────────────────────────────────── 140 + 141 + function Login({ 142 + onLogin, 143 + }: { 144 + onLogin: (i: Identity) => void 145 + }): React.ReactElement { 146 + const [email, setEmail] = useState('') 147 + const [apiKey, setApiKey] = useState('') 148 + const [busy, setBusy] = useState(false) 149 + const [error, setError] = useState<string | null>(null) 150 + const [hasStoredKey, setHasStoredKey] = useState(false) 151 + 152 + useEffect(() => { 153 + setHasStoredKey(false) 154 + const e = email.trim().toLowerCase() 155 + if (!e.includes('@')) return 156 + const t = setTimeout(async () => { 157 + if (await keystore.has(e)) setHasStoredKey(true) 158 + }, 250) 159 + return () => clearTimeout(t) 160 + }, [email]) 161 + 162 + const validateAndLogin = async (key: string): Promise<void> => { 163 + setBusy(true) 164 + setError(null) 165 + try { 166 + const client = new Anthropic({ apiKey: key, dangerouslyAllowBrowser: true }) 167 + await client.messages.create({ 168 + model: 'claude-haiku-4-5-20251001', 169 + max_tokens: 8, 170 + messages: [{ role: 'user', content: 'hi' }], 171 + }) 172 + await keystore.set(email.trim().toLowerCase(), key) 173 + const r = await authApi.login(email.trim().toLowerCase()) 174 + onLogin(r.user) 175 + } catch (err) { 176 + setError(err instanceof Error ? err.message : String(err)) 177 + } finally { 178 + setBusy(false) 179 + } 180 + } 181 + 182 + const submit = async (): Promise<void> => validateAndLogin(apiKey.trim()) 183 + const submitWithStored = async (): Promise<void> => { 184 + const stored = await keystore.get(email.trim().toLowerCase()) 185 + if (stored) await validateAndLogin(stored) 186 + } 187 + 188 + return ( 189 + <div style={{ maxWidth: 400, margin: '80px auto', padding: 16 }}> 190 + <h2 style={{ marginBottom: 4 }}>Centrosome</h2> 191 + <p style={{ color: 'var(--mc-fg-muted)', fontSize: 13, marginTop: 0 }}> 192 + Sign in with your email and Anthropic API key. The key is stored in 193 + the browser's IndexedDB; the Centrosome server never sees it. 194 + </p> 195 + <p style={{ color: 'var(--mc-fg-muted)', fontSize: 12, marginTop: 8 }}> 196 + Need a key? Create one at{' '} 197 + <a 198 + href="https://platform.claude.com/settings/workspaces/default/keys" 199 + target="_blank" 200 + rel="noreferrer" 201 + style={{ color: 'var(--mc-link)' }} 202 + > 203 + platform.claude.com 204 + </a> 205 + . Pro / Max subscriptions don't cover API usage — add credits under{' '} 206 + <a 207 + href="https://console.anthropic.com/settings/billing" 208 + target="_blank" 209 + rel="noreferrer" 210 + style={{ color: 'var(--mc-link)' }} 211 + > 212 + Plans &amp; Billing 213 + </a>{' '} 214 + before the key works. 215 + </p> 216 + <input 217 + value={email} 218 + onChange={(e) => setEmail(e.target.value)} 219 + placeholder="you@example.com" 220 + style={inputStyle} 221 + /> 222 + <input 223 + value={apiKey} 224 + onChange={(e) => setApiKey(e.target.value)} 225 + placeholder="sk-ant-..." 226 + type="password" 227 + style={inputStyle} 228 + /> 229 + <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> 230 + <button 231 + disabled={busy || !email || !apiKey} 232 + onClick={submit} 233 + style={buttonStyle} 234 + > 235 + {busy ? 'Validating…' : 'Sign in'} 236 + </button> 237 + {hasStoredKey && ( 238 + <button 239 + disabled={busy || !email} 240 + onClick={submitWithStored} 241 + style={buttonStyle} 242 + title="A previously-saved key for this email is in the browser's keystore" 243 + > 244 + Use saved key 245 + </button> 246 + )} 247 + </div> 248 + {error && ( 249 + <p style={{ color: 'var(--mc-danger)', fontSize: 13 }}>{error}</p> 250 + )} 251 + </div> 252 + ) 253 + } 254 + 255 + // ─── ProjectList ─────────────────────────────────────────────────────────── 256 + 257 + function ProjectList({ 258 + identity, 259 + onOpen, 260 + onSignOut, 261 + }: { 262 + identity: Identity 263 + onOpen: (m: ProjectMeta) => void 264 + onSignOut: () => void 265 + }): React.ReactElement { 266 + const [projects, setProjects] = useState<ProjectMeta[]>([]) 267 + const [newName, setNewName] = useState('') 268 + 269 + const refresh = useCallback(async () => { 270 + setProjects(await projectsApi.list()) 271 + }, []) 272 + useEffect(() => { 273 + refresh() 274 + }, [refresh]) 275 + 276 + const create = async (): Promise<void> => { 277 + if (!newName.trim()) return 278 + const p = await projectsApi.create(newName.trim()) 279 + setNewName('') 280 + await refresh() 281 + onOpen(p) 282 + } 283 + 284 + return ( 285 + <div style={{ maxWidth: 640, margin: '40px auto', padding: 16 }}> 286 + <h2>Projects</h2> 287 + <p style={{ fontSize: 13, color: 'var(--mc-fg-muted)' }}> 288 + Signed in as <strong>{identity.email}</strong> ({identity.userId}).{' '} 289 + <button onClick={onSignOut} style={linkBtn}> 290 + sign out 291 + </button> 292 + </p> 293 + <ul style={{ listStyle: 'none', padding: 0 }}> 294 + {projects.map((p) => ( 295 + <li 296 + key={p.id} 297 + onClick={() => onOpen(p)} 298 + style={{ 299 + padding: 12, 300 + border: '1px solid var(--mc-border)', 301 + borderRadius: 6, 302 + marginBottom: 8, 303 + cursor: 'pointer', 304 + }} 305 + > 306 + <strong>{p.name}</strong> 307 + {p.role && ( 308 + <span 309 + style={{ 310 + fontSize: 11, 311 + color: 'var(--mc-fg-faint)', 312 + marginLeft: 8, 313 + }} 314 + > 315 + {p.role} 316 + </span> 317 + )} 318 + </li> 319 + ))} 320 + {projects.length === 0 && ( 321 + <li style={{ color: 'var(--mc-fg-faint)', fontSize: 13 }}> 322 + No projects yet. Create one below or ask an owner to send you an 323 + invite link. 324 + </li> 325 + )} 326 + </ul> 327 + <div style={{ display: 'flex', gap: 8 }}> 328 + <input 329 + value={newName} 330 + onChange={(e) => setNewName(e.target.value)} 331 + placeholder="New project name" 332 + onKeyDown={(e) => { 333 + if (e.key === 'Enter') create() 334 + }} 335 + style={{ ...inputStyle, marginBottom: 0 }} 336 + /> 337 + <button onClick={create} style={buttonStyle}> 338 + Create 339 + </button> 340 + </div> 341 + </div> 342 + ) 343 + } 344 + 345 + // ─── ProjectView ─────────────────────────────────────────────────────────── 346 + 347 + function ProjectView({ 348 + identity, 349 + meta, 350 + onBack, 351 + }: { 352 + identity: Identity 353 + meta: ProjectMeta 354 + onBack: () => void 355 + }): React.ReactElement { 356 + const state = useProjectState() 357 + const [activeConvId, setActiveConvId] = useState<string | null>(null) 358 + const [selectedFileId, setSelectedFileId] = useState<string | null>(null) 359 + const [rightPaneOpen, setRightPaneOpen] = useState(false) 360 + const seenConvIds = useRef(new Set<string>()) 361 + 362 + // Auto-select a freshly-created conversation that came back from the 363 + // server (created within the last 5s by us). 364 + useEffect(() => { 365 + if (!state) return 366 + for (const c of state.conversations.values()) { 367 + if (seenConvIds.current.has(c.id)) continue 368 + seenConvIds.current.add(c.id) 369 + if ( 370 + c.createdBy === identity.userId && 371 + Date.now() - c.createdAt < 5000 && 372 + !activeConvId 373 + ) { 374 + setActiveConvId(c.id) 375 + } 376 + } 377 + }) 378 + 379 + if (!state) { 380 + return ( 381 + <div style={{ padding: 40, color: 'var(--mc-fg-faint)' }}> 382 + Loading project… 383 + </div> 384 + ) 385 + } 386 + 387 + const conversations = Array.from(state.conversations.values()).sort( 388 + (a, b) => b.createdAt - a.createdAt, 389 + ) 390 + const activeConv = activeConvId ? state.conversations.get(activeConvId) : null 391 + const turnsInConv = activeConv 392 + ? Array.from(state.turns.values()).filter((t) => t.convId === activeConv.id) 393 + : [] 394 + const selectedFile = selectedFileId ? state.files.get(selectedFileId) ?? null : null 395 + 396 + const newConv = (): void => { 397 + ws.send({ 398 + type: 'conversation:create', 399 + projectId: meta.id, 400 + title: 'Untitled', 401 + }) 402 + } 403 + 404 + const openFile = (fileId: string): void => { 405 + setSelectedFileId(fileId) 406 + setRightPaneOpen(true) 407 + } 408 + 409 + const newFile = (): void => { 410 + const raw = window.prompt( 411 + 'File name (e.g. notes.md, todo.md):', 412 + 'notes.md', 413 + ) 414 + if (!raw) return 415 + const name = raw.trim() 416 + if (!name) return 417 + const fileId = crypto.randomUUID() 418 + ws.send({ 419 + type: 'file:create', 420 + projectId: meta.id, 421 + file: { id: fileId, name, content: `# ${name}\n\n` }, 422 + }) 423 + openFile(fileId) 424 + } 425 + 426 + const uploadInputRef = useRef<HTMLInputElement>(null) 427 + const triggerUpload = (): void => uploadInputRef.current?.click() 428 + const MAX_TEXT_UPLOAD_BYTES = 256 * 1024 429 + const MAX_BINARY_UPLOAD_BYTES = 10 * 1024 * 1024 430 + 431 + const onUploadFiles = async ( 432 + e: React.ChangeEvent<HTMLInputElement>, 433 + ): Promise<void> => { 434 + const picked = Array.from(e.target.files ?? []) 435 + let lastId: string | null = null 436 + for (const f of picked) { 437 + if (looksLikeText(f)) { 438 + if (f.size > MAX_TEXT_UPLOAD_BYTES) { 439 + window.alert( 440 + `${f.name} is ${(f.size / 1024).toFixed(0)}KB — exceeds the 256KB text cap.`, 441 + ) 442 + continue 443 + } 444 + let text: string 445 + try { 446 + text = await f.text() 447 + } catch (err) { 448 + window.alert(`Could not read ${f.name} as text: ${String(err)}`) 449 + continue 450 + } 451 + const id = crypto.randomUUID() 452 + ws.send({ 453 + type: 'file:create', 454 + projectId: meta.id, 455 + file: { id, name: f.name, content: text }, 456 + }) 457 + lastId = id 458 + } else { 459 + if (f.size > MAX_BINARY_UPLOAD_BYTES) { 460 + window.alert( 461 + `${f.name} is ${(f.size / 1024 / 1024).toFixed(1)}MB — exceeds the 10MB binary cap.`, 462 + ) 463 + continue 464 + } 465 + let base64: string 466 + try { 467 + base64 = await readFileAsBase64(f) 468 + } catch (err) { 469 + window.alert(`Could not read ${f.name}: ${String(err)}`) 470 + continue 471 + } 472 + try { 473 + const r = await blobsApi.upload(meta.id, { 474 + name: f.name, 475 + base64, 476 + mimeType: f.type || 'application/octet-stream', 477 + }) 478 + lastId = r.fileId 479 + } catch (err) { 480 + window.alert(`Upload failed: ${String(err)}`) 481 + } 482 + } 483 + } 484 + if (lastId) openFile(lastId) 485 + if (uploadInputRef.current) uploadInputRef.current.value = '' 486 + } 487 + 488 + return ( 489 + <PanelGroup 490 + direction="horizontal" 491 + autoSaveId="mc-project-view" 492 + style={{ height: '100vh' }} 493 + > 494 + <Panel defaultSize={20} minSize={14} maxSize={40}> 495 + <aside 496 + style={{ 497 + height: '100%', 498 + padding: 12, 499 + overflowY: 'auto', 500 + boxSizing: 'border-box', 501 + }} 502 + > 503 + <button onClick={onBack} style={{ ...linkBtn, marginBottom: 8 }}> 504 + ← Projects 505 + </button> 506 + <h3 style={{ margin: '8px 0 4px 0' }}>{state.project.name}</h3> 507 + <MemberList 508 + members={state.members} 509 + presence={state.presence} 510 + meUserId={identity.userId} 511 + /> 512 + {state.members.get(identity.userId)?.role === 'owner' && ( 513 + <InviteControl projectId={meta.id} /> 514 + )} 515 + 516 + <SidebarSectionHeader 517 + label="Files" 518 + actions={[ 519 + { label: '↑ Upload', onClick: triggerUpload }, 520 + { label: '+ New', onClick: newFile }, 521 + ]} 522 + /> 523 + <input 524 + ref={uploadInputRef} 525 + type="file" 526 + multiple 527 + onChange={onUploadFiles} 528 + style={{ display: 'none' }} 529 + /> 530 + <FileList 531 + files={state.files} 532 + mainFileId={state.project.mainFileId} 533 + selectedFileId={selectedFileId} 534 + onOpen={openFile} 535 + /> 536 + 537 + <SidebarSectionHeader 538 + label="Conversations" 539 + actions={[{ label: '+ New', onClick: newConv }]} 540 + /> 541 + <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}> 542 + {conversations.map((c) => ( 543 + <li 544 + key={c.id} 545 + onClick={() => setActiveConvId(c.id)} 546 + style={{ 547 + padding: 8, 548 + borderRadius: 4, 549 + cursor: 'pointer', 550 + background: 551 + c.id === activeConvId ? 'var(--mc-bg-sel)' : 'transparent', 552 + fontSize: 13, 553 + }} 554 + > 555 + {c.title || 'Untitled'} 556 + <div style={{ fontSize: 10, color: 'var(--mc-fg-faint)' }}> 557 + {countTurns(state, c.id)} turns 558 + </div> 559 + </li> 560 + ))} 561 + {conversations.length === 0 && ( 562 + <li 563 + style={{ 564 + color: 'var(--mc-fg-faint)', 565 + fontSize: 12, 566 + padding: 8, 567 + }} 568 + > 569 + No conversations yet. 570 + </li> 571 + )} 572 + </ul> 573 + </aside> 574 + </Panel> 575 + <PanelResizeHandle style={resizeHandleStyle} /> 576 + <Panel minSize={30}> 577 + <main 578 + style={{ 579 + height: '100%', 580 + display: 'flex', 581 + flexDirection: 'column', 582 + }} 583 + > 584 + <ProjectHeader 585 + files={state.files} 586 + rightPaneOpen={rightPaneOpen} 587 + onToggleRight={() => setRightPaneOpen((v) => !v)} 588 + /> 589 + {activeConv ? ( 590 + <ConversationView 591 + key={activeConv.id} 592 + identity={identity} 593 + projectId={meta.id} 594 + project={state.project} 595 + members={state.members} 596 + files={state.files} 597 + conv={activeConv} 598 + turns={turnsInConv} 599 + defaultModel={state.project.defaultModel} 600 + /> 601 + ) : ( 602 + <div style={{ margin: 'auto', color: 'var(--mc-fg-faint)' }}> 603 + Pick or create a conversation. 604 + </div> 605 + )} 606 + </main> 607 + </Panel> 608 + {rightPaneOpen && ( 609 + <> 610 + <PanelResizeHandle style={resizeHandleStyle} /> 611 + <Panel defaultSize={32} minSize={20}> 612 + <FileViewer 613 + projectId={meta.id} 614 + file={selectedFile} 615 + isMain={selectedFileId === state.project.mainFileId} 616 + members={state.members} 617 + onClose={() => setRightPaneOpen(false)} 618 + /> 619 + </Panel> 620 + </> 621 + )} 622 + </PanelGroup> 623 + ) 624 + } 625 + 626 + function countTurns(state: ProjectState, convId: string): number { 627 + let n = 0 628 + for (const t of state.turns.values()) if (t.convId === convId) n++ 629 + return n 630 + } 631 + 632 + // ─── Project header ──────────────────────────────────────────────────────── 633 + 634 + function ProjectHeader({ 635 + files, 636 + rightPaneOpen, 637 + onToggleRight, 638 + }: { 639 + files: Map<string, ProjectFile> 640 + rightPaneOpen: boolean 641 + onToggleRight: () => void 642 + }): React.ReactElement { 643 + const arr = Array.from(files.values()) 644 + const totalChars = arr.reduce((s, f) => s + f.content.length, 0) 645 + const estTokens = Math.round(totalChars / 4 / 100) / 10 646 + return ( 647 + <div 648 + style={{ 649 + display: 'flex', 650 + justifyContent: 'space-between', 651 + alignItems: 'center', 652 + padding: '6px 12px', 653 + borderBottom: '1px solid var(--mc-border-faint)', 654 + background: 'var(--mc-bg-elev)', 655 + fontSize: 12, 656 + color: 'var(--mc-fg-muted)', 657 + }} 658 + > 659 + <span 660 + title={`${totalChars.toLocaleString()} characters across ${arr.length} file(s)`} 661 + > 662 + Context: ~{estTokens}k tokens · {arr.length} file{arr.length === 1 ? '' : 's'} 663 + </span> 664 + <button onClick={onToggleRight} style={linkBtn}> 665 + {rightPaneOpen ? 'Hide files ›' : '‹ Show files'} 666 + </button> 667 + </div> 668 + ) 669 + } 670 + 671 + // ─── Sidebar bits ────────────────────────────────────────────────────────── 672 + 673 + function SidebarSectionHeader({ 674 + label, 675 + actions, 676 + }: { 677 + label: string 678 + actions?: Array<{ label: string; onClick: () => void }> 679 + }): React.ReactElement { 680 + return ( 681 + <div 682 + style={{ 683 + display: 'flex', 684 + justifyContent: 'space-between', 685 + alignItems: 'baseline', 686 + marginTop: 14, 687 + marginBottom: 6, 688 + borderBottom: '1px solid var(--mc-border-faint)', 689 + paddingBottom: 2, 690 + gap: 8, 691 + }} 692 + > 693 + <span 694 + style={{ 695 + fontSize: 11, 696 + fontWeight: 600, 697 + color: 'var(--mc-fg-muted)', 698 + textTransform: 'uppercase', 699 + letterSpacing: 0.4, 700 + }} 701 + > 702 + {label} 703 + </span> 704 + <div style={{ display: 'flex', gap: 8 }}> 705 + {actions?.map((a) => ( 706 + <button 707 + key={a.label} 708 + onClick={a.onClick} 709 + style={{ ...linkBtn, fontSize: 11 }} 710 + > 711 + {a.label} 712 + </button> 713 + ))} 714 + </div> 715 + </div> 716 + ) 717 + } 718 + 719 + function FileList({ 720 + files, 721 + mainFileId, 722 + selectedFileId, 723 + onOpen, 724 + }: { 725 + files: Map<string, ProjectFile> 726 + mainFileId: string | null 727 + selectedFileId: string | null 728 + onOpen: (id: string) => void 729 + }): React.ReactElement { 730 + const all = Array.from(files.values()) 731 + const main = mainFileId ? files.get(mainFileId) ?? null : null 732 + const working = all 733 + .filter((f) => f.id !== mainFileId) 734 + .sort((a, b) => a.name.localeCompare(b.name)) 735 + 736 + const item = (f: ProjectFile, isMain: boolean): React.ReactElement => { 737 + const icon = isMain ? '★' : f.binary ? '◇' : '•' 738 + return ( 739 + <li 740 + key={f.id} 741 + onClick={() => onOpen(f.id)} 742 + style={{ 743 + padding: '6px 8px', 744 + borderRadius: 4, 745 + cursor: 'pointer', 746 + background: 747 + f.id === selectedFileId ? 'var(--mc-bg-sel)' : 'transparent', 748 + fontSize: 13, 749 + display: 'flex', 750 + alignItems: 'center', 751 + gap: 6, 752 + }} 753 + > 754 + <span 755 + style={{ 756 + fontSize: 11, 757 + color: 'var(--mc-fg-faintest)', 758 + width: 12, 759 + }} 760 + > 761 + {icon} 762 + </span> 763 + <span 764 + style={{ 765 + flex: 1, 766 + overflow: 'hidden', 767 + textOverflow: 'ellipsis', 768 + whiteSpace: 'nowrap', 769 + }} 770 + > 771 + {f.name} 772 + </span> 773 + </li> 774 + ) 775 + } 776 + 777 + return ( 778 + <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}> 779 + {main && item(main, true)} 780 + {working.map((f) => item(f, false))} 781 + {!main && working.length === 0 && ( 782 + <li 783 + style={{ 784 + color: 'var(--mc-fg-faint)', 785 + fontSize: 12, 786 + padding: '4px 8px', 787 + }} 788 + > 789 + No files yet. 790 + </li> 791 + )} 792 + </ul> 793 + ) 794 + } 795 + 796 + // ─── Members + invite ────────────────────────────────────────────────────── 797 + 798 + function MemberList({ 799 + members, 800 + presence, 801 + meUserId, 802 + }: { 803 + members: Map<string, Member> 804 + presence: Map<string, number> 805 + meUserId: string 806 + }): React.ReactElement { 807 + const entries = Array.from(members.values()).sort((a, b) => { 808 + if (a.userId === meUserId) return -1 809 + if (b.userId === meUserId) return 1 810 + return a.displayName.localeCompare(b.displayName) 811 + }) 812 + const now = Date.now() 813 + return ( 814 + <div 815 + style={{ 816 + display: 'flex', 817 + flexWrap: 'wrap', 818 + gap: 4, 819 + fontSize: 11, 820 + color: 'var(--mc-fg-muted)', 821 + marginBottom: 4, 822 + }} 823 + > 824 + {entries.map((m) => { 825 + const ts = presence.get(m.userId) ?? 0 826 + const online = now - ts < 15_000 827 + return ( 828 + <span 829 + key={m.userId} 830 + title={`${m.email}${online ? ' · online' : ''}`} 831 + style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }} 832 + > 833 + <span 834 + style={{ 835 + display: 'inline-block', 836 + width: 6, 837 + height: 6, 838 + borderRadius: '50%', 839 + background: online ? '#28a745' : 'var(--mc-border)', 840 + }} 841 + /> 842 + {m.displayName} 843 + {m.userId === meUserId ? '*' : ''} 844 + </span> 845 + ) 846 + })} 847 + </div> 848 + ) 849 + } 850 + 851 + function InviteControl({ 852 + projectId, 853 + }: { 854 + projectId: string 855 + }): React.ReactElement { 856 + const [mode, setMode] = useState<'closed' | 'pending' | 'shown'>('closed') 857 + const [url, setUrl] = useState<string | null>(null) 858 + const [error, setError] = useState<string | null>(null) 859 + const [copied, setCopied] = useState(false) 860 + 861 + const generate = async (): Promise<void> => { 862 + setMode('pending') 863 + setError(null) 864 + try { 865 + const { token } = await projectsApi.invite(projectId) 866 + const u = new URL(location.origin) 867 + u.searchParams.set('invite', token) 868 + setUrl(u.toString()) 869 + setMode('shown') 870 + } catch (err) { 871 + setError(err instanceof Error ? err.message : String(err)) 872 + setMode('closed') 873 + } 874 + } 875 + 876 + const copy = async (): Promise<void> => { 877 + if (!url) return 878 + try { 879 + await navigator.clipboard.writeText(url) 880 + setCopied(true) 881 + setTimeout(() => setCopied(false), 1500) 882 + } catch { 883 + // No-op. 884 + } 885 + } 886 + 887 + if (mode === 'closed') { 888 + return ( 889 + <div style={{ marginBottom: 4 }}> 890 + <button 891 + onClick={generate} 892 + style={{ ...linkBtn, fontSize: 11 }} 893 + > 894 + + Invite member 895 + </button> 896 + {error && ( 897 + <div style={{ color: 'var(--mc-danger)', fontSize: 10 }}>{error}</div> 898 + )} 899 + </div> 900 + ) 901 + } 902 + if (mode === 'pending') { 903 + return ( 904 + <div style={{ fontSize: 11, color: 'var(--mc-fg-muted)', marginBottom: 4 }}> 905 + Generating invite… 906 + </div> 907 + ) 908 + } 909 + return ( 910 + <div 911 + style={{ 912 + border: '1px solid var(--mc-border)', 913 + borderRadius: 4, 914 + padding: 8, 915 + marginBottom: 6, 916 + fontSize: 11, 917 + color: 'var(--mc-fg-muted)', 918 + }} 919 + > 920 + <div style={{ marginBottom: 6, color: 'var(--mc-fg)' }}> 921 + Send this URL to the person you want to add. It's one-shot — they'll 922 + be added as a member when they click it (after signing in). 923 + </div> 924 + <div 925 + style={{ 926 + fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace', 927 + fontSize: 10, 928 + background: 'var(--mc-bg-card)', 929 + padding: 6, 930 + borderRadius: 3, 931 + wordBreak: 'break-all', 932 + marginBottom: 6, 933 + }} 934 + > 935 + {url} 936 + </div> 937 + <div style={{ display: 'flex', gap: 6 }}> 938 + <button onClick={copy} style={linkBtn}> 939 + {copied ? '✓ Copied' : 'Copy'} 940 + </button> 941 + <button onClick={generate} style={linkBtn}> 942 + New token 943 + </button> 944 + <button onClick={() => setMode('closed')} style={linkBtn}> 945 + Done 946 + </button> 947 + </div> 948 + </div> 949 + ) 950 + } 951 + 952 + // ─── File viewer ─────────────────────────────────────────────────────────── 953 + 954 + function FileViewer({ 955 + projectId, 956 + file, 957 + isMain, 958 + members, 959 + onClose, 960 + }: { 961 + projectId: string 962 + file: ProjectFile | null 963 + isMain: boolean 964 + members: Map<string, Member> 965 + onClose: () => void 966 + }): React.ReactElement { 967 + const [mode, setMode] = useState<'view' | 'edit'>('view') 968 + const [draft, setDraft] = useState('') 969 + const [basedOn, setBasedOn] = useState('') 970 + const [renaming, setRenaming] = useState(false) 971 + const [nameDraft, setNameDraft] = useState('') 972 + const [followLive, setFollowLive] = useState(false) 973 + 974 + const fileId = file?.id ?? null 975 + useEffect(() => { 976 + setMode('view') 977 + setDraft(file?.content ?? '') 978 + setBasedOn(file?.content ?? '') 979 + setRenaming(false) 980 + setNameDraft(file?.name ?? '') 981 + setFollowLive(false) 982 + }, [fileId]) 983 + 984 + useEffect(() => { 985 + if (followLive && file) { 986 + setDraft(file.content) 987 + setBasedOn(file.content) 988 + } 989 + }, [followLive, file?.content]) 990 + 991 + if (!file) { 992 + return ( 993 + <PaneShell title="No file selected" onClose={onClose}> 994 + <div style={{ padding: 16, color: 'var(--mc-fg-faint)', fontSize: 13 }}> 995 + Pick a file from the left sidebar. 996 + </div> 997 + </PaneShell> 998 + ) 999 + } 1000 + 1001 + if (file.binary) { 1002 + const subtitle = `binary · uploaded by ${ 1003 + members.get(file.updatedBy)?.displayName ?? file.updatedBy 1004 + } ${formatRelative(file.updatedAt)}` 1005 + return ( 1006 + <PaneShell title={file.name} subtitle={subtitle} onClose={onClose}> 1007 + <BinaryFileViewer 1008 + projectId={projectId} 1009 + file={file} 1010 + onDelete={() => { 1011 + if (!window.confirm(`Delete "${file.name}"?`)) return 1012 + ws.send({ type: 'file:delete', projectId, fileId: file.id }) 1013 + onClose() 1014 + }} 1015 + /> 1016 + </PaneShell> 1017 + ) 1018 + } 1019 + 1020 + const dirty = mode === 'edit' && draft !== basedOn 1021 + const peerChanged = mode === 'edit' && file.content !== basedOn 1022 + 1023 + const save = (): void => { 1024 + if (!dirty || followLive) return 1025 + ws.send({ 1026 + type: 'file:update', 1027 + projectId, 1028 + fileId: file.id, 1029 + fields: { content: draft }, 1030 + }) 1031 + setBasedOn(draft) 1032 + setMode('view') 1033 + } 1034 + const discard = (): void => { 1035 + setDraft(file.content) 1036 + setBasedOn(file.content) 1037 + setMode('view') 1038 + } 1039 + const del = (): void => { 1040 + if (!window.confirm(`Delete "${file.name}"?`)) return 1041 + ws.send({ type: 'file:delete', projectId, fileId: file.id }) 1042 + onClose() 1043 + } 1044 + const commitRename = (): void => { 1045 + const next = nameDraft.trim() 1046 + if (!next || next === file.name) { 1047 + setRenaming(false) 1048 + setNameDraft(file.name) 1049 + return 1050 + } 1051 + ws.send({ 1052 + type: 'file:update', 1053 + projectId, 1054 + fileId: file.id, 1055 + fields: { name: next }, 1056 + }) 1057 + setRenaming(false) 1058 + } 1059 + const cancelRename = (): void => { 1060 + setRenaming(false) 1061 + setNameDraft(file.name) 1062 + } 1063 + 1064 + const subtitle = `${isMain ? 'main file · ' : ''}updated by ${ 1065 + members.get(file.updatedBy)?.displayName ?? file.updatedBy 1066 + } ${formatRelative(file.updatedAt)}` 1067 + 1068 + const titleNode = renaming ? ( 1069 + <input 1070 + autoFocus 1071 + value={nameDraft} 1072 + onChange={(e) => setNameDraft(e.target.value)} 1073 + onBlur={commitRename} 1074 + onKeyDown={(e) => { 1075 + if (e.key === 'Enter') commitRename() 1076 + if (e.key === 'Escape') cancelRename() 1077 + }} 1078 + style={{ 1079 + fontSize: 13, 1080 + fontWeight: 500, 1081 + padding: '2px 4px', 1082 + width: '100%', 1083 + boxSizing: 'border-box', 1084 + }} 1085 + /> 1086 + ) : ( 1087 + <span 1088 + onClick={() => setRenaming(true)} 1089 + title="Click to rename" 1090 + style={{ cursor: 'text' }} 1091 + > 1092 + {file.name} 1093 + </span> 1094 + ) 1095 + 1096 + return ( 1097 + <PaneShell title={titleNode} subtitle={subtitle} onClose={onClose}> 1098 + <div 1099 + style={{ 1100 + display: 'flex', 1101 + gap: 8, 1102 + padding: '6px 12px', 1103 + borderBottom: '1px solid var(--mc-border-faint)', 1104 + background: 'var(--mc-bg-elev)', 1105 + fontSize: 12, 1106 + alignItems: 'center', 1107 + }} 1108 + > 1109 + <button 1110 + onClick={() => setMode('view')} 1111 + style={{ ...linkBtn, fontWeight: mode === 'view' ? 600 : 400 }} 1112 + > 1113 + Preview 1114 + </button> 1115 + <button 1116 + onClick={() => setMode('edit')} 1117 + style={{ ...linkBtn, fontWeight: mode === 'edit' ? 600 : 400 }} 1118 + > 1119 + Edit 1120 + </button> 1121 + {mode === 'edit' && ( 1122 + <> 1123 + <span style={{ width: 1, height: 12, background: 'var(--mc-border)' }} /> 1124 + <button onClick={save} disabled={!dirty || followLive} style={linkBtn}> 1125 + Save 1126 + </button> 1127 + <button onClick={discard} disabled={!dirty || followLive} style={linkBtn}> 1128 + Discard 1129 + </button> 1130 + <button 1131 + onClick={() => setFollowLive((v) => !v)} 1132 + style={{ ...linkBtn, fontWeight: followLive ? 600 : 400 }} 1133 + title="Mirror live file content (read-only)" 1134 + > 1135 + {followLive ? '● Following' : '○ Follow live'} 1136 + </button> 1137 + {peerChanged && !followLive && ( 1138 + <> 1139 + <button 1140 + onClick={() => { 1141 + setDraft(file.content) 1142 + setBasedOn(file.content) 1143 + }} 1144 + style={linkBtn} 1145 + > 1146 + Reset to current 1147 + </button> 1148 + <span style={{ color: 'var(--mc-warning)', fontSize: 11 }}> 1149 + ⚠ peer updated this file 1150 + </span> 1151 + </> 1152 + )} 1153 + </> 1154 + )} 1155 + {!isMain && ( 1156 + <button 1157 + onClick={del} 1158 + style={{ ...linkBtn, marginLeft: 'auto', color: 'var(--mc-danger)' }} 1159 + > 1160 + Delete 1161 + </button> 1162 + )} 1163 + </div> 1164 + <div style={{ flex: 1, overflowY: 'auto' }}> 1165 + {mode === 'view' ? ( 1166 + <div className="mc-md" style={{ padding: 16 }}> 1167 + <ReactMarkdown remarkPlugins={[remarkGfm]}> 1168 + {file.content} 1169 + </ReactMarkdown> 1170 + </div> 1171 + ) : ( 1172 + <textarea 1173 + value={draft} 1174 + readOnly={followLive} 1175 + onChange={(e) => setDraft(e.target.value)} 1176 + onKeyDown={(e) => { 1177 + if ((e.metaKey || e.ctrlKey) && e.key === 's') { 1178 + e.preventDefault() 1179 + save() 1180 + } 1181 + }} 1182 + style={{ 1183 + width: '100%', 1184 + height: '100%', 1185 + boxSizing: 'border-box', 1186 + border: 'none', 1187 + outline: 'none', 1188 + padding: 14, 1189 + fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace', 1190 + fontSize: 13, 1191 + resize: 'none', 1192 + background: followLive ? 'var(--mc-bg-elev)' : 'var(--mc-bg)', 1193 + color: 'var(--mc-fg)', 1194 + cursor: followLive ? 'default' : 'text', 1195 + }} 1196 + /> 1197 + )} 1198 + </div> 1199 + </PaneShell> 1200 + ) 1201 + } 1202 + 1203 + function BinaryFileViewer({ 1204 + projectId, 1205 + file, 1206 + onDelete, 1207 + }: { 1208 + projectId: string 1209 + file: ProjectFile 1210 + onDelete: () => void 1211 + }): React.ReactElement { 1212 + const isImage = file.binary?.mimeType?.startsWith('image/') ?? false 1213 + const blobUrl = file.binary ? blobsApi.url(projectId, file.binary.sha256) : null 1214 + 1215 + const download = async (): Promise<void> => { 1216 + if (!file.binary) return 1217 + const b = await blobsApi.download(projectId, file.binary.sha256) 1218 + const url = URL.createObjectURL(b) 1219 + const a = document.createElement('a') 1220 + a.href = url 1221 + a.download = file.name 1222 + document.body.appendChild(a) 1223 + a.click() 1224 + document.body.removeChild(a) 1225 + URL.revokeObjectURL(url) 1226 + } 1227 + 1228 + return ( 1229 + <div style={{ flex: 1, overflowY: 'auto', padding: 16 }}> 1230 + <div 1231 + style={{ 1232 + fontSize: 12, 1233 + color: 'var(--mc-fg-muted)', 1234 + marginBottom: 12, 1235 + }} 1236 + > 1237 + {file.binary?.mimeType} ·{' '} 1238 + {((file.binary?.sizeBytes ?? 0) / 1024).toFixed(1)} KB 1239 + </div> 1240 + {isImage && blobUrl && ( 1241 + <img 1242 + src={blobUrl} 1243 + alt={file.name} 1244 + style={{ maxWidth: '100%', height: 'auto', borderRadius: 4 }} 1245 + /> 1246 + )} 1247 + <div style={{ marginTop: 16, display: 'flex', gap: 12 }}> 1248 + <button onClick={download} style={linkBtn}> 1249 + Download 1250 + </button> 1251 + <button 1252 + onClick={onDelete} 1253 + style={{ ...linkBtn, color: 'var(--mc-danger)' }} 1254 + > 1255 + Delete 1256 + </button> 1257 + </div> 1258 + </div> 1259 + ) 1260 + } 1261 + 1262 + function PaneShell({ 1263 + title, 1264 + subtitle, 1265 + onClose, 1266 + children, 1267 + }: { 1268 + title: React.ReactNode 1269 + subtitle?: string 1270 + onClose: () => void 1271 + children: React.ReactNode 1272 + }): React.ReactElement { 1273 + return ( 1274 + <aside 1275 + style={{ 1276 + height: '100%', 1277 + boxSizing: 'border-box', 1278 + display: 'flex', 1279 + flexDirection: 'column', 1280 + }} 1281 + > 1282 + <div 1283 + style={{ 1284 + display: 'flex', 1285 + justifyContent: 'space-between', 1286 + alignItems: 'center', 1287 + padding: '6px 12px', 1288 + borderBottom: '1px solid var(--mc-border-faint)', 1289 + background: 'var(--mc-bg-elev)', 1290 + }} 1291 + > 1292 + <div style={{ flex: 1, minWidth: 0 }}> 1293 + <div style={{ fontSize: 13, fontWeight: 500 }}>{title}</div> 1294 + {subtitle && ( 1295 + <div style={{ fontSize: 10, color: 'var(--mc-fg-faint)' }}> 1296 + {subtitle} 1297 + </div> 1298 + )} 1299 + </div> 1300 + <button onClick={onClose} style={linkBtn}> 1301 + × 1302 + </button> 1303 + </div> 1304 + {children} 1305 + </aside> 1306 + ) 1307 + } 1308 + 1309 + // ─── ConversationView ───────────────────────────────────────────────────── 1310 + 1311 + function ConversationView({ 1312 + identity, 1313 + projectId, 1314 + project, 1315 + members, 1316 + files, 1317 + conv, 1318 + turns, 1319 + defaultModel, 1320 + }: { 1321 + identity: Identity 1322 + projectId: string 1323 + project: { systemPrompt: string; mainFileId: string | null } 1324 + members: Map<string, Member> 1325 + files: Map<string, ProjectFile> 1326 + conv: Conversation 1327 + turns: Turn[] 1328 + defaultModel: string 1329 + }): React.ReactElement { 1330 + const [text, setText] = useState('') 1331 + const [localSending, setLocalSending] = useState(false) 1332 + const [selectedLeafId, setSelectedLeafId] = useState<string | null>(null) 1333 + useEffect(() => { 1334 + setSelectedLeafId(null) 1335 + }, [conv.id]) 1336 + 1337 + const turnsMap = new Map(turns.map((t) => [t.id, t])) 1338 + const effectiveLeafId = 1339 + selectedLeafId && turnsMap.has(selectedLeafId) 1340 + ? selectedLeafId 1341 + : latestLeafId(turns) 1342 + const linear = effectiveLeafId 1343 + ? linearizeFromLeaf(turnsMap, effectiveLeafId) 1344 + : [] 1345 + 1346 + const scrollerRef = useRef<HTMLDivElement>(null) 1347 + const stickToBottomRef = useRef(true) 1348 + const lastTurnId = linear[linear.length - 1]?.id ?? null 1349 + const lastTurnLen = linear[linear.length - 1]?.text.length ?? 0 1350 + useEffect(() => { 1351 + const el = scrollerRef.current 1352 + if (!el) return 1353 + el.scrollTop = el.scrollHeight 1354 + stickToBottomRef.current = true 1355 + }, [conv.id]) 1356 + useEffect(() => { 1357 + const el = scrollerRef.current 1358 + if (!el || !stickToBottomRef.current) return 1359 + el.scrollTop = el.scrollHeight 1360 + }, [lastTurnId, lastTurnLen, linear.length]) 1361 + const onScrollerScroll = (): void => { 1362 + const el = scrollerRef.current 1363 + if (!el) return 1364 + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80 1365 + stickToBottomRef.current = nearBottom 1366 + } 1367 + 1368 + const findParentLeaf = (): string | null => { 1369 + if (turns.length === 0) return null 1370 + const hasChild = new Set<string>() 1371 + for (const t of turns) if (t.parentId) hasChild.add(t.parentId) 1372 + const leaves = turns 1373 + .filter((t) => !hasChild.has(t.id)) 1374 + .sort((a, b) => b.createdAt - a.createdAt) 1375 + const usable = leaves.find( 1376 + (t) => t.role === 'user' || !t.status || t.status === 'complete', 1377 + ) 1378 + if (usable) return usable.id 1379 + return leaves[0]?.parentId ?? null 1380 + } 1381 + 1382 + const send = async (): Promise<void> => { 1383 + if (!text.trim() || localSending) return 1384 + const apiKey = await keystore.get(identity.email) 1385 + if (!apiKey) { 1386 + window.alert('No API key found in browser. Sign out and back in.') 1387 + return 1388 + } 1389 + setLocalSending(true) 1390 + const t = text 1391 + setText('') 1392 + 1393 + const parentId = findParentLeaf() 1394 + const userTurnId = crypto.randomUUID() 1395 + const asstTurnId = crypto.randomUUID() 1396 + const now = Date.now() 1397 + 1398 + ws.send({ 1399 + type: 'turn:create', 1400 + projectId, 1401 + convId: conv.id, 1402 + turn: { 1403 + id: userTurnId, 1404 + parentId, 1405 + author: identity.userId, 1406 + role: 'user', 1407 + createdAt: now, 1408 + text: t, 1409 + }, 1410 + }) 1411 + ws.send({ 1412 + type: 'turn:create', 1413 + projectId, 1414 + convId: conv.id, 1415 + turn: { 1416 + id: asstTurnId, 1417 + parentId: userTurnId, 1418 + author: identity.userId, 1419 + role: 'assistant', 1420 + createdAt: now + 1, 1421 + text: '', 1422 + status: 'streaming', 1423 + modelId: defaultModel, 1424 + paidBy: identity.userId, 1425 + }, 1426 + }) 1427 + 1428 + // Build agent history including the just-sent user turn. 1429 + const speculativeTurns = new Map(turnsMap) 1430 + speculativeTurns.set(userTurnId, { 1431 + id: userTurnId, 1432 + convId: conv.id, 1433 + parentId, 1434 + author: identity.userId, 1435 + role: 'user', 1436 + createdAt: now, 1437 + text: t, 1438 + }) 1439 + const history = linearizeFromLeaf(speculativeTurns, userTurnId) 1440 + 1441 + const STREAM_FLUSH_MS = 250 1442 + let pendingText = '' 1443 + let lastFlushed = '' 1444 + const flushIfChanged = (): void => { 1445 + if (pendingText === lastFlushed) return 1446 + ws.send({ 1447 + type: 'turn:update', 1448 + projectId, 1449 + turnId: asstTurnId, 1450 + fields: { text: pendingText }, 1451 + }) 1452 + lastFlushed = pendingText 1453 + } 1454 + const flushTimer = window.setInterval(flushIfChanged, STREAM_FLUSH_MS) 1455 + 1456 + const handleToolUse = async (call: ToolCall): Promise<ToolResult> => { 1457 + const input = call.input 1458 + if (call.name === 'update_main_file') { 1459 + const content = typeof input.content === 'string' ? input.content : '' 1460 + const mainId = project.mainFileId 1461 + if (!mainId) { 1462 + return { toolUseId: call.id, content: 'No main file exists.', isError: true } 1463 + } 1464 + const f = files.get(mainId) 1465 + if (!f) { 1466 + return { 1467 + toolUseId: call.id, 1468 + content: 'Main file not found in current state.', 1469 + isError: true, 1470 + } 1471 + } 1472 + ws.send({ 1473 + type: 'file:update', 1474 + projectId, 1475 + fileId: mainId, 1476 + fields: { content }, 1477 + }) 1478 + return { toolUseId: call.id, content: `Updated ${f.name}` } 1479 + } 1480 + if (call.name === 'create_file') { 1481 + const name = typeof input.name === 'string' ? input.name.trim() : '' 1482 + const content = typeof input.content === 'string' ? input.content : '' 1483 + if (!name) { 1484 + return { toolUseId: call.id, content: 'Error: name is required.', isError: true } 1485 + } 1486 + const newId = crypto.randomUUID() 1487 + ws.send({ 1488 + type: 'file:create', 1489 + projectId, 1490 + file: { id: newId, name, content }, 1491 + }) 1492 + return { toolUseId: call.id, content: `Created ${name}` } 1493 + } 1494 + if (call.name === 'update_file') { 1495 + const name = typeof input.name === 'string' ? input.name : '' 1496 + const content = typeof input.content === 'string' ? input.content : '' 1497 + const f = Array.from(files.values()).find((x) => x.name === name) 1498 + if (!f) { 1499 + return { toolUseId: call.id, content: `File "${name}" not found.`, isError: true } 1500 + } 1501 + if (f.binary) { 1502 + return { toolUseId: call.id, content: `File "${name}" is binary; cannot edit.`, isError: true } 1503 + } 1504 + ws.send({ 1505 + type: 'file:update', 1506 + projectId, 1507 + fileId: f.id, 1508 + fields: { content }, 1509 + }) 1510 + return { toolUseId: call.id, content: `Updated ${name}` } 1511 + } 1512 + if (call.name === 'patch_file') { 1513 + const name = typeof input.name === 'string' ? input.name : '' 1514 + const rawPatches = Array.isArray(input.patches) ? input.patches : [] 1515 + const patches = rawPatches 1516 + .map((p) => 1517 + p && typeof p === 'object' 1518 + ? (p as { search?: unknown; replace?: unknown }) 1519 + : null, 1520 + ) 1521 + .filter( 1522 + (p): p is { search: string; replace: string } => 1523 + !!p && 1524 + typeof p.search === 'string' && 1525 + typeof p.replace === 'string', 1526 + ) 1527 + if (patches.length === 0) { 1528 + return { toolUseId: call.id, content: 'Error: patches must be a non-empty array.', isError: true } 1529 + } 1530 + const f = Array.from(files.values()).find((x) => x.name === name) 1531 + if (!f) { 1532 + return { toolUseId: call.id, content: `File "${name}" not found.`, isError: true } 1533 + } 1534 + if (f.binary) { 1535 + return { toolUseId: call.id, content: `File "${name}" is binary; cannot patch.`, isError: true } 1536 + } 1537 + let content = f.content 1538 + for (let i = 0; i < patches.length; i++) { 1539 + const { search, replace } = patches[i] 1540 + if (!search) { 1541 + return { toolUseId: call.id, content: `Patch ${i + 1}: empty search string.`, isError: true } 1542 + } 1543 + const idx = content.indexOf(search) 1544 + if (idx === -1) { 1545 + return { toolUseId: call.id, content: `Patch ${i + 1}: search string not found in ${name}.`, isError: true } 1546 + } 1547 + const idx2 = content.indexOf(search, idx + 1) 1548 + if (idx2 !== -1) { 1549 + return { toolUseId: call.id, content: `Patch ${i + 1}: search string matches multiple places in ${name}. Add more context.`, isError: true } 1550 + } 1551 + content = content.slice(0, idx) + replace + content.slice(idx + search.length) 1552 + } 1553 + ws.send({ 1554 + type: 'file:patch', 1555 + projectId, 1556 + fileId: f.id, 1557 + patches, 1558 + }) 1559 + return { 1560 + toolUseId: call.id, 1561 + content: `Applied ${patches.length} patch${patches.length === 1 ? '' : 'es'} to ${name}`, 1562 + } 1563 + } 1564 + return { toolUseId: call.id, content: `Unknown tool: ${call.name}`, isError: true } 1565 + } 1566 + 1567 + await new Promise<void>((resolve) => { 1568 + runAgentTurn( 1569 + { 1570 + apiKey, 1571 + model: defaultModel, 1572 + getSystemPrompt: () => 1573 + buildSystemPrompt({ 1574 + systemPrompt: project.systemPrompt, 1575 + mainFileId: project.mainFileId, 1576 + files: Array.from(files.values()), 1577 + }), 1578 + history, 1579 + tools: PROJECT_TOOLS, 1580 + toolHandler: handleToolUse, 1581 + annotateToolResult: (_call, result) => { 1582 + const icon = result.isError ? '⚠' : '✓' 1583 + return `\n\n_${icon} ${result.content}_` 1584 + }, 1585 + }, 1586 + { 1587 + onTextSnapshot: (snap) => { 1588 + pendingText = snap 1589 + }, 1590 + onFinal: (result) => { 1591 + window.clearInterval(flushTimer) 1592 + ws.send({ 1593 + type: 'turn:update', 1594 + projectId, 1595 + turnId: asstTurnId, 1596 + fields: { 1597 + text: result.text, 1598 + status: 'complete', 1599 + inputTokens: result.inputTokens, 1600 + outputTokens: result.outputTokens, 1601 + }, 1602 + }) 1603 + if (conv.title === 'Untitled') { 1604 + void autoNameConversation( 1605 + projectId, 1606 + conv.id, 1607 + apiKey, 1608 + t, 1609 + result.text, 1610 + ) 1611 + } 1612 + resolve() 1613 + }, 1614 + onError: (err) => { 1615 + window.clearInterval(flushTimer) 1616 + ws.send({ 1617 + type: 'turn:update', 1618 + projectId, 1619 + turnId: asstTurnId, 1620 + fields: { 1621 + text: `${pendingText || ''}\n\n[error: ${err.message}]`, 1622 + status: 'error', 1623 + }, 1624 + }) 1625 + resolve() 1626 + }, 1627 + }, 1628 + ) 1629 + }) 1630 + 1631 + setLocalSending(false) 1632 + } 1633 + 1634 + return ( 1635 + <> 1636 + <div 1637 + ref={scrollerRef} 1638 + onScroll={onScrollerScroll} 1639 + style={{ flex: 1, overflowY: 'auto', padding: 20 }} 1640 + > 1641 + {linear.map((turn, i) => { 1642 + const isStreaming = turn.status === 'streaming' 1643 + const isError = turn.status === 'error' 1644 + const kids = childrenOf(turns, turn.id) 1645 + const nextInChain = linear[i + 1] 1646 + const branchIdx = nextInChain 1647 + ? kids.findIndex((c) => c.id === nextInChain.id) 1648 + : -1 1649 + return ( 1650 + <React.Fragment key={turn.id}> 1651 + <div 1652 + style={{ 1653 + marginBottom: 16, 1654 + padding: 12, 1655 + background: 1656 + turn.role === 'user' 1657 + ? 'var(--mc-bg-card)' 1658 + : isError 1659 + ? 'var(--mc-bg-card-err)' 1660 + : 'var(--mc-bg-card-asst)', 1661 + borderRadius: 6, 1662 + }} 1663 + > 1664 + <div 1665 + style={{ 1666 + fontSize: 11, 1667 + color: 'var(--mc-fg-muted)', 1668 + marginBottom: 4, 1669 + }} 1670 + > 1671 + {turn.role === 'user' 1672 + ? `${members.get(turn.author)?.displayName ?? turn.author}${ 1673 + turn.author === identity.userId ? ' (you)' : '' 1674 + }` 1675 + : `Claude — billed to ${ 1676 + members.get(turn.author)?.displayName ?? turn.author 1677 + }${isStreaming ? ' · streaming' : ''}`} 1678 + </div> 1679 + {turn.role === 'assistant' ? ( 1680 + <div className="mc-md"> 1681 + <ReactMarkdown remarkPlugins={[remarkGfm]}> 1682 + {turn.text} 1683 + </ReactMarkdown> 1684 + {isStreaming && ( 1685 + <span 1686 + style={{ 1687 + display: 'inline-block', 1688 + marginLeft: 2, 1689 + animation: 'mc-blink 1s steps(2) infinite', 1690 + }} 1691 + > 1692 + 1693 + </span> 1694 + )} 1695 + </div> 1696 + ) : ( 1697 + <div style={{ whiteSpace: 'pre-wrap' }}>{turn.text}</div> 1698 + )} 1699 + </div> 1700 + {kids.length > 1 && branchIdx >= 0 && ( 1701 + <BranchNav 1702 + total={kids.length} 1703 + index={branchIdx} 1704 + onPick={(newIdx) => { 1705 + const pickedChild = kids[newIdx] 1706 + if (pickedChild) { 1707 + setSelectedLeafId(latestLeafInSubtree(turns, pickedChild.id)) 1708 + } 1709 + }} 1710 + /> 1711 + )} 1712 + </React.Fragment> 1713 + ) 1714 + })} 1715 + </div> 1716 + <div 1717 + style={{ 1718 + borderTop: '1px solid var(--mc-border)', 1719 + padding: 12, 1720 + display: 'flex', 1721 + gap: 8, 1722 + }} 1723 + > 1724 + <textarea 1725 + value={text} 1726 + onChange={(e) => setText(e.target.value)} 1727 + onKeyDown={(e) => { 1728 + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) send() 1729 + }} 1730 + placeholder="Message Claude (Cmd/Ctrl-Enter to send)" 1731 + style={{ flex: 1, padding: 8, minHeight: 60 }} 1732 + /> 1733 + <button 1734 + onClick={send} 1735 + disabled={localSending || !text.trim()} 1736 + style={buttonStyle} 1737 + > 1738 + Send 1739 + </button> 1740 + </div> 1741 + <style>{` 1742 + @keyframes mc-blink { to { visibility: hidden; } } 1743 + .mc-md { line-height: 1.5; } 1744 + .mc-md > :first-child { margin-top: 0; } 1745 + .mc-md > :last-child { margin-bottom: 0; } 1746 + .mc-md p { margin: 0.6em 0; } 1747 + .mc-md h1, .mc-md h2, .mc-md h3, .mc-md h4 { margin: 1em 0 0.4em; line-height: 1.25; } 1748 + .mc-md h1 { font-size: 1.5em; } 1749 + .mc-md h2 { font-size: 1.3em; } 1750 + .mc-md h3 { font-size: 1.15em; } 1751 + .mc-md h4 { font-size: 1em; } 1752 + .mc-md ul, .mc-md ol { margin: 0.5em 0; padding-left: 1.6em; } 1753 + .mc-md li { margin: 0.2em 0; } 1754 + .mc-md li > p { margin: 0.2em 0; } 1755 + .mc-md code { 1756 + font-family: ui-monospace, "SF Mono", Menlo, monospace; 1757 + font-size: 0.92em; 1758 + background: var(--mc-bg-code-inline); 1759 + padding: 0.1em 0.35em; 1760 + border-radius: 3px; 1761 + } 1762 + .mc-md pre { 1763 + background: var(--mc-bg-pre); 1764 + color: var(--mc-fg-pre); 1765 + padding: 12px 14px; 1766 + border-radius: 6px; 1767 + overflow-x: auto; 1768 + margin: 0.7em 0; 1769 + } 1770 + .mc-md pre code { 1771 + background: transparent; 1772 + padding: 0; 1773 + color: inherit; 1774 + font-size: 0.9em; 1775 + } 1776 + .mc-md blockquote { 1777 + margin: 0.6em 0; 1778 + padding-left: 12px; 1779 + border-left: 3px solid var(--mc-border-quote); 1780 + color: var(--mc-fg-muted); 1781 + } 1782 + .mc-md table { border-collapse: collapse; margin: 0.6em 0; } 1783 + .mc-md th, .mc-md td { border: 1px solid var(--mc-border); padding: 4px 8px; text-align: left; } 1784 + .mc-md th { background: var(--mc-bg-th); } 1785 + .mc-md a { color: var(--mc-link); } 1786 + .mc-md hr { border: none; border-top: 1px solid var(--mc-border); margin: 1em 0; } 1787 + `}</style> 1788 + </> 1789 + ) 1790 + } 1791 + 1792 + function BranchNav({ 1793 + total, 1794 + index, 1795 + onPick, 1796 + }: { 1797 + total: number 1798 + index: number 1799 + onPick: (n: number) => void 1800 + }): React.ReactElement { 1801 + const wrap = (n: number): number => ((n % total) + total) % total 1802 + return ( 1803 + <div 1804 + style={{ 1805 + display: 'flex', 1806 + alignItems: 'center', 1807 + gap: 6, 1808 + margin: '-8px 0 16px', 1809 + fontSize: 11, 1810 + color: 'var(--mc-fg-muted)', 1811 + }} 1812 + > 1813 + <button 1814 + onClick={() => onPick(wrap(index - 1))} 1815 + style={{ ...linkBtn, fontSize: 14, lineHeight: 1 }} 1816 + > 1817 + 1818 + </button> 1819 + <span> 1820 + branch {index + 1} / {total} 1821 + </span> 1822 + <button 1823 + onClick={() => onPick(wrap(index + 1))} 1824 + style={{ ...linkBtn, fontSize: 14, lineHeight: 1 }} 1825 + > 1826 + 1827 + </button> 1828 + </div> 1829 + ) 1830 + } 1831 + 1832 + // ─── auto-name ───────────────────────────────────────────────────────────── 1833 + 1834 + async function autoNameConversation( 1835 + projectId: string, 1836 + convId: string, 1837 + apiKey: string, 1838 + firstUser: string, 1839 + firstAsst: string, 1840 + ): Promise<void> { 1841 + if (!firstUser || !firstAsst) return 1842 + try { 1843 + const client = new Anthropic({ apiKey, dangerouslyAllowBrowser: true }) 1844 + const resp = await client.messages.create({ 1845 + model: 'claude-haiku-4-5-20251001', 1846 + max_tokens: 30, 1847 + messages: [ 1848 + { 1849 + role: 'user', 1850 + content: 1851 + `Suggest a concise 4-6 word title for this conversation. ` + 1852 + `Reply with only the title — no quotes, no punctuation at end.\n\n` + 1853 + `User: ${firstUser.slice(0, 500)}\n\n` + 1854 + `Assistant: ${firstAsst.slice(0, 500)}`, 1855 + }, 1856 + ], 1857 + }) 1858 + const title = resp.content 1859 + .filter((b): b is Anthropic.TextBlock => b.type === 'text') 1860 + .map((b) => b.text) 1861 + .join('') 1862 + .trim() 1863 + .replace(/^["']|["'.]$/g, '') 1864 + .slice(0, 80) 1865 + if (!title) return 1866 + ws.send({ 1867 + type: 'conversation:update', 1868 + projectId, 1869 + convId, 1870 + fields: { title }, 1871 + }) 1872 + } catch (err) { 1873 + console.warn('[auto-name] failed:', err) 1874 + } 1875 + } 1876 + 1877 + // ─── helpers + styles ────────────────────────────────────────────────────── 1878 + 1879 + function looksLikeText(file: File): boolean { 1880 + if (file.type.startsWith('text/')) return true 1881 + const textMimes = new Set([ 1882 + 'application/json', 1883 + 'application/xml', 1884 + 'application/javascript', 1885 + 'application/typescript', 1886 + 'application/x-yaml', 1887 + ]) 1888 + if (textMimes.has(file.type)) return true 1889 + return /\.(md|markdown|txt|json|ya?ml|csv|tsv|xml|html?|css|m?jsx?|tsx?|py|rs|go|java|sh|toml|ini|conf|log)$/i.test( 1890 + file.name, 1891 + ) 1892 + } 1893 + 1894 + function readFileAsBase64(file: File): Promise<string> { 1895 + return new Promise((resolve, reject) => { 1896 + const reader = new FileReader() 1897 + reader.onload = (): void => { 1898 + const r = reader.result 1899 + if (typeof r !== 'string') { 1900 + reject(new Error('unexpected reader result')) 1901 + return 1902 + } 1903 + const i = r.indexOf(',') 1904 + resolve(i === -1 ? '' : r.slice(i + 1)) 1905 + } 1906 + reader.onerror = (): void => reject(reader.error ?? new Error('read error')) 1907 + reader.readAsDataURL(file) 1908 + }) 1909 + } 1910 + 1911 + function formatRelative(ts: number): string { 1912 + const delta = Date.now() - ts 1913 + if (delta < 60_000) return 'just now' 1914 + if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m ago` 1915 + if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)}h ago` 1916 + return `${Math.floor(delta / 86_400_000)}d ago` 1917 + } 1918 + 1919 + const inputStyle: React.CSSProperties = { 1920 + display: 'block', 1921 + width: '100%', 1922 + padding: 8, 1923 + marginBottom: 8, 1924 + fontSize: 14, 1925 + boxSizing: 'border-box', 1926 + } 1927 + const buttonStyle: React.CSSProperties = { 1928 + padding: '8px 16px', 1929 + fontSize: 14, 1930 + cursor: 'pointer', 1931 + } 1932 + const linkBtn: React.CSSProperties = { 1933 + background: 'none', 1934 + border: 'none', 1935 + color: 'var(--mc-link)', 1936 + cursor: 'pointer', 1937 + padding: 0, 1938 + fontSize: 12, 1939 + } 1940 + const resizeHandleStyle: React.CSSProperties = { 1941 + width: 4, 1942 + background: 'var(--mc-resize)', 1943 + cursor: 'col-resize', 1944 + }
+156
src/lib/api.ts
··· 1 + // Browser API client. HTTP for resource CRUD + auth, WebSocket for the 2 + // realtime project channel. Replaces the IPC `window.mc.*` surface from the 3 + // Electron version. 4 + 5 + import type { 6 + Identity, 7 + ProjectFile, 8 + ProjectMeta, 9 + WsClientEvent, 10 + WsServerEvent, 11 + } from '../shared/schema' 12 + 13 + async function jsonFetch<T>(path: string, init: RequestInit = {}): Promise<T> { 14 + const res = await fetch(path, { 15 + credentials: 'same-origin', 16 + headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) }, 17 + ...init, 18 + }) 19 + if (!res.ok) { 20 + const body = await res.text() 21 + throw new Error(`${res.status} ${res.statusText}: ${body}`) 22 + } 23 + return res.json() as Promise<T> 24 + } 25 + 26 + export const auth = { 27 + me: () => jsonFetch<{ user: Identity | null }>('/api/auth/me'), 28 + login: (email: string) => 29 + jsonFetch<{ user: Identity }>('/api/auth/login', { 30 + method: 'POST', 31 + body: JSON.stringify({ email }), 32 + }), 33 + logout: () => jsonFetch<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }), 34 + } 35 + 36 + export const projects = { 37 + list: () => 38 + jsonFetch<{ projects: ProjectMeta[] }>('/api/projects').then((r) => r.projects), 39 + create: (name: string) => 40 + jsonFetch<{ project: ProjectMeta }>('/api/projects', { 41 + method: 'POST', 42 + body: JSON.stringify({ name }), 43 + }).then((r) => r.project), 44 + invite: (projectId: string) => 45 + jsonFetch<{ token: string }>(`/api/projects/${projectId}/invites`, { 46 + method: 'POST', 47 + }), 48 + } 49 + 50 + export const invites = { 51 + accept: (token: string) => 52 + jsonFetch<{ projectId: string }>(`/api/invites/${token}/accept`, { 53 + method: 'POST', 54 + }), 55 + } 56 + 57 + export const blobs = { 58 + upload: ( 59 + projectId: string, 60 + args: { name: string; base64: string; mimeType: string }, 61 + ) => 62 + jsonFetch<{ 63 + fileId: string 64 + sha256: string 65 + sizeBytes: number 66 + mimeType: string 67 + }>(`/api/projects/${projectId}/blobs`, { 68 + method: 'POST', 69 + body: JSON.stringify(args), 70 + }), 71 + url: (projectId: string, sha256: string) => 72 + `/api/projects/${projectId}/blobs/${sha256}`, 73 + download: async (projectId: string, sha256: string) => { 74 + const res = await fetch(blobs.url(projectId, sha256), { 75 + credentials: 'same-origin', 76 + }) 77 + if (!res.ok) throw new Error(`blob download ${res.status}`) 78 + return new Blob([await res.arrayBuffer()], { 79 + type: res.headers.get('content-type') ?? 'application/octet-stream', 80 + }) 81 + }, 82 + } 83 + 84 + // ---- WebSocket client ----------------------------------------------------- 85 + 86 + type WsHandler = (event: WsServerEvent) => void 87 + 88 + class WsClient { 89 + private socket: WebSocket | null = null 90 + private handlers = new Set<WsHandler>() 91 + private reconnectDelay = 1000 92 + private currentProject: string | null = null 93 + private connected = false 94 + 95 + connect(): void { 96 + if (this.socket) return 97 + const proto = location.protocol === 'https:' ? 'wss' : 'ws' 98 + const url = `${proto}://${location.host}/ws` 99 + const s = new WebSocket(url) 100 + this.socket = s 101 + s.addEventListener('open', () => { 102 + this.connected = true 103 + this.reconnectDelay = 1000 104 + if (this.currentProject) this.send({ type: 'subscribe', projectId: this.currentProject }) 105 + }) 106 + s.addEventListener('message', (e) => { 107 + let event: WsServerEvent 108 + try { 109 + event = JSON.parse(e.data as string) as WsServerEvent 110 + } catch { 111 + return 112 + } 113 + for (const h of this.handlers) h(event) 114 + }) 115 + s.addEventListener('close', () => { 116 + this.socket = null 117 + this.connected = false 118 + // Reconnect with light backoff. Server snapshot will rehydrate state. 119 + setTimeout(() => this.connect(), this.reconnectDelay) 120 + this.reconnectDelay = Math.min(this.reconnectDelay * 2, 15_000) 121 + }) 122 + s.addEventListener('error', () => { 123 + // Just let the close handler do the reconnect. 124 + }) 125 + } 126 + 127 + on(h: WsHandler): () => void { 128 + this.handlers.add(h) 129 + return () => this.handlers.delete(h) 130 + } 131 + 132 + send(event: WsClientEvent): void { 133 + if (!this.socket || !this.connected) return 134 + this.socket.send(JSON.stringify(event)) 135 + } 136 + 137 + subscribe(projectId: string): void { 138 + if (this.currentProject === projectId) return 139 + if (this.currentProject) this.send({ type: 'unsubscribe', projectId: this.currentProject }) 140 + this.currentProject = projectId 141 + this.send({ type: 'subscribe', projectId }) 142 + } 143 + 144 + unsubscribe(): void { 145 + if (this.currentProject) { 146 + this.send({ type: 'unsubscribe', projectId: this.currentProject }) 147 + this.currentProject = null 148 + } 149 + } 150 + 151 + isConnected(): boolean { 152 + return this.connected 153 + } 154 + } 155 + 156 + export const ws = new WsClient()
+51
src/lib/keystore.ts
··· 1 + // API key storage in the browser. v1: plaintext in IndexedDB, scoped by 2 + // email. Same trust boundary as the desktop app on a logged-in Mac — anyone 3 + // with browser-data access wins. Acceptable in prototype mode (see the 4 + // project's PORT-PLAN.md). Future hardening: passphrase-derived AES key. 5 + 6 + const DB_NAME = 'centrosome' 7 + const STORE = 'apikeys' 8 + const DB_VERSION = 1 9 + 10 + function open(): Promise<IDBDatabase> { 11 + return new Promise((resolve, reject) => { 12 + const req = indexedDB.open(DB_NAME, DB_VERSION) 13 + req.onupgradeneeded = (): void => { 14 + const db = req.result 15 + if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE) 16 + } 17 + req.onsuccess = (): void => resolve(req.result) 18 + req.onerror = (): void => reject(req.error ?? new Error('idb open failed')) 19 + }) 20 + } 21 + 22 + async function withStore<T>( 23 + mode: IDBTransactionMode, 24 + fn: (store: IDBObjectStore) => IDBRequest<T>, 25 + ): Promise<T> { 26 + const db = await open() 27 + return new Promise<T>((resolve, reject) => { 28 + const tx = db.transaction(STORE, mode) 29 + const store = tx.objectStore(STORE) 30 + const req = fn(store) 31 + req.onsuccess = (): void => resolve(req.result) 32 + req.onerror = (): void => reject(req.error ?? new Error('idb op failed')) 33 + }) 34 + } 35 + 36 + export const keystore = { 37 + async get(email: string): Promise<string | null> { 38 + const v = await withStore('readonly', (s) => s.get(email.toLowerCase())) 39 + return typeof v === 'string' ? v : null 40 + }, 41 + async set(email: string, apiKey: string): Promise<void> { 42 + await withStore('readwrite', (s) => s.put(apiKey, email.toLowerCase())) 43 + }, 44 + async has(email: string): Promise<boolean> { 45 + const v = await withStore('readonly', (s) => s.getKey(email.toLowerCase())) 46 + return v !== undefined 47 + }, 48 + async delete(email: string): Promise<void> { 49 + await withStore('readwrite', (s) => s.delete(email.toLowerCase())) 50 + }, 51 + }
+115
src/lib/store.ts
··· 1 + // Active-project store. One snapshot lives in memory; WebSocket events 2 + // mutate it; React subscribes via useSyncExternalStore. No third-party 3 + // state library to keep the dep surface small. 4 + 5 + import type { 6 + Conversation, 7 + Member, 8 + ProjectFile, 9 + ProjectSnapshot, 10 + Turn, 11 + WsServerEvent, 12 + } from '../shared/schema' 13 + 14 + export interface ProjectState { 15 + project: ProjectSnapshot['project'] 16 + members: Map<string, Member> 17 + files: Map<string, ProjectFile> 18 + conversations: Map<string, Conversation> 19 + turns: Map<string, Turn> 20 + // Most recent presence ts per user. UI shows green dot if within freshness. 21 + presence: Map<string, number> 22 + } 23 + 24 + class ProjectStore { 25 + private state: ProjectState | null = null 26 + private listeners = new Set<() => void>() 27 + 28 + getState = (): ProjectState | null => this.state 29 + 30 + subscribe = (l: () => void): (() => void) => { 31 + this.listeners.add(l) 32 + return () => this.listeners.delete(l) 33 + } 34 + 35 + private emit = (): void => { 36 + for (const l of this.listeners) l() 37 + } 38 + 39 + clear = (): void => { 40 + this.state = null 41 + this.emit() 42 + } 43 + 44 + apply = (event: WsServerEvent): void => { 45 + if (event.type === 'snapshot') { 46 + this.state = { 47 + project: event.snapshot.project, 48 + members: new Map(event.snapshot.members.map((m) => [m.userId, m])), 49 + files: new Map(event.snapshot.files.map((f) => [f.id, f])), 50 + conversations: new Map( 51 + event.snapshot.conversations.map((c) => [c.id, c]), 52 + ), 53 + turns: new Map(event.snapshot.turns.map((t) => [t.id, t])), 54 + presence: this.state?.presence ?? new Map(), 55 + } 56 + this.emit() 57 + return 58 + } 59 + if (!this.state) return 60 + 61 + switch (event.type) { 62 + case 'conversation': 63 + this.state = { 64 + ...this.state, 65 + conversations: new Map(this.state.conversations).set( 66 + event.conversation.id, 67 + event.conversation, 68 + ), 69 + } 70 + break 71 + case 'turn': 72 + this.state = { 73 + ...this.state, 74 + turns: new Map(this.state.turns).set(event.turn.id, event.turn), 75 + } 76 + break 77 + case 'file': 78 + this.state = { 79 + ...this.state, 80 + files: new Map(this.state.files).set(event.file.id, event.file), 81 + } 82 + break 83 + case 'file:deleted': { 84 + const files = new Map(this.state.files) 85 + files.delete(event.fileId) 86 + this.state = { ...this.state, files } 87 + break 88 + } 89 + case 'main': 90 + this.state = { 91 + ...this.state, 92 + project: { ...this.state.project, mainFileId: event.fileId }, 93 + } 94 + break 95 + case 'member': 96 + this.state = { 97 + ...this.state, 98 + members: new Map(this.state.members).set( 99 + event.member.userId, 100 + event.member, 101 + ), 102 + } 103 + break 104 + case 'presence': 105 + this.state = { 106 + ...this.state, 107 + presence: new Map(this.state.presence).set(event.userId, event.ts), 108 + } 109 + break 110 + } 111 + this.emit() 112 + } 113 + } 114 + 115 + export const store = new ProjectStore()
+169
src/lib/systemPrompt.ts
··· 1 + // System-prompt builder + tool definitions. Mirrors the Electron version 2 + // in src/main/index.ts; runs in the browser now. 3 + 4 + import type Anthropic from '@anthropic-ai/sdk' 5 + import type { ProjectFile } from '../shared/schema' 6 + 7 + // Budget for file content in the system prompt, in characters (~chars/4 8 + // tokens). 400k chars ≈ 100k tokens — half the Sonnet context, leaving room 9 + // for conversation history + response. Files past the budget are stubbed. 10 + export const SYSTEM_PROMPT_FILE_BUDGET_CHARS = 400_000 11 + 12 + export interface SystemPromptInput { 13 + systemPrompt: string 14 + mainFileId: string | null 15 + files: ProjectFile[] 16 + } 17 + 18 + export function buildSystemPrompt(input: SystemPromptInput): string { 19 + const parts: string[] = [] 20 + if (input.systemPrompt) parts.push(input.systemPrompt) 21 + 22 + parts.push( 23 + `You can modify the project's shared files via tools:\n` + 24 + ` - update_main_file: replace the main file (the team's shared brain). Use when asked to remember facts, record decisions, or update running state.\n` + 25 + ` - create_file: add a new working file.\n` + 26 + ` - update_file: replace an existing working file's contents.\n` + 27 + ` - patch_file: search/replace within an existing file. Prefer this over update_file/update_main_file when the change is small relative to the file.\n\n` + 28 + `When using update_main_file / update_file, pass the COMPLETE new content. ` + 29 + `When using patch_file, each search string must appear exactly once. ` + 30 + `Be conservative — only modify files when the user clearly asks for it or when ` + 31 + `a fact/decision is worth persisting beyond this conversation.`, 32 + ) 33 + 34 + if (input.files.length > 0) { 35 + const main = input.mainFileId 36 + ? input.files.find((f) => f.id === input.mainFileId) 37 + : undefined 38 + const working = input.files 39 + .filter((f) => f.id !== input.mainFileId) 40 + .sort((a, b) => a.name.localeCompare(b.name)) 41 + const ordered = main ? [main, ...working] : working 42 + 43 + parts.push( 44 + `Current project files (the main file is authoritative for state, ` + 45 + `decisions, and known facts):`, 46 + ) 47 + let used = 0 48 + for (const f of ordered) { 49 + const tag = f.id === input.mainFileId ? ' (main file)' : '' 50 + if (f.binary) { 51 + const kb = Math.round(f.binary.sizeBytes / 1024) 52 + parts.push( 53 + `=== ${f.name}${tag} ===\n[binary file: ${f.binary.mimeType}, ${kb} KB — content not loaded into context. You cannot edit binary files.]`, 54 + ) 55 + continue 56 + } 57 + const remaining = SYSTEM_PROMPT_FILE_BUDGET_CHARS - used 58 + if (f.content.length <= remaining) { 59 + parts.push(`=== ${f.name}${tag} ===\n${f.content}`) 60 + used += f.content.length 61 + } else { 62 + parts.push( 63 + `=== ${f.name}${tag} ===\n[file omitted from this turn: ${f.content.length} chars would exceed the ${SYSTEM_PROMPT_FILE_BUDGET_CHARS}-char context budget. Use patch_file or ask the user for the relevant section.]`, 64 + ) 65 + } 66 + } 67 + } 68 + 69 + return parts.join('\n\n') 70 + } 71 + 72 + export const PROJECT_TOOLS: Anthropic.Tool[] = [ 73 + { 74 + name: 'update_main_file', 75 + description: 76 + "Replace the entire content of the project's main file (the team's shared brain). " + 77 + 'Use this when a user asks you to remember a fact, record a decision, or update the running state. ' + 78 + 'Pass the complete new content; existing content not in your update will be lost.', 79 + input_schema: { 80 + type: 'object', 81 + properties: { 82 + content: { 83 + type: 'string', 84 + description: 'The complete new content of the main file (markdown).', 85 + }, 86 + }, 87 + required: ['content'], 88 + }, 89 + }, 90 + { 91 + name: 'create_file', 92 + description: 93 + 'Create a new working file in the project. ' + 94 + 'Use when a user asks you to start a new document, list, or note that should be shared.', 95 + input_schema: { 96 + type: 'object', 97 + properties: { 98 + name: { 99 + type: 'string', 100 + description: 'Filename including extension, e.g. "todo.md" or "spec.md".', 101 + }, 102 + content: { 103 + type: 'string', 104 + description: 'Initial content of the file (markdown).', 105 + }, 106 + }, 107 + required: ['name', 'content'], 108 + }, 109 + }, 110 + { 111 + name: 'update_file', 112 + description: 113 + 'Replace the entire content of an existing working file (NOT the main file — use update_main_file for that). ' + 114 + 'Pass the complete new content. Prefer `patch_file` when changing only part of a long file.', 115 + input_schema: { 116 + type: 'object', 117 + properties: { 118 + name: { 119 + type: 'string', 120 + description: 'Filename of an existing working file in the project.', 121 + }, 122 + content: { 123 + type: 'string', 124 + description: 'The complete new content of the file.', 125 + }, 126 + }, 127 + required: ['name', 'content'], 128 + }, 129 + }, 130 + { 131 + name: 'patch_file', 132 + description: 133 + 'Apply one or more search/replace patches to an existing file (main or working). ' + 134 + 'Cheaper than rewriting the whole file. Each search string must match exactly (including whitespace) ' + 135 + 'and uniquely; include enough surrounding context to disambiguate.', 136 + input_schema: { 137 + type: 'object', 138 + properties: { 139 + name: { 140 + type: 'string', 141 + description: 'Filename of an existing file in the project.', 142 + }, 143 + patches: { 144 + type: 'array', 145 + description: 146 + 'One or more search/replace operations applied in order. ' + 147 + 'If any patch fails (string not found, multiple matches, empty search), ' + 148 + 'no changes are committed.', 149 + items: { 150 + type: 'object', 151 + properties: { 152 + search: { 153 + type: 'string', 154 + description: 155 + 'Exact text to find. Must appear exactly once in the file at the time of the patch.', 156 + }, 157 + replace: { 158 + type: 'string', 159 + description: 'Text to replace the matched search string with.', 160 + }, 161 + }, 162 + required: ['search', 'replace'], 163 + }, 164 + }, 165 + }, 166 + required: ['name', 'patches'], 167 + }, 168 + }, 169 + ]
+56 -44
src/main/claudeApi.ts src/lib/claudeAgent.ts
··· 1 - import Anthropic from '@anthropic-ai/sdk' 2 - import type { Conversation, Turn } from '../shared/schema.js' 1 + // Agent loop. Runs in the browser using the Anthropic SDK with 2 + // dangerouslyAllowBrowser. Streams text + tool_use rounds until end_turn, 3 + // invoking the caller's `toolHandler` for each tool_use. 4 + // 5 + // Ported from the Electron version's src/main/claudeApi.ts. Same shape; the 6 + // tool handler now talks to the server over the WebSocket instead of 7 + // mutating an Automerge doc. 3 8 4 - // Agent loop: stream a response, execute any tool calls the model emits, 5 - // feed the results back, and continue until the model stops calling tools. 6 - // 7 - // Caller responsibilities: 8 - // - Provide a `tools` list (Anthropic.Tool[]) plus a `toolHandler` that 9 - // mutates app state and returns a result string. 10 - // - Optionally `getSystemPrompt`: a function rather than a string, because 11 - // tool calls may change project state (file contents) that the next 12 - // round's system prompt should reflect. 13 - // - Subscribe to `onTextSnapshot` for the live running text (which includes 14 - // prior rounds + any tool annotations) so the UI / persisted turn can 15 - // update as it grows. 9 + import Anthropic from '@anthropic-ai/sdk' 10 + import type { Turn } from '../shared/schema' 16 11 17 12 export interface ToolCall { 18 13 id: string ··· 31 26 export interface AgentTurnOpts { 32 27 apiKey: string 33 28 model: string 34 - // Called at the start of every round so the model always sees current file 35 - // state, etc. Return '' for no system prompt. 29 + // Re-evaluated each round so tool effects (file edits) feed into the 30 + // next round's system prompt. 36 31 getSystemPrompt: () => string 37 - conversation: Conversation 38 - leafTurnId: string 32 + // The full chain of past turns leading up to (and including) the user 33 + // turn we're responding to. 34 + history: Turn[] 39 35 tools: Anthropic.Tool[] 40 36 toolHandler: ToolHandler 41 - // Optional formatter for an italic annotation appended to the running text 42 - // after each tool result, e.g. "✓ Updated main.md". 43 37 annotateToolResult?: (call: ToolCall, result: ToolResult) => string 44 38 } 45 39 ··· 59 53 opts: AgentTurnOpts, 60 54 cb: AgentTurnCallbacks, 61 55 ): { abort: () => void } { 62 - const client = new Anthropic({ apiKey: opts.apiKey }) 56 + const client = new Anthropic({ 57 + apiKey: opts.apiKey, 58 + dangerouslyAllowBrowser: true, 59 + }) 63 60 let aborted = false 64 61 let currentStream: ReturnType<typeof client.messages.stream> | null = null 65 62 66 63 const run = async (): Promise<void> => { 67 - let messages: Anthropic.MessageParam[] = buildLinearHistory( 68 - opts.conversation, 69 - opts.leafTurnId, 70 - ) 64 + let messages: Anthropic.MessageParam[] = opts.history 65 + .filter((t) => t.role === 'user' || !t.status || t.status === 'complete') 66 + .map((t) => ({ role: t.role, content: t.text })) 71 67 let textSoFar = '' 72 68 let inputTokens = 0 73 69 let outputTokens = 0 ··· 118 114 return 119 115 } 120 116 121 - // Execute each tool and collect results. 122 117 const toolResultBlocks: Anthropic.ToolResultBlockParam[] = [] 123 118 for (const tu of toolUses) { 124 119 const call: ToolCall = { ··· 150 145 } 151 146 } 152 147 153 - // Continue the loop with the assistant message and tool results. 154 148 messages = [ 155 149 ...messages, 156 150 { role: 'assistant', content: final.content }, ··· 158 152 ] 159 153 } 160 154 161 - cb.onError( 162 - new Error(`agent tool loop exceeded ${MAX_ROUNDS} rounds`), 163 - ) 155 + cb.onError(new Error(`agent tool loop exceeded ${MAX_ROUNDS} rounds`)) 164 156 } 165 157 166 158 void run() ··· 173 165 } 174 166 } 175 167 176 - // Walk parentId chain from the leaf back to root, then reverse for 177 - // chronological order. Drop assistant turns that are still streaming or 178 - // errored — they must not be sent back to the API as conversation history. 179 - function buildLinearHistory( 180 - conv: Conversation, 181 - leafTurnId: string, 182 - ): Anthropic.MessageParam[] { 168 + // Walk parentId chain from leaf back to root; return chronological order. 169 + export function linearizeFromLeaf( 170 + turns: Map<string, Turn>, 171 + leafId: string, 172 + ): Turn[] { 183 173 const chain: Turn[] = [] 184 - let cur: string | null = leafTurnId 174 + let cur: string | null = leafId 185 175 while (cur) { 186 - const t: Turn | undefined = conv.turns[cur] 176 + const t = turns.get(cur) 187 177 if (!t) break 188 178 chain.push(t) 189 179 cur = t.parentId 190 180 } 191 - chain.reverse() 192 - const usable = chain.filter( 193 - (t) => t.role === 'user' || !t.status || t.status === 'complete', 194 - ) 195 - return usable.map((t) => ({ role: t.role, content: t.text })) 181 + return chain.reverse() 182 + } 183 + 184 + export function latestLeafId(turns: Turn[]): string | null { 185 + if (turns.length === 0) return null 186 + const hasChild = new Set<string>() 187 + for (const t of turns) if (t.parentId) hasChild.add(t.parentId) 188 + const leaves = turns 189 + .filter((t) => !hasChild.has(t.id)) 190 + .sort((a, b) => b.createdAt - a.createdAt) 191 + return leaves[0]?.id ?? null 192 + } 193 + 194 + export function childrenOf(turns: Turn[], parentId: string): Turn[] { 195 + return turns 196 + .filter((t) => t.parentId === parentId) 197 + .sort((a, b) => a.createdAt - b.createdAt) 198 + } 199 + 200 + export function latestLeafInSubtree(turns: Turn[], rootId: string): string { 201 + let cur = rootId 202 + while (true) { 203 + const kids = childrenOf(turns, cur) 204 + if (kids.length === 0) return cur 205 + const latest = kids.reduce((a, b) => (a.createdAt > b.createdAt ? a : b)) 206 + cur = latest.id 207 + } 196 208 }
-1084
src/main/index.ts
··· 1 - import { app, BrowserWindow, ipcMain, dialog } from 'electron' 2 - import * as path from 'path' 3 - import { fileURLToPath } from 'url' 4 - import { promises as fs } from 'fs' 5 - import * as os from 'os' 6 - import * as crypto from 'crypto' 7 - import Anthropic from '@anthropic-ai/sdk' 8 - import * as Automerge from '@automerge/automerge' 9 - 10 - import { ProjectSync, userIdFromEmail } from './sync.js' 11 - import { setApiKey, getApiKey } from './keychain.js' 12 - import { runAgentTurn, type ToolCall, type ToolResult } from './claudeApi.js' 13 - import type { 14 - Conversation, 15 - Identity, 16 - ProjectDoc, 17 - ProjectMeta, 18 - } from '../shared/schema.js' 19 - 20 - const __dirname = path.dirname(fileURLToPath(import.meta.url)) 21 - 22 - let mainWindow: BrowserWindow | null = null 23 - let currentIdentity: Identity | null = null 24 - let currentSync: ProjectSync | null = null 25 - let presenceTimer: NodeJS.Timeout | null = null 26 - 27 - // Presence is broadcast out-of-band (NOT in the Automerge doc — it churns too 28 - // fast). Each client writes presence/<userId>.json with a timestamp every few 29 - // seconds; clients ignore entries older than PRESENCE_STALE_MS. 30 - const PRESENCE_HEARTBEAT_MS = 3000 31 - const PRESENCE_STALE_MS = 10_000 32 - 33 - interface PresenceEntry { 34 - userId: string 35 - displayName: string 36 - ts: number 37 - } 38 - 39 - // ---- Settings (chosen sync root, last email) ------------------------------- 40 - 41 - interface Settings { 42 - email?: string 43 - syncRoot?: string 44 - } 45 - 46 - const settingsPath = (): string => 47 - path.join(app.getPath('userData'), 'settings.json') 48 - 49 - async function loadSettings(): Promise<Settings> { 50 - try { 51 - return JSON.parse(await fs.readFile(settingsPath(), 'utf8')) 52 - } catch { 53 - return {} 54 - } 55 - } 56 - 57 - async function saveSettings(s: Settings): Promise<void> { 58 - await fs.writeFile(settingsPath(), JSON.stringify(s, null, 2)) 59 - } 60 - 61 - function defaultSyncRoot(): string { 62 - return path.join( 63 - os.homedir(), 64 - 'Library', 65 - 'Mobile Documents', 66 - 'com~apple~CloudDocs', 67 - 'Centrosome', 68 - ) 69 - } 70 - 71 - async function ensureSyncRoot(): Promise<string> { 72 - const s = await loadSettings() 73 - const root = s.syncRoot ?? defaultSyncRoot() 74 - await fs.mkdir(root, { recursive: true }) 75 - if (!s.syncRoot) { 76 - s.syncRoot = root 77 - await saveSettings(s) 78 - } 79 - return root 80 - } 81 - 82 - async function listProjects(): Promise<ProjectMeta[]> { 83 - if (!currentIdentity) return [] 84 - const root = await ensureSyncRoot() 85 - const me = currentIdentity.userId 86 - const entries = await fs.readdir(root, { withFileTypes: true }) 87 - const projects: ProjectMeta[] = [] 88 - for (const e of entries) { 89 - if (!e.isDirectory()) continue 90 - const dir = path.join(root, e.name) 91 - try { 92 - const meta = JSON.parse( 93 - await fs.readFile(path.join(dir, 'meta.json'), 'utf8'), 94 - ) 95 - if (!(await projectHasMember(dir, me))) continue 96 - projects.push({ id: meta.id, name: meta.name, syncFolder: dir }) 97 - } catch { 98 - // Skip dirs without a meta.json or unreadable. 99 - } 100 - } 101 - return projects 102 - } 103 - 104 - async function writeOwnPresence(): Promise<void> { 105 - if (!currentSync || !currentIdentity) return 106 - const dir = path.join(currentSync.projectDir, 'presence') 107 - try { 108 - await fs.mkdir(dir, { recursive: true }) 109 - const entry: PresenceEntry = { 110 - userId: currentIdentity.userId, 111 - displayName: currentIdentity.displayName, 112 - ts: Date.now(), 113 - } 114 - await fs.writeFile( 115 - path.join(dir, `${currentIdentity.userId}.json`), 116 - JSON.stringify(entry), 117 - ) 118 - } catch (err) { 119 - console.warn('[presence] write failed:', err) 120 - } 121 - } 122 - 123 - async function readPeerPresence(): Promise<void> { 124 - if (!currentSync) return 125 - const dir = path.join(currentSync.projectDir, 'presence') 126 - const result: Record<string, PresenceEntry> = {} 127 - const cutoff = Date.now() - PRESENCE_STALE_MS 128 - try { 129 - const files = await fs.readdir(dir) 130 - for (const f of files) { 131 - if (!f.endsWith('.json')) continue 132 - try { 133 - const data = JSON.parse( 134 - await fs.readFile(path.join(dir, f), 'utf8'), 135 - ) as PresenceEntry 136 - if (data.ts > cutoff) result[data.userId] = data 137 - } catch { 138 - // Ignore corrupt entries. 139 - } 140 - } 141 - } catch { 142 - // Presence dir may not exist yet on a brand-new project — fine. 143 - } 144 - mainWindow?.webContents.send('presence:update', result) 145 - } 146 - 147 - async function clearOwnPresence(): Promise<void> { 148 - if (!currentSync || !currentIdentity) return 149 - try { 150 - await fs.unlink( 151 - path.join( 152 - currentSync.projectDir, 153 - 'presence', 154 - `${currentIdentity.userId}.json`, 155 - ), 156 - ) 157 - } catch { 158 - // Best-effort. 159 - } 160 - } 161 - 162 - function startPresence(): void { 163 - if (presenceTimer) clearInterval(presenceTimer) 164 - void writeOwnPresence() 165 - void readPeerPresence() 166 - presenceTimer = setInterval(() => { 167 - void writeOwnPresence() 168 - void readPeerPresence() 169 - }, PRESENCE_HEARTBEAT_MS) 170 - } 171 - 172 - function stopPresence(): void { 173 - if (presenceTimer) { 174 - clearInterval(presenceTimer) 175 - presenceTimer = null 176 - } 177 - } 178 - 179 - // Cache: projectDir|userId → { mtime: max doc-file mtime, isMember }. 180 - // We re-validate on every call by comparing the current max mtime against 181 - // the cached one; cheap stat() calls only, no full doc loads on cache hit. 182 - const membershipCache = new Map< 183 - string, 184 - { mtime: number; isMember: boolean } 185 - >() 186 - 187 - async function projectHasMember( 188 - projectDir: string, 189 - userId: string, 190 - ): Promise<boolean> { 191 - let names: string[] 192 - try { 193 - names = await fs.readdir(projectDir) 194 - } catch { 195 - return false 196 - } 197 - const docFiles = names.filter( 198 - (f) => f.startsWith('doc.') && f.endsWith('.automerge'), 199 - ) 200 - if (docFiles.length === 0) return false 201 - 202 - // Find the newest doc-file mtime; that's our cache key. 203 - let maxMtime = 0 204 - for (const f of docFiles) { 205 - try { 206 - const st = await fs.stat(path.join(projectDir, f)) 207 - if (st.mtimeMs > maxMtime) maxMtime = st.mtimeMs 208 - } catch { 209 - // Ignore. 210 - } 211 - } 212 - const cacheKey = `${projectDir}|${userId}` 213 - const cached = membershipCache.get(cacheKey) 214 - if (cached && cached.mtime === maxMtime) return cached.isMember 215 - 216 - // Cache miss: actually load + merge to check. 217 - let doc = Automerge.init<ProjectDoc>() 218 - for (const f of docFiles) { 219 - try { 220 - const bytes = await fs.readFile(path.join(projectDir, f)) 221 - const peer = Automerge.load<ProjectDoc>(bytes) 222 - doc = Automerge.merge(doc, peer) 223 - } catch { 224 - // Skip unreadable / corrupt peer files. 225 - } 226 - } 227 - const isMember = Boolean(doc.members?.[userId]) 228 - membershipCache.set(cacheKey, { mtime: maxMtime, isMember }) 229 - return isMember 230 - } 231 - 232 - // ---- Window ---------------------------------------------------------------- 233 - 234 - function createWindow(): void { 235 - mainWindow = new BrowserWindow({ 236 - width: 1100, 237 - height: 780, 238 - webPreferences: { 239 - preload: path.join(__dirname, '../preload/index.mjs'), 240 - contextIsolation: true, 241 - nodeIntegration: false, 242 - // ESM preload (.mjs) is only supported with sandbox disabled. 243 - // Acceptable for this small-group experiment; contextIsolation stays on. 244 - sandbox: false, 245 - }, 246 - }) 247 - 248 - if (process.env.ELECTRON_RENDERER_URL) { 249 - mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) 250 - mainWindow.webContents.openDevTools({ mode: 'detach' }) 251 - } else { 252 - mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')) 253 - } 254 - 255 - // Surface renderer errors and console messages to the main process stdout 256 - // so they show up in our dev logs. 257 - mainWindow.webContents.on('console-message', (_e, level, message, line, source) => { 258 - const tag = ['LOG', 'WARN', 'ERROR', 'INFO', 'DEBUG'][level] ?? `L${level}` 259 - console.log(`[renderer ${tag}] ${message} (${source}:${line})`) 260 - }) 261 - mainWindow.webContents.on('render-process-gone', (_e, details) => { 262 - console.error('[renderer gone]', details) 263 - }) 264 - mainWindow.webContents.on('did-fail-load', (_e, code, desc, url) => { 265 - console.error(`[renderer did-fail-load] ${code} ${desc} ${url}`) 266 - }) 267 - } 268 - 269 - app.whenReady().then(() => { 270 - createWindow() 271 - app.on('activate', () => { 272 - if (BrowserWindow.getAllWindows().length === 0) createWindow() 273 - }) 274 - }) 275 - 276 - app.on('window-all-closed', () => { 277 - if (process.platform !== 'darwin') app.quit() 278 - }) 279 - 280 - app.on('before-quit', async () => { 281 - stopPresence() 282 - await clearOwnPresence() 283 - }) 284 - 285 - // ---- IPC: auth ------------------------------------------------------------- 286 - 287 - ipcMain.handle( 288 - 'auth:login', 289 - async (_e, args: { email: string; apiKey: string }) => { 290 - const client = new Anthropic({ apiKey: args.apiKey }) 291 - try { 292 - await client.messages.create({ 293 - model: 'claude-haiku-4-5-20251001', 294 - max_tokens: 8, 295 - messages: [{ role: 'user', content: 'hi' }], 296 - }) 297 - } catch (err) { 298 - return { 299 - ok: false as const, 300 - error: err instanceof Error ? err.message : String(err), 301 - } 302 - } 303 - await setApiKey(args.email, args.apiKey) 304 - await saveSettings({ ...(await loadSettings()), email: args.email }) 305 - currentIdentity = identityFromEmail(args.email) 306 - return { ok: true as const, identity: currentIdentity } 307 - }, 308 - ) 309 - 310 - ipcMain.handle('auth:status', async () => { 311 - const s = await loadSettings() 312 - if (!s.email) return { loggedIn: false as const } 313 - const key = await getApiKey(s.email) 314 - if (!key) return { loggedIn: false as const } 315 - currentIdentity = identityFromEmail(s.email) 316 - return { loggedIn: true as const, identity: currentIdentity } 317 - }) 318 - 319 - function identityFromEmail(email: string): Identity { 320 - return { 321 - email, 322 - userId: userIdFromEmail(email), 323 - displayName: email.split('@')[0], 324 - } 325 - } 326 - 327 - // ---- IPC: settings --------------------------------------------------------- 328 - 329 - ipcMain.handle('settings:get', loadSettings) 330 - 331 - ipcMain.handle('settings:pickSyncRoot', async () => { 332 - if (!mainWindow) return null 333 - const r = await dialog.showOpenDialog(mainWindow, { 334 - properties: ['openDirectory', 'createDirectory'], 335 - title: 'Pick sync folder (e.g. somewhere inside iCloud Drive)', 336 - }) 337 - if (r.canceled || !r.filePaths[0]) return null 338 - await saveSettings({ ...(await loadSettings()), syncRoot: r.filePaths[0] }) 339 - return r.filePaths[0] 340 - }) 341 - 342 - // ---- IPC: projects --------------------------------------------------------- 343 - 344 - ipcMain.handle('project:list', listProjects) 345 - 346 - ipcMain.handle('project:create', async (_e, args: { name: string }) => { 347 - if (!currentIdentity) throw new Error('not logged in') 348 - const root = await ensureSyncRoot() 349 - const id = crypto.randomUUID() 350 - const slug = args.name.replace(/[^\w\-]/g, '_').slice(0, 40) 351 - const dir = path.join(root, `${slug}-${id.slice(0, 8)}`) 352 - await fs.mkdir(dir, { recursive: true }) 353 - await fs.writeFile( 354 - path.join(dir, 'meta.json'), 355 - JSON.stringify({ id, name: args.name }, null, 2), 356 - ) 357 - return { id, name: args.name, syncFolder: dir } satisfies ProjectMeta 358 - }) 359 - 360 - ipcMain.handle('project:open', async (_e, args: { syncFolder: string }) => { 361 - if (!currentIdentity) throw new Error('not logged in') 362 - if (currentSync) { 363 - stopPresence() 364 - await clearOwnPresence() 365 - await currentSync.close() 366 - currentSync = null 367 - } 368 - const meta = JSON.parse( 369 - await fs.readFile(path.join(args.syncFolder, 'meta.json'), 'utf8'), 370 - ) 371 - const sync = new ProjectSync(args.syncFolder, currentIdentity.userId) 372 - const now = Date.now() 373 - const id = currentIdentity // capture for closures 374 - await sync.init({ 375 - id: meta.id, 376 - createdAt: now, 377 - createdBy: id.userId, 378 - name: meta.name, 379 - description: '', 380 - systemPrompt: '', 381 - defaultModel: 'claude-sonnet-4-6', 382 - members: { 383 - [id.userId]: { 384 - email: id.email, 385 - displayName: id.displayName, 386 - joinedAt: now, 387 - role: 'owner', 388 - lastSeen: now, 389 - }, 390 - }, 391 - conversations: {}, 392 - files: {}, 393 - mainFileId: null, 394 - }) 395 - // Mark ourselves as a member (no-op if we already are), bump lastSeen, 396 - // seed the main file if no peer has done so yet, and sweep any of OUR own 397 - // orphan streaming turns (from a previous run that crashed mid-stream). 398 - sync.change((d) => { 399 - const existing = d.members[id.userId] 400 - if (!existing) { 401 - d.members[id.userId] = { 402 - email: id.email, 403 - displayName: id.displayName, 404 - joinedAt: now, 405 - role: 'member', 406 - lastSeen: now, 407 - } 408 - } else { 409 - existing.lastSeen = now 410 - } 411 - if (!d.mainFileId) { 412 - const fileId = crypto.randomUUID() 413 - d.files[fileId] = { 414 - id: fileId, 415 - name: 'main.md', 416 - content: 417 - `# ${d.name}\n\n` + 418 - `Project overview. The agent keeps this file updated with known facts, ` + 419 - `current state, decisions made, and anything the team has asked to be remembered.\n`, 420 - createdBy: id.userId, 421 - createdAt: now, 422 - updatedBy: id.userId, 423 - updatedAt: now, 424 - } 425 - d.mainFileId = fileId 426 - } 427 - // Only sweep streaming turns authored by us. Peers may still be streaming 428 - // on their machines — we'd be wrongly killing live turns. 429 - for (const conv of Object.values(d.conversations)) { 430 - for (const turn of Object.values(conv.turns)) { 431 - if (turn.status === 'streaming' && turn.author === id.userId) { 432 - turn.status = 'error' 433 - turn.text = (turn.text || '') + '\n\n_[interrupted]_' 434 - } 435 - } 436 - } 437 - }) 438 - sync.on('change', (snap: ProjectDoc) => { 439 - mainWindow?.webContents.send('doc:changed', snap) 440 - }) 441 - currentSync = sync 442 - startPresence() 443 - return sync.snapshot() 444 - }) 445 - 446 - ipcMain.handle('project:invite', async (_e, args: { email: string }) => { 447 - if (!currentSync || !currentIdentity) throw new Error('no project open') 448 - const snap = currentSync.snapshot() 449 - const myRole = snap.members[currentIdentity.userId]?.role 450 - if (myRole !== 'owner') { 451 - return { ok: false as const, error: 'only owners can invite' } 452 - } 453 - const email = args.email.trim().toLowerCase() 454 - if (!email.includes('@')) { 455 - return { ok: false as const, error: 'invalid email' } 456 - } 457 - const newUserId = userIdFromEmail(email) 458 - const now = Date.now() 459 - currentSync.change((d) => { 460 - if (!d.members[newUserId]) { 461 - d.members[newUserId] = { 462 - email, 463 - displayName: email.split('@')[0], 464 - joinedAt: now, 465 - role: 'member', 466 - lastSeen: 0, 467 - } 468 - } 469 - }) 470 - return { ok: true as const } 471 - }) 472 - 473 - // Append the file's pre-image to its history (capped to MAX_FILE_HISTORY 474 - // entries, oldest dropped). Called BEFORE overwriting `f.content`. Does not 475 - // touch updatedBy/updatedAt — the caller stamps those after. 476 - const MAX_FILE_HISTORY = 5 477 - function pushHistory( 478 - f: { content: string; updatedBy: string; updatedAt: number; history?: Array<{ content: string; updatedBy: string; updatedAt: number }> }, 479 - _by: string, 480 - _ts: number, 481 - ): void { 482 - if (!f.history) f.history = [] 483 - f.history.push({ 484 - content: f.content, 485 - updatedBy: f.updatedBy, 486 - updatedAt: f.updatedAt, 487 - }) 488 - while (f.history.length > MAX_FILE_HISTORY) f.history.shift() 489 - } 490 - 491 - // ---- IPC: files ----------------------------------------------------------- 492 - 493 - ipcMain.handle( 494 - 'file:create', 495 - async (_e, args: { name: string; content?: string }) => { 496 - if (!currentSync || !currentIdentity) throw new Error('no project open') 497 - const me = currentIdentity 498 - const id = crypto.randomUUID() 499 - const now = Date.now() 500 - currentSync.change((d) => { 501 - d.files[id] = { 502 - id, 503 - name: args.name, 504 - content: args.content ?? '', 505 - createdBy: me.userId, 506 - createdAt: now, 507 - updatedBy: me.userId, 508 - updatedAt: now, 509 - } 510 - }) 511 - return id 512 - }, 513 - ) 514 - 515 - ipcMain.handle( 516 - 'file:update', 517 - async ( 518 - _e, 519 - args: { fileId: string; content?: string; name?: string }, 520 - ) => { 521 - if (!currentSync || !currentIdentity) throw new Error('no project open') 522 - const me = currentIdentity 523 - const now = Date.now() 524 - currentSync.change((d) => { 525 - const f = d.files[args.fileId] 526 - if (!f) return 527 - if (args.content !== undefined && args.content !== f.content) { 528 - pushHistory(f, me.userId, now) 529 - f.content = args.content 530 - } 531 - if (args.name !== undefined) f.name = args.name 532 - f.updatedBy = me.userId 533 - f.updatedAt = now 534 - }) 535 - return { ok: true as const } 536 - }, 537 - ) 538 - 539 - ipcMain.handle('file:delete', async (_e, args: { fileId: string }) => { 540 - if (!currentSync) throw new Error('no project open') 541 - const snap = currentSync.snapshot() 542 - if (snap.mainFileId === args.fileId) { 543 - return { ok: false as const, error: 'cannot delete the main file' } 544 - } 545 - currentSync.change((d) => { 546 - delete d.files[args.fileId] 547 - }) 548 - return { ok: true as const } 549 - }) 550 - 551 - // ---- IPC: conversations & turns ------------------------------------------- 552 - 553 - ipcMain.handle('conversation:create', async (_e, args: { title: string }) => { 554 - if (!currentSync || !currentIdentity) throw new Error('no project open') 555 - const id = crypto.randomUUID() 556 - const now = Date.now() 557 - const me = currentIdentity.userId 558 - currentSync.change((d) => { 559 - d.conversations[id] = { 560 - id, 561 - createdAt: now, 562 - createdBy: me, 563 - title: args.title, 564 - archived: false, 565 - rootTurnIds: {}, 566 - turns: {}, 567 - } 568 - }) 569 - return id 570 - }) 571 - 572 - // Throttle for how often a streaming assistant turn's text gets persisted to 573 - // the Automerge doc (and thus written to disk and propagated to peers). 574 - const STREAM_FLUSH_MS = 250 575 - 576 - ipcMain.handle( 577 - 'turn:send', 578 - async (_e, args: { convId: string; text: string }) => { 579 - if (!currentSync || !currentIdentity) throw new Error('no project open') 580 - const sync = currentSync 581 - const me = currentIdentity 582 - const snap = sync.snapshot() 583 - const conv = snap.conversations[args.convId] 584 - if (!conv) throw new Error('conversation not found') 585 - 586 - const apiKey = await getApiKey(me.email) 587 - if (!apiKey) throw new Error('api key missing from keychain') 588 - 589 - const parentId = findLatestLeaf(conv) 590 - const userTurnId = crypto.randomUUID() 591 - const asstTurnId = crypto.randomUUID() 592 - const now = Date.now() 593 - 594 - // Append the user turn and an empty streaming placeholder for the 595 - // assistant turn in a single change, so peers see both immediately. 596 - sync.change((d) => { 597 - const c = d.conversations[args.convId] 598 - c.turns[userTurnId] = { 599 - id: userTurnId, 600 - parentId, 601 - author: me.userId, 602 - role: 'user', 603 - createdAt: now, 604 - text: args.text, 605 - } 606 - if (!parentId) c.rootTurnIds[userTurnId] = true 607 - c.turns[asstTurnId] = { 608 - id: asstTurnId, 609 - parentId: userTurnId, 610 - author: me.userId, 611 - role: 'assistant', 612 - createdAt: now + 1, 613 - text: '', 614 - status: 'streaming', 615 - modelId: snap.defaultModel, 616 - paidBy: me.userId, 617 - } 618 - }) 619 - 620 - // Throttled flush: stream callbacks write to `pendingText`; a timer 621 - // pushes the latest value into the doc no more than every STREAM_FLUSH_MS. 622 - let pendingText = '' 623 - let lastFlushed = '' 624 - const flushIfChanged = (): void => { 625 - if (pendingText === lastFlushed) return 626 - const txt = pendingText 627 - sync.change((d) => { 628 - const t = d.conversations[args.convId]?.turns[asstTurnId] 629 - if (t && t.status === 'streaming') t.text = txt 630 - }) 631 - lastFlushed = txt 632 - } 633 - const flushTimer = setInterval(flushIfChanged, STREAM_FLUSH_MS) 634 - 635 - // Handle tool calls by mutating the synced doc. Returns a short result 636 - // string for the model + a tag for the UI annotation. 637 - const handleToolUse = async (call: ToolCall): Promise<ToolResult> => { 638 - const input = call.input 639 - const meUserId = me.userId 640 - 641 - if (call.name === 'update_main_file') { 642 - const content = 643 - typeof input.content === 'string' ? input.content : '' 644 - let msg = '' 645 - let ok = false 646 - sync.change((d) => { 647 - const fid = d.mainFileId 648 - const f = fid ? d.files[fid] : null 649 - if (!f) { 650 - msg = 'No main file exists in this project.' 651 - return 652 - } 653 - if (content !== f.content) { 654 - pushHistory(f, meUserId, Date.now()) 655 - f.content = content 656 - } 657 - f.updatedBy = meUserId 658 - f.updatedAt = Date.now() 659 - msg = `Updated ${f.name}` 660 - ok = true 661 - }) 662 - return { toolUseId: call.id, content: msg, isError: !ok } 663 - } 664 - 665 - if (call.name === 'create_file') { 666 - const name = 667 - typeof input.name === 'string' ? input.name.trim() : '' 668 - const content = 669 - typeof input.content === 'string' ? input.content : '' 670 - if (!name) { 671 - return { 672 - toolUseId: call.id, 673 - content: 'Error: `name` is required.', 674 - isError: true, 675 - } 676 - } 677 - const fid = crypto.randomUUID() 678 - const now = Date.now() 679 - sync.change((d) => { 680 - d.files[fid] = { 681 - id: fid, 682 - name, 683 - content, 684 - createdBy: meUserId, 685 - createdAt: now, 686 - updatedBy: meUserId, 687 - updatedAt: now, 688 - } 689 - }) 690 - return { toolUseId: call.id, content: `Created ${name}` } 691 - } 692 - 693 - if (call.name === 'update_file') { 694 - const name = typeof input.name === 'string' ? input.name : '' 695 - const content = 696 - typeof input.content === 'string' ? input.content : '' 697 - let foundId: string | null = null 698 - let msg = '' 699 - sync.change((d) => { 700 - for (const id of Object.keys(d.files)) { 701 - if (d.files[id].name === name) { 702 - foundId = id 703 - break 704 - } 705 - } 706 - if (!foundId) { 707 - msg = `File "${name}" not found.` 708 - return 709 - } 710 - const f = d.files[foundId] 711 - if (content !== f.content) { 712 - pushHistory(f, meUserId, Date.now()) 713 - f.content = content 714 - } 715 - f.updatedBy = meUserId 716 - f.updatedAt = Date.now() 717 - msg = `Updated ${name}` 718 - }) 719 - return { toolUseId: call.id, content: msg, isError: !foundId } 720 - } 721 - 722 - if (call.name === 'patch_file') { 723 - const name = typeof input.name === 'string' ? input.name : '' 724 - const rawPatches = Array.isArray(input.patches) ? input.patches : [] 725 - const patches = rawPatches 726 - .map((p) => 727 - p && typeof p === 'object' 728 - ? (p as { search?: unknown; replace?: unknown }) 729 - : null, 730 - ) 731 - .filter( 732 - (p): p is { search: string; replace: string } => 733 - !!p && 734 - typeof p.search === 'string' && 735 - typeof p.replace === 'string', 736 - ) 737 - if (patches.length === 0) { 738 - return { 739 - toolUseId: call.id, 740 - content: 'Error: patches must be a non-empty array of {search, replace}.', 741 - isError: true, 742 - } 743 - } 744 - 745 - let foundId: string | null = null 746 - let errMsg = '' 747 - let appliedCount = 0 748 - 749 - sync.change((d) => { 750 - for (const id of Object.keys(d.files)) { 751 - if (d.files[id].name === name) { 752 - foundId = id 753 - break 754 - } 755 - } 756 - if (!foundId) { 757 - errMsg = `File "${name}" not found.` 758 - return 759 - } 760 - let content: string = d.files[foundId].content 761 - for (let i = 0; i < patches.length; i++) { 762 - const { search, replace } = patches[i] 763 - if (!search) { 764 - errMsg = `Patch ${i + 1}: empty search string.` 765 - return 766 - } 767 - const idx = content.indexOf(search) 768 - if (idx === -1) { 769 - errMsg = `Patch ${i + 1}: search string not found in ${name}. ` + 770 - `Searched for: ${JSON.stringify(search.slice(0, 80))}` 771 - return 772 - } 773 - const idx2 = content.indexOf(search, idx + 1) 774 - if (idx2 !== -1) { 775 - errMsg = `Patch ${i + 1}: search string matches multiple places ` + 776 - `in ${name}. Add more surrounding context to make it unique.` 777 - return 778 - } 779 - content = 780 - content.slice(0, idx) + replace + content.slice(idx + search.length) 781 - appliedCount++ 782 - } 783 - const f = d.files[foundId] 784 - if (content !== f.content) { 785 - pushHistory(f, meUserId, Date.now()) 786 - f.content = content 787 - } 788 - f.updatedBy = meUserId 789 - f.updatedAt = Date.now() 790 - }) 791 - 792 - if (errMsg) { 793 - return { toolUseId: call.id, content: errMsg, isError: true } 794 - } 795 - return { 796 - toolUseId: call.id, 797 - content: `Applied ${appliedCount} patch${appliedCount === 1 ? '' : 'es'} to ${name}`, 798 - } 799 - } 800 - 801 - return { 802 - toolUseId: call.id, 803 - content: `Unknown tool: ${call.name}`, 804 - isError: true, 805 - } 806 - } 807 - 808 - return new Promise<{ ok: boolean; error?: string }>((resolve) => { 809 - const freshConv = sync.snapshot().conversations[args.convId] 810 - runAgentTurn( 811 - { 812 - apiKey, 813 - model: snap.defaultModel, 814 - getSystemPrompt: () => buildSystemPrompt(sync.snapshot()), 815 - conversation: freshConv, 816 - leafTurnId: userTurnId, 817 - tools: PROJECT_TOOLS, 818 - toolHandler: handleToolUse, 819 - annotateToolResult: (_call, result) => { 820 - const icon = result.isError ? '⚠' : '✓' 821 - return `\n\n_${icon} ${result.content}_` 822 - }, 823 - }, 824 - { 825 - onTextSnapshot: (text) => { 826 - pendingText = text 827 - }, 828 - onFinal: (result) => { 829 - clearInterval(flushTimer) 830 - sync.change((d) => { 831 - const t = d.conversations[args.convId]?.turns[asstTurnId] 832 - if (!t) return 833 - t.text = result.text 834 - t.status = 'complete' 835 - t.inputTokens = result.inputTokens 836 - t.outputTokens = result.outputTokens 837 - }) 838 - // Fire-and-forget auto-name if this conversation is still untitled 839 - // and we now have the first user+assistant exchange complete. 840 - void maybeAutoNameConversation(args.convId, apiKey, sync) 841 - resolve({ ok: true }) 842 - }, 843 - onError: (err) => { 844 - clearInterval(flushTimer) 845 - sync.change((d) => { 846 - const t = d.conversations[args.convId]?.turns[asstTurnId] 847 - if (!t) return 848 - t.text = 849 - (pendingText || '') + `\n\n[error: ${err.message}]` 850 - t.status = 'error' 851 - }) 852 - resolve({ ok: false, error: err.message }) 853 - }, 854 - }, 855 - ) 856 - }) 857 - }, 858 - ) 859 - 860 - // Tools the model can invoke to modify shared project state. Defined once 861 - // here; the executor is `handleToolUse` inside `turn:send`. Always pass the 862 - // full new content (not patches) — keeps the schema dead-simple. 863 - const PROJECT_TOOLS: Anthropic.Tool[] = [ 864 - { 865 - name: 'update_main_file', 866 - description: 867 - "Replace the entire content of the project's main file (the team's shared brain). " + 868 - 'Use this when a user asks you to remember a fact, record a decision, or update the running state. ' + 869 - 'Pass the complete new content; existing content not in your update will be lost.', 870 - input_schema: { 871 - type: 'object', 872 - properties: { 873 - content: { 874 - type: 'string', 875 - description: 'The complete new content of the main file (markdown).', 876 - }, 877 - }, 878 - required: ['content'], 879 - }, 880 - }, 881 - { 882 - name: 'create_file', 883 - description: 884 - 'Create a new working file in the project. ' + 885 - 'Use when a user asks you to start a new document, list, or note that should be shared.', 886 - input_schema: { 887 - type: 'object', 888 - properties: { 889 - name: { 890 - type: 'string', 891 - description: 'Filename including extension, e.g. "todo.md" or "spec.md".', 892 - }, 893 - content: { 894 - type: 'string', 895 - description: 'Initial content of the file (markdown).', 896 - }, 897 - }, 898 - required: ['name', 'content'], 899 - }, 900 - }, 901 - { 902 - name: 'update_file', 903 - description: 904 - 'Replace the entire content of an existing working file (NOT the main file — use update_main_file for that). ' + 905 - 'Pass the complete new content. Prefer `patch_file` when changing only part of a long file.', 906 - input_schema: { 907 - type: 'object', 908 - properties: { 909 - name: { 910 - type: 'string', 911 - description: 'Filename of an existing working file in the project.', 912 - }, 913 - content: { 914 - type: 'string', 915 - description: 'The complete new content of the file.', 916 - }, 917 - }, 918 - required: ['name', 'content'], 919 - }, 920 - }, 921 - { 922 - name: 'patch_file', 923 - description: 924 - 'Apply one or more search/replace patches to an existing file (main or working). ' + 925 - 'Cheaper than rewriting the whole file. Each search string must match exactly (including whitespace) ' + 926 - 'and uniquely; include enough surrounding context to disambiguate.', 927 - input_schema: { 928 - type: 'object', 929 - properties: { 930 - name: { 931 - type: 'string', 932 - description: 'Filename of an existing file in the project.', 933 - }, 934 - patches: { 935 - type: 'array', 936 - description: 937 - 'One or more search/replace operations applied in order. ' + 938 - 'If any patch fails (string not found, multiple matches, empty search), ' + 939 - 'no changes are committed.', 940 - items: { 941 - type: 'object', 942 - properties: { 943 - search: { 944 - type: 'string', 945 - description: 946 - 'Exact text to find. Must appear exactly once in the file at the time of the patch.', 947 - }, 948 - replace: { 949 - type: 'string', 950 - description: 'Text to replace the matched search string with.', 951 - }, 952 - }, 953 - required: ['search', 'replace'], 954 - }, 955 - }, 956 - }, 957 - required: ['name', 'patches'], 958 - }, 959 - }, 960 - ] 961 - 962 - // Budget for file content in the system prompt, in characters. Roughly 963 - // `chars / 4 ≈ tokens`. 400k chars ≈ 100k tokens — half the Sonnet context, 964 - // leaving room for conversation history + response. Files past the budget 965 - // are listed as metadata stubs rather than full content. 966 - const SYSTEM_PROMPT_FILE_BUDGET_CHARS = 400_000 967 - 968 - // Build the system prompt for an API call. Includes the project's configured 969 - // systemPrompt, a tools-usage note, then project files inline (main first, 970 - // working files alphabetically). Enforces a character budget on file content; 971 - // files past the budget are stubbed with a "[omitted]" marker. 972 - function buildSystemPrompt(snap: ProjectDoc): string { 973 - const parts: string[] = [] 974 - if (snap.systemPrompt) parts.push(snap.systemPrompt) 975 - 976 - parts.push( 977 - `You can modify the project's shared files via tools:\n` + 978 - ` - update_main_file: replace the main file (the team's shared brain). Use when asked to remember facts, record decisions, or update running state.\n` + 979 - ` - create_file: add a new working file.\n` + 980 - ` - update_file: replace an existing working file's contents.\n` + 981 - ` - patch_file: search/replace within an existing file. Prefer this over update_file/update_main_file when the change is small relative to the file.\n\n` + 982 - `When using update_main_file / update_file, pass the COMPLETE new content. ` + 983 - `When using patch_file, each search string must appear exactly once. ` + 984 - `Be conservative — only modify files when the user clearly asks for it or when ` + 985 - `a fact/decision is worth persisting beyond this conversation.`, 986 - ) 987 - 988 - const all = Object.values(snap.files) 989 - if (all.length > 0) { 990 - const main = snap.mainFileId ? snap.files[snap.mainFileId] : null 991 - const working = all 992 - .filter((f) => f.id !== snap.mainFileId) 993 - .sort((a, b) => a.name.localeCompare(b.name)) 994 - const ordered = main ? [main, ...working] : working 995 - parts.push( 996 - `Current project files (the main file is authoritative for state, ` + 997 - `decisions, and known facts):`, 998 - ) 999 - let used = 0 1000 - for (const f of ordered) { 1001 - const tag = f.id === snap.mainFileId ? ' (main file)' : '' 1002 - const remaining = SYSTEM_PROMPT_FILE_BUDGET_CHARS - used 1003 - if (f.content.length <= remaining) { 1004 - parts.push(`=== ${f.name}${tag} ===\n${f.content}`) 1005 - used += f.content.length 1006 - } else { 1007 - parts.push( 1008 - `=== ${f.name}${tag} ===\n[file omitted from this turn: ${f.content.length} chars would exceed the ${SYSTEM_PROMPT_FILE_BUDGET_CHARS}-char context budget. Use patch_file or ask the user for the relevant section.]`, 1009 - ) 1010 - } 1011 - } 1012 - } 1013 - 1014 - return parts.join('\n\n') 1015 - } 1016 - 1017 - // Cheap Haiku call to suggest a 4-6 word title after the first exchange 1018 - // completes. Only fires if the conversation is still titled "Untitled" — by 1019 - // the time this runs, it may have been renamed manually or by another client. 1020 - async function maybeAutoNameConversation( 1021 - convId: string, 1022 - apiKey: string, 1023 - sync: ProjectSync, 1024 - ): Promise<void> { 1025 - const snap = sync.snapshot() 1026 - const conv = snap.conversations[convId] 1027 - if (!conv || conv.title !== 'Untitled') return 1028 - const turns = Object.values(conv.turns) 1029 - .filter((t) => !t.status || t.status === 'complete') 1030 - .sort((a, b) => a.createdAt - b.createdAt) 1031 - const firstUser = turns.find((t) => t.role === 'user') 1032 - const firstAsst = turns.find((t) => t.role === 'assistant') 1033 - if (!firstUser || !firstAsst) return 1034 - try { 1035 - const client = new Anthropic({ apiKey }) 1036 - const resp = await client.messages.create({ 1037 - model: 'claude-haiku-4-5-20251001', 1038 - max_tokens: 30, 1039 - messages: [ 1040 - { 1041 - role: 'user', 1042 - content: 1043 - `Suggest a concise 4-6 word title for this conversation. ` + 1044 - `Reply with only the title — no quotes, no punctuation at end.\n\n` + 1045 - `User: ${firstUser.text.slice(0, 500)}\n\n` + 1046 - `Assistant: ${firstAsst.text.slice(0, 500)}`, 1047 - }, 1048 - ], 1049 - }) 1050 - const title = resp.content 1051 - .filter((b): b is Anthropic.TextBlock => b.type === 'text') 1052 - .map((b) => b.text) 1053 - .join('') 1054 - .trim() 1055 - .replace(/^["']|["'.]$/g, '') 1056 - .slice(0, 80) 1057 - if (!title) return 1058 - sync.change((d) => { 1059 - const c = d.conversations[convId] 1060 - if (c && c.title === 'Untitled') c.title = title 1061 - }) 1062 - } catch (err) { 1063 - console.warn('[auto-name] failed:', err) 1064 - } 1065 - } 1066 - 1067 - // Pick a leaf to parent a new user turn to. Prefer the most recent leaf that 1068 - // isn't an in-flight (streaming) or errored assistant turn — those shouldn't 1069 - // be in the response history we send back to Claude. If everything is 1070 - // in-flight (concurrent users), fall back to the in-flight leaf's parent. 1071 - function findLatestLeaf(conv: Conversation): string | null { 1072 - const turns = Object.values(conv.turns) 1073 - if (turns.length === 0) return null 1074 - const hasChild = new Set<string>() 1075 - for (const t of turns) if (t.parentId) hasChild.add(t.parentId) 1076 - const leaves = turns 1077 - .filter((t) => !hasChild.has(t.id)) 1078 - .sort((a, b) => b.createdAt - a.createdAt) 1079 - const usable = leaves.find( 1080 - (t) => t.role === 'user' || !t.status || t.status === 'complete', 1081 - ) 1082 - if (usable) return usable.id 1083 - return leaves[0]?.parentId ?? null 1084 - }
-15
src/main/keychain.ts
··· 1 - import keytar from 'keytar' 2 - 3 - const SERVICE = 'centrosome' 4 - 5 - export async function setApiKey(email: string, key: string): Promise<void> { 6 - await keytar.setPassword(SERVICE, email, key) 7 - } 8 - 9 - export async function getApiKey(email: string): Promise<string | null> { 10 - return keytar.getPassword(SERVICE, email) 11 - } 12 - 13 - export async function deleteApiKey(email: string): Promise<void> { 14 - await keytar.deletePassword(SERVICE, email) 15 - }
-181
src/main/sync.ts
··· 1 - // File-per-actor Automerge sync over a shared folder (typically iCloud Drive). 2 - // 3 - // Invariants: 4 - // - Each client only ever writes its own file: `doc.<userId>.automerge`. 5 - // - On startup, and whenever any peer file changes, we merge it into our in-memory doc. 6 - // - Writes are debounced; concurrent flushes are coalesced into one pending write. 7 - // 8 - // Why file-per-actor: iCloud Drive's response to two clients writing the same 9 - // file concurrently is to create "foo 2.automerge" conflict copies. Avoiding 10 - // shared writers sidesteps that entirely. 11 - 12 - import * as Automerge from '@automerge/automerge' 13 - import { promises as fs } from 'fs' 14 - import * as path from 'path' 15 - import * as crypto from 'crypto' 16 - import chokidar from 'chokidar' 17 - import { EventEmitter } from 'events' 18 - import type { ProjectDoc, UserId } from '../shared/schema.js' 19 - 20 - export function userIdFromEmail(email: string): UserId { 21 - return crypto 22 - .createHash('sha256') 23 - .update(email.trim().toLowerCase()) 24 - .digest('hex') 25 - .slice(0, 16) 26 - } 27 - 28 - export class ProjectSync extends EventEmitter { 29 - private doc: Automerge.Doc<ProjectDoc> 30 - private readonly myFile: string 31 - private watcher?: chokidar.FSWatcher 32 - private writePending = false 33 - private writing = false 34 - 35 - constructor( 36 - public readonly projectDir: string, 37 - private readonly actorId: UserId, 38 - ) { 39 - super() 40 - this.myFile = path.join(projectDir, `doc.${actorId}.automerge`) 41 - this.doc = Automerge.init<ProjectDoc>({ actor: actorId }) 42 - } 43 - 44 - async init(seed: ProjectDoc): Promise<void> { 45 - await fs.mkdir(this.projectDir, { recursive: true }) 46 - await fs.mkdir(path.join(this.projectDir, 'blobs'), { recursive: true }) 47 - 48 - // Merge in everything that already exists on disk (including our own file 49 - // from a prior session). 50 - const files = await fs.readdir(this.projectDir) 51 - const docFiles = files.filter( 52 - (f) => f.startsWith('doc.') && f.endsWith('.automerge'), 53 - ) 54 - let loadedAny = false 55 - for (const f of docFiles) { 56 - try { 57 - const bytes = await fs.readFile(path.join(this.projectDir, f)) 58 - const peer = Automerge.load<ProjectDoc>(bytes) 59 - this.doc = Automerge.merge(this.doc, peer) 60 - loadedAny = true 61 - } catch (err) { 62 - console.warn(`[sync] failed to load ${f}:`, err) 63 - } 64 - } 65 - 66 - // Conflict-copy reconciliation: iCloud / Dropbox occasionally produce 67 - // "doc.<userId> 2.automerge" copies of our canonical file when the same 68 - // user is logged in on two machines. Their bytes are already merged into 69 - // our in-memory doc above; flush our canonical write, then delete the 70 - // copies so they don't accumulate. We only ever delete copies of OUR 71 - // own file — never anyone else's. 72 - const myBase = path.basename(this.myFile) 73 - const myPrefix = `doc.${this.actorId}` 74 - const ownConflictCopies = docFiles.filter( 75 - (f) => f !== myBase && f.startsWith(myPrefix) && f.endsWith('.automerge'), 76 - ) 77 - 78 - if (!loadedAny) { 79 - // First-ever open: stamp the project's full initial state. 80 - this.doc = Automerge.change(this.doc, (d) => { 81 - Object.assign(d, seed) 82 - }) 83 - await this.flush() 84 - } else { 85 - // Backfill: a doc written by an older schema may be missing top-level 86 - // fields we now expect (e.g. `files`, `mainFileId`). Fill them from 87 - // the seed so callers can rely on them existing. 88 - const js = Automerge.toJS(this.doc) as unknown as Record<string, unknown> 89 - const missing = Object.entries(seed).filter( 90 - ([k]) => js[k] === undefined, 91 - ) 92 - if (missing.length > 0) { 93 - this.doc = Automerge.change(this.doc, (d) => { 94 - const dd = d as unknown as Record<string, unknown> 95 - for (const [k, v] of missing) dd[k] = v 96 - }) 97 - await this.flush() 98 - } 99 - } 100 - 101 - // After our canonical doc is on disk with everything merged in, delete 102 - // our own conflict copies. 103 - for (const c of ownConflictCopies) { 104 - try { 105 - await fs.unlink(path.join(this.projectDir, c)) 106 - } catch { 107 - // Best-effort; ignore. 108 - } 109 - } 110 - 111 - // Watch for peer writes. 112 - this.watcher = chokidar.watch(this.projectDir, { 113 - ignored: [this.myFile, /(^|[\\/])\../, /blobs[\\/]/], 114 - persistent: true, 115 - ignoreInitial: true, 116 - awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 }, 117 - }) 118 - this.watcher.on('add', (f) => this.mergePeerFile(f)) 119 - this.watcher.on('change', (f) => this.mergePeerFile(f)) 120 - } 121 - 122 - private async mergePeerFile(file: string): Promise<void> { 123 - if (!file.endsWith('.automerge')) return 124 - if (path.resolve(file) === path.resolve(this.myFile)) return 125 - try { 126 - const bytes = await fs.readFile(file) 127 - const peer = Automerge.load<ProjectDoc>(bytes) 128 - const merged = Automerge.merge(this.doc, peer) 129 - const before = Automerge.getHeads(this.doc).join(',') 130 - const after = Automerge.getHeads(merged).join(',') 131 - if (before !== after) { 132 - this.doc = merged 133 - this.emit('change', this.snapshot()) 134 - } 135 - } catch (err) { 136 - console.warn(`[sync] failed to merge ${file}:`, err) 137 - } 138 - } 139 - 140 - change(fn: (d: ProjectDoc) => void): void { 141 - this.doc = Automerge.change(this.doc, fn) 142 - this.emit('change', this.snapshot()) 143 - this.scheduleFlush() 144 - } 145 - 146 - private scheduleFlush(): void { 147 - this.writePending = true 148 - if (!this.writing) void this.doFlush() 149 - } 150 - 151 - private async doFlush(): Promise<void> { 152 - while (this.writePending) { 153 - this.writePending = false 154 - this.writing = true 155 - try { 156 - const bytes = Automerge.save(this.doc) 157 - const tmp = `${this.myFile}.tmp` 158 - await fs.writeFile(tmp, bytes) 159 - await fs.rename(tmp, this.myFile) 160 - } catch (err) { 161 - console.error('[sync] flush failed:', err) 162 - } finally { 163 - this.writing = false 164 - } 165 - } 166 - } 167 - 168 - async flush(): Promise<void> { 169 - this.writePending = true 170 - await this.doFlush() 171 - } 172 - 173 - snapshot(): ProjectDoc { 174 - return Automerge.toJS(this.doc) as ProjectDoc 175 - } 176 - 177 - async close(): Promise<void> { 178 - await this.watcher?.close() 179 - await this.flush() 180 - } 181 - }
-86
src/preload/index.ts
··· 1 - import { contextBridge, ipcRenderer } from 'electron' 2 - import type { 3 - Identity, 4 - ProjectDoc, 5 - ProjectMeta, 6 - } from '../shared/schema.js' 7 - 8 - const api = { 9 - auth: { 10 - login: (email: string, apiKey: string) => 11 - ipcRenderer.invoke('auth:login', { email, apiKey }) as Promise< 12 - { ok: true; identity: Identity } | { ok: false; error: string } 13 - >, 14 - status: () => 15 - ipcRenderer.invoke('auth:status') as Promise< 16 - { loggedIn: true; identity: Identity } | { loggedIn: false } 17 - >, 18 - }, 19 - settings: { 20 - get: () => 21 - ipcRenderer.invoke('settings:get') as Promise<{ 22 - email?: string 23 - syncRoot?: string 24 - }>, 25 - pickSyncRoot: () => 26 - ipcRenderer.invoke('settings:pickSyncRoot') as Promise<string | null>, 27 - }, 28 - project: { 29 - list: () => ipcRenderer.invoke('project:list') as Promise<ProjectMeta[]>, 30 - create: (name: string) => 31 - ipcRenderer.invoke('project:create', { name }) as Promise<ProjectMeta>, 32 - open: (syncFolder: string) => 33 - ipcRenderer.invoke('project:open', { syncFolder }) as Promise<ProjectDoc>, 34 - invite: (email: string) => 35 - ipcRenderer.invoke('project:invite', { email }) as Promise<{ 36 - ok: boolean 37 - error?: string 38 - }>, 39 - }, 40 - conversation: { 41 - create: (title: string) => 42 - ipcRenderer.invoke('conversation:create', { title }) as Promise<string>, 43 - }, 44 - file: { 45 - create: (name: string, content?: string) => 46 - ipcRenderer.invoke('file:create', { name, content }) as Promise<string>, 47 - update: (fileId: string, args: { content?: string; name?: string }) => 48 - ipcRenderer.invoke('file:update', { 49 - fileId, 50 - ...args, 51 - }) as Promise<{ ok: boolean; error?: string }>, 52 - delete: (fileId: string) => 53 - ipcRenderer.invoke('file:delete', { fileId }) as Promise<{ 54 - ok: boolean 55 - error?: string 56 - }>, 57 - }, 58 - turn: { 59 - send: (convId: string, text: string) => 60 - ipcRenderer.invoke('turn:send', { convId, text }) as Promise<{ 61 - ok: boolean 62 - error?: string 63 - }>, 64 - }, 65 - onDocChanged: (cb: (doc: ProjectDoc) => void): (() => void) => { 66 - const listener = (_e: unknown, doc: ProjectDoc): void => cb(doc) 67 - ipcRenderer.on('doc:changed', listener) 68 - return () => ipcRenderer.removeListener('doc:changed', listener) 69 - }, 70 - onPresence: ( 71 - cb: ( 72 - presence: Record< 73 - string, 74 - { userId: string; displayName: string; ts: number } 75 - >, 76 - ) => void, 77 - ): (() => void) => { 78 - const listener = (_e: unknown, p: Record<string, { userId: string; displayName: string; ts: number }>): void => cb(p) 79 - ipcRenderer.on('presence:update', listener) 80 - return () => ipcRenderer.removeListener('presence:update', listener) 81 - }, 82 - } 83 - 84 - contextBridge.exposeInMainWorld('mc', api) 85 - 86 - export type McApi = typeof api
src/renderer/index.html index.html
-1410
src/renderer/src/App.tsx
··· 1 - import React, { useEffect, useRef, useState } from 'react' 2 - import ReactMarkdown from 'react-markdown' 3 - import remarkGfm from 'remark-gfm' 4 - import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels' 5 - import type { 6 - Conversation, 7 - Identity, 8 - Member, 9 - ProjectDoc, 10 - ProjectFile, 11 - ProjectMeta, 12 - Turn, 13 - } from '../../shared/schema' 14 - 15 - // Window-exposed API surface from preload. 16 - declare global { 17 - interface Window { 18 - mc: { 19 - auth: { 20 - login: ( 21 - email: string, 22 - apiKey: string, 23 - ) => Promise< 24 - { ok: true; identity: Identity } | { ok: false; error: string } 25 - > 26 - status: () => Promise< 27 - { loggedIn: true; identity: Identity } | { loggedIn: false } 28 - > 29 - } 30 - settings: { 31 - get: () => Promise<{ email?: string; syncRoot?: string }> 32 - pickSyncRoot: () => Promise<string | null> 33 - } 34 - project: { 35 - list: () => Promise<ProjectMeta[]> 36 - create: (name: string) => Promise<ProjectMeta> 37 - open: (syncFolder: string) => Promise<ProjectDoc> 38 - invite: (email: string) => Promise<{ ok: boolean; error?: string }> 39 - } 40 - conversation: { create: (title: string) => Promise<string> } 41 - file: { 42 - create: (name: string, content?: string) => Promise<string> 43 - update: ( 44 - fileId: string, 45 - args: { content?: string; name?: string }, 46 - ) => Promise<{ ok: boolean; error?: string }> 47 - delete: (fileId: string) => Promise<{ ok: boolean; error?: string }> 48 - } 49 - turn: { 50 - send: ( 51 - convId: string, 52 - text: string, 53 - ) => Promise<{ ok: boolean; error?: string }> 54 - } 55 - onDocChanged: (cb: (doc: ProjectDoc) => void) => () => void 56 - onPresence: ( 57 - cb: ( 58 - presence: Record< 59 - string, 60 - { userId: string; displayName: string; ts: number } 61 - >, 62 - ) => void, 63 - ) => () => void 64 - } 65 - } 66 - } 67 - 68 - type PresenceMap = Record< 69 - string, 70 - { userId: string; displayName: string; ts: number } 71 - > 72 - 73 - export function App(): React.ReactElement { 74 - const [identity, setIdentity] = useState<Identity | null>(null) 75 - const [project, setProject] = useState<{ 76 - meta: ProjectMeta 77 - doc: ProjectDoc 78 - } | null>(null) 79 - const [presence, setPresence] = useState<PresenceMap>({}) 80 - 81 - useEffect(() => { 82 - window.mc.auth.status().then((s) => { 83 - if (s.loggedIn) setIdentity(s.identity) 84 - }) 85 - }, []) 86 - 87 - useEffect(() => { 88 - if (!project) return 89 - return window.mc.onDocChanged((doc) => { 90 - setProject((p) => (p ? { ...p, doc } : null)) 91 - }) 92 - }, [project?.meta.id]) 93 - 94 - useEffect(() => { 95 - if (!project) { 96 - setPresence({}) 97 - return 98 - } 99 - return window.mc.onPresence((p) => setPresence(p)) 100 - }, [project?.meta.id]) 101 - 102 - if (!identity) return <Login onLogin={setIdentity} /> 103 - if (!project) 104 - return ( 105 - <ProjectList 106 - identity={identity} 107 - onOpen={(meta, doc) => setProject({ meta, doc })} 108 - /> 109 - ) 110 - return ( 111 - <ProjectView 112 - identity={identity} 113 - meta={project.meta} 114 - doc={project.doc} 115 - presence={presence} 116 - onBack={() => setProject(null)} 117 - /> 118 - ) 119 - } 120 - 121 - function Login({ 122 - onLogin, 123 - }: { 124 - onLogin: (i: Identity) => void 125 - }): React.ReactElement { 126 - const [email, setEmail] = useState('') 127 - const [apiKey, setApiKey] = useState('') 128 - const [error, setError] = useState<string | null>(null) 129 - const [busy, setBusy] = useState(false) 130 - 131 - const submit = async (): Promise<void> => { 132 - setBusy(true) 133 - setError(null) 134 - const r = await window.mc.auth.login(email.trim(), apiKey.trim()) 135 - setBusy(false) 136 - if (r.ok) onLogin(r.identity) 137 - else setError(r.error) 138 - } 139 - 140 - return ( 141 - <div style={{ maxWidth: 380, margin: '80px auto', padding: 16 }}> 142 - <h2 style={{ marginBottom: 4 }}>Centrosome</h2> 143 - <p style={{ color: 'var(--mc-fg-muted)', fontSize: 13, marginTop: 0 }}> 144 - Sign in with your email and Anthropic API key. Key is stored in the 145 - macOS Keychain, never in the synced project. 146 - </p> 147 - <input 148 - value={email} 149 - onChange={(e) => setEmail(e.target.value)} 150 - placeholder="you@example.com" 151 - style={inputStyle} 152 - /> 153 - <input 154 - value={apiKey} 155 - onChange={(e) => setApiKey(e.target.value)} 156 - placeholder="sk-ant-..." 157 - type="password" 158 - style={inputStyle} 159 - /> 160 - <button 161 - disabled={busy || !email || !apiKey} 162 - onClick={submit} 163 - style={buttonStyle} 164 - > 165 - {busy ? 'Validating…' : 'Sign in'} 166 - </button> 167 - {error && ( 168 - <p style={{ color: 'var(--mc-danger)', fontSize: 13 }}>{error}</p> 169 - )} 170 - </div> 171 - ) 172 - } 173 - 174 - function ProjectList({ 175 - identity, 176 - onOpen, 177 - }: { 178 - identity: Identity 179 - onOpen: (m: ProjectMeta, d: ProjectDoc) => void 180 - }): React.ReactElement { 181 - const [projects, setProjects] = useState<ProjectMeta[]>([]) 182 - const [syncRoot, setSyncRoot] = useState<string | undefined>() 183 - const [newName, setNewName] = useState('') 184 - 185 - const refresh = async (): Promise<void> => { 186 - setProjects(await window.mc.project.list()) 187 - setSyncRoot((await window.mc.settings.get()).syncRoot) 188 - } 189 - useEffect(() => { 190 - refresh() 191 - }, []) 192 - 193 - const openProject = async (m: ProjectMeta): Promise<void> => { 194 - const doc = await window.mc.project.open(m.syncFolder) 195 - onOpen(m, doc) 196 - } 197 - 198 - const create = async (): Promise<void> => { 199 - if (!newName.trim()) return 200 - const m = await window.mc.project.create(newName.trim()) 201 - setNewName('') 202 - await refresh() 203 - openProject(m) 204 - } 205 - 206 - return ( 207 - <div style={{ maxWidth: 640, margin: '40px auto', padding: 16 }}> 208 - <h2>Projects</h2> 209 - <p style={{ fontSize: 13, color: 'var(--mc-fg-muted)' }}> 210 - Signed in as <strong>{identity.email}</strong> ({identity.userId}). 211 - <br /> 212 - Sync folder: <code>{syncRoot ?? '(none)'}</code>{' '} 213 - <button 214 - onClick={async () => { 215 - await window.mc.settings.pickSyncRoot() 216 - refresh() 217 - }} 218 - style={linkBtn} 219 - > 220 - change 221 - </button> 222 - </p> 223 - <ul style={{ listStyle: 'none', padding: 0 }}> 224 - {projects.map((p) => ( 225 - <li 226 - key={p.id} 227 - style={{ 228 - padding: 12, 229 - border: '1px solid var(--mc-border)', 230 - borderRadius: 6, 231 - marginBottom: 8, 232 - cursor: 'pointer', 233 - }} 234 - onClick={() => openProject(p)} 235 - > 236 - <strong>{p.name}</strong> 237 - <div style={{ fontSize: 11, color: 'var(--mc-fg-faint)' }}>{p.syncFolder}</div> 238 - </li> 239 - ))} 240 - {projects.length === 0 && ( 241 - <li style={{ color: 'var(--mc-fg-faint)', fontSize: 13 }}> 242 - No projects you're a member of. Create one below, or ask an owner 243 - to invite <strong>{identity.email}</strong>. 244 - </li> 245 - )} 246 - </ul> 247 - <div style={{ display: 'flex', gap: 8 }}> 248 - <input 249 - value={newName} 250 - onChange={(e) => setNewName(e.target.value)} 251 - placeholder="New project name" 252 - style={{ ...inputStyle, marginBottom: 0 }} 253 - /> 254 - <button onClick={create} style={buttonStyle}> 255 - Create 256 - </button> 257 - </div> 258 - </div> 259 - ) 260 - } 261 - 262 - function MemberList({ 263 - members, 264 - presence, 265 - meUserId, 266 - }: { 267 - members: ProjectDoc['members'] 268 - presence: PresenceMap 269 - meUserId: string 270 - }): React.ReactElement { 271 - const entries = Object.entries(members).sort(([a], [b]) => { 272 - // me first, then alphabetical by displayName 273 - if (a === meUserId) return -1 274 - if (b === meUserId) return 1 275 - return members[a].displayName.localeCompare(members[b].displayName) 276 - }) 277 - return ( 278 - <div 279 - style={{ 280 - display: 'flex', 281 - flexWrap: 'wrap', 282 - gap: 4, 283 - fontSize: 11, 284 - color: 'var(--mc-fg-muted)', 285 - marginBottom: 4, 286 - }} 287 - > 288 - {entries.map(([userId, m]) => { 289 - const online = !!presence[userId] 290 - return ( 291 - <span 292 - key={userId} 293 - title={`${m.email}${online ? ' · online' : ''}`} 294 - style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }} 295 - > 296 - <span 297 - style={{ 298 - display: 'inline-block', 299 - width: 6, 300 - height: 6, 301 - borderRadius: '50%', 302 - background: online ? '#28a745' : 'var(--mc-border)', 303 - }} 304 - /> 305 - {m.displayName} 306 - {userId === meUserId ? '*' : ''} 307 - </span> 308 - ) 309 - })} 310 - </div> 311 - ) 312 - } 313 - 314 - function InviteControl(): React.ReactElement { 315 - const [open, setOpen] = useState(false) 316 - const [email, setEmail] = useState('') 317 - const [busy, setBusy] = useState(false) 318 - const [error, setError] = useState<string | null>(null) 319 - 320 - const submit = async (): Promise<void> => { 321 - if (!email.trim()) return 322 - setBusy(true) 323 - setError(null) 324 - const r = await window.mc.project.invite(email.trim()) 325 - setBusy(false) 326 - if (r.ok) { 327 - setEmail('') 328 - setOpen(false) 329 - } else { 330 - setError(r.error ?? 'failed') 331 - } 332 - } 333 - 334 - if (!open) { 335 - return ( 336 - <button 337 - onClick={() => setOpen(true)} 338 - style={{ ...linkBtn, fontSize: 11, marginBottom: 4 }} 339 - > 340 - + Invite member 341 - </button> 342 - ) 343 - } 344 - return ( 345 - <div style={{ display: 'flex', gap: 4, marginBottom: 6 }}> 346 - <input 347 - value={email} 348 - onChange={(e) => setEmail(e.target.value)} 349 - placeholder="email@…" 350 - autoFocus 351 - onKeyDown={(e) => { 352 - if (e.key === 'Enter') submit() 353 - if (e.key === 'Escape') setOpen(false) 354 - }} 355 - style={{ flex: 1, fontSize: 12, padding: 4 }} 356 - /> 357 - <button 358 - onClick={submit} 359 - disabled={busy || !email.trim()} 360 - style={{ fontSize: 12, padding: '2px 8px' }} 361 - > 362 - Add 363 - </button> 364 - {error && ( 365 - <div style={{ color: 'var(--mc-danger)', fontSize: 10 }}>{error}</div> 366 - )} 367 - </div> 368 - ) 369 - } 370 - 371 - function ProjectView({ 372 - identity, 373 - doc, 374 - presence, 375 - onBack, 376 - }: { 377 - identity: Identity 378 - meta: ProjectMeta 379 - doc: ProjectDoc 380 - presence: PresenceMap 381 - onBack: () => void 382 - }): React.ReactElement { 383 - const [activeConvId, setActiveConvId] = useState<string | null>(null) 384 - const [selectedFileId, setSelectedFileId] = useState<string | null>(null) 385 - const [rightPaneOpen, setRightPaneOpen] = useState(false) 386 - const convs = Object.values(doc.conversations).sort( 387 - (a, b) => b.createdAt - a.createdAt, 388 - ) 389 - const activeConv = activeConvId ? doc.conversations[activeConvId] : null 390 - 391 - const newConv = async (): Promise<void> => { 392 - const id = await window.mc.conversation.create('Untitled') 393 - setActiveConvId(id) 394 - } 395 - 396 - const openFile = (fileId: string): void => { 397 - setSelectedFileId(fileId) 398 - setRightPaneOpen(true) 399 - } 400 - 401 - const newFile = async (): Promise<void> => { 402 - const raw = window.prompt( 403 - 'File name (e.g. notes.md, todo.md):', 404 - 'notes.md', 405 - ) 406 - if (!raw) return 407 - const name = raw.trim() 408 - if (!name) return 409 - const id = await window.mc.file.create(name, `# ${name}\n\n`) 410 - openFile(id) 411 - } 412 - 413 - const uploadInputRef = useRef<HTMLInputElement>(null) 414 - const triggerUpload = (): void => uploadInputRef.current?.click() 415 - const MAX_UPLOAD_BYTES = 256 * 1024 // 256KB per file 416 - const onUploadFiles = async ( 417 - e: React.ChangeEvent<HTMLInputElement>, 418 - ): Promise<void> => { 419 - const picked = Array.from(e.target.files ?? []) 420 - let lastId: string | null = null 421 - for (const f of picked) { 422 - if (f.size > MAX_UPLOAD_BYTES) { 423 - window.alert( 424 - `${f.name} is ${(f.size / 1024).toFixed(0)}KB — exceeds 256KB cap.`, 425 - ) 426 - continue 427 - } 428 - let text: string 429 - try { 430 - text = await f.text() 431 - } catch (err) { 432 - window.alert(`Could not read ${f.name} as text: ${String(err)}`) 433 - continue 434 - } 435 - lastId = await window.mc.file.create(f.name, text) 436 - } 437 - if (lastId) openFile(lastId) 438 - if (uploadInputRef.current) uploadInputRef.current.value = '' 439 - } 440 - 441 - const toggleRight = (): void => setRightPaneOpen((v) => !v) 442 - const selectedFile = selectedFileId ? doc.files[selectedFileId] : null 443 - 444 - return ( 445 - <PanelGroup 446 - direction="horizontal" 447 - autoSaveId="mc-project-view" 448 - style={{ height: '100vh' }} 449 - > 450 - <Panel defaultSize={20} minSize={14} maxSize={40}> 451 - <aside 452 - style={{ 453 - height: '100%', 454 - padding: 12, 455 - overflowY: 'auto', 456 - boxSizing: 'border-box', 457 - }} 458 - > 459 - <button onClick={onBack} style={{ ...linkBtn, marginBottom: 8 }}> 460 - ← Projects 461 - </button> 462 - <h3 style={{ margin: '8px 0 4px 0' }}>{doc.name}</h3> 463 - <MemberList 464 - members={doc.members} 465 - presence={presence} 466 - meUserId={identity.userId} 467 - /> 468 - {doc.members[identity.userId]?.role === 'owner' && ( 469 - <InviteControl /> 470 - )} 471 - 472 - <SidebarSectionHeader 473 - label="Files" 474 - actions={[ 475 - { label: '↑ Upload', onClick: triggerUpload }, 476 - { label: '+ New', onClick: newFile }, 477 - ]} 478 - /> 479 - <input 480 - ref={uploadInputRef} 481 - type="file" 482 - multiple 483 - accept=".md,.txt,.json,.yml,.yaml,.csv,.tsv,.xml,.html,.css,.js,.ts,.tsx,.jsx,.py,.rs,.go,.java,.sh,.toml,.ini,.conf,.log" 484 - onChange={onUploadFiles} 485 - style={{ display: 'none' }} 486 - /> 487 - <FileList 488 - doc={doc} 489 - selectedFileId={selectedFileId} 490 - onOpen={openFile} 491 - /> 492 - 493 - <SidebarSectionHeader 494 - label="Conversations" 495 - actions={[{ label: '+ New', onClick: newConv }]} 496 - /> 497 - <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}> 498 - {convs.map((c) => ( 499 - <li 500 - key={c.id} 501 - onClick={() => setActiveConvId(c.id)} 502 - style={{ 503 - padding: 8, 504 - borderRadius: 4, 505 - cursor: 'pointer', 506 - background: 507 - c.id === activeConvId ? 'var(--mc-bg-sel)' : 'transparent', 508 - fontSize: 13, 509 - }} 510 - > 511 - {c.title || 'Untitled'} 512 - <div style={{ fontSize: 10, color: 'var(--mc-fg-faint)' }}> 513 - {Object.keys(c.turns).length} turns 514 - </div> 515 - </li> 516 - ))} 517 - {convs.length === 0 && ( 518 - <li style={{ color: 'var(--mc-fg-faint)', fontSize: 12, padding: 8 }}> 519 - No conversations yet. 520 - </li> 521 - )} 522 - </ul> 523 - </aside> 524 - </Panel> 525 - <PanelResizeHandle style={resizeHandleStyle} /> 526 - <Panel minSize={30}> 527 - <main 528 - style={{ 529 - height: '100%', 530 - display: 'flex', 531 - flexDirection: 'column', 532 - }} 533 - > 534 - <ProjectHeader 535 - rightPaneOpen={rightPaneOpen} 536 - onToggleRight={toggleRight} 537 - doc={doc} 538 - /> 539 - {activeConv ? ( 540 - <ConversationView 541 - conv={activeConv} 542 - members={doc.members} 543 - identity={identity} 544 - /> 545 - ) : ( 546 - <div style={{ margin: 'auto', color: 'var(--mc-fg-faint)' }}> 547 - Pick or create a conversation. 548 - </div> 549 - )} 550 - </main> 551 - </Panel> 552 - {rightPaneOpen && ( 553 - <> 554 - <PanelResizeHandle style={resizeHandleStyle} /> 555 - <Panel defaultSize={32} minSize={20}> 556 - <FileViewer 557 - file={selectedFile} 558 - isMain={selectedFileId === doc.mainFileId} 559 - members={doc.members} 560 - onClose={() => setRightPaneOpen(false)} 561 - /> 562 - </Panel> 563 - </> 564 - )} 565 - </PanelGroup> 566 - ) 567 - } 568 - 569 - function SidebarSectionHeader({ 570 - label, 571 - actions, 572 - }: { 573 - label: string 574 - actions?: Array<{ label: string; onClick: () => void }> 575 - }): React.ReactElement { 576 - return ( 577 - <div 578 - style={{ 579 - display: 'flex', 580 - justifyContent: 'space-between', 581 - alignItems: 'baseline', 582 - marginTop: 14, 583 - marginBottom: 6, 584 - borderBottom: '1px solid var(--mc-border-faint)', 585 - paddingBottom: 2, 586 - gap: 8, 587 - }} 588 - > 589 - <span style={{ fontSize: 11, fontWeight: 600, color: 'var(--mc-fg-muted)', textTransform: 'uppercase', letterSpacing: 0.4 }}> 590 - {label} 591 - </span> 592 - <div style={{ display: 'flex', gap: 8 }}> 593 - {actions?.map((a) => ( 594 - <button 595 - key={a.label} 596 - onClick={a.onClick} 597 - style={{ ...linkBtn, fontSize: 11 }} 598 - > 599 - {a.label} 600 - </button> 601 - ))} 602 - </div> 603 - </div> 604 - ) 605 - } 606 - 607 - function FileList({ 608 - doc, 609 - selectedFileId, 610 - onOpen, 611 - }: { 612 - doc: ProjectDoc 613 - selectedFileId: string | null 614 - onOpen: (id: string) => void 615 - }): React.ReactElement { 616 - const allFiles = Object.values(doc.files) 617 - const mainFile = doc.mainFileId ? doc.files[doc.mainFileId] : null 618 - const workingFiles = allFiles 619 - .filter((f) => f.id !== doc.mainFileId) 620 - .sort((a, b) => a.name.localeCompare(b.name)) 621 - 622 - const item = (f: ProjectFile, isMain: boolean): React.ReactElement => ( 623 - <li 624 - key={f.id} 625 - onClick={() => onOpen(f.id)} 626 - style={{ 627 - padding: '6px 8px', 628 - borderRadius: 4, 629 - cursor: 'pointer', 630 - background: f.id === selectedFileId ? 'var(--mc-bg-sel)' : 'transparent', 631 - fontSize: 13, 632 - display: 'flex', 633 - alignItems: 'center', 634 - gap: 6, 635 - }} 636 - > 637 - <span style={{ fontSize: 11, color: 'var(--mc-fg-faintest)', width: 12 }}> 638 - {isMain ? '★' : '•'} 639 - </span> 640 - <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> 641 - {f.name} 642 - </span> 643 - </li> 644 - ) 645 - 646 - return ( 647 - <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}> 648 - {mainFile && item(mainFile, true)} 649 - {workingFiles.map((f) => item(f, false))} 650 - {!mainFile && workingFiles.length === 0 && ( 651 - <li style={{ color: 'var(--mc-fg-faint)', fontSize: 12, padding: '4px 8px' }}> 652 - No files yet. 653 - </li> 654 - )} 655 - </ul> 656 - ) 657 - } 658 - 659 - function ProjectHeader({ 660 - rightPaneOpen, 661 - onToggleRight, 662 - doc, 663 - }: { 664 - rightPaneOpen: boolean 665 - onToggleRight: () => void 666 - doc: ProjectDoc 667 - }): React.ReactElement { 668 - const files = Object.values(doc.files) 669 - const totalChars = files.reduce((s, f) => s + f.content.length, 0) 670 - const estTokens = Math.round(totalChars / 4 / 100) / 10 // in 'k', 1 decimal 671 - return ( 672 - <div 673 - style={{ 674 - display: 'flex', 675 - justifyContent: 'space-between', 676 - alignItems: 'center', 677 - padding: '6px 12px', 678 - borderBottom: '1px solid var(--mc-border-faint)', 679 - background: 'var(--mc-bg-elev)', 680 - fontSize: 12, 681 - color: 'var(--mc-fg-muted)', 682 - }} 683 - > 684 - <span title={`${totalChars.toLocaleString()} characters across ${files.length} file(s)`}> 685 - Context: ~{estTokens}k tokens · {files.length} file{files.length === 1 ? '' : 's'} 686 - </span> 687 - <button onClick={onToggleRight} style={linkBtn}> 688 - {rightPaneOpen ? 'Hide files ›' : '‹ Show files'} 689 - </button> 690 - </div> 691 - ) 692 - } 693 - 694 - function FileViewer({ 695 - file, 696 - isMain, 697 - members, 698 - onClose, 699 - }: { 700 - file: ProjectFile | null 701 - isMain: boolean 702 - members: ProjectDoc['members'] 703 - onClose: () => void 704 - }): React.ReactElement { 705 - // Track the file we're editing so that switching files resets local state. 706 - const [mode, setMode] = useState<'view' | 'edit' | 'history'>('view') 707 - const [draft, setDraft] = useState('') 708 - // Remember which doc-content we initialized the draft from, so we can 709 - // detect when a peer wrote underneath us mid-edit. 710 - const [basedOn, setBasedOn] = useState<string>('') 711 - const [saving, setSaving] = useState(false) 712 - const [renaming, setRenaming] = useState(false) 713 - const [nameDraft, setNameDraft] = useState('') 714 - // While `followLive` is on, the textarea is read-only and mirrors 715 - // file.content as it changes (useful for watching the agent rewrite the 716 - // file). Local edits switch this off automatically. 717 - const [followLive, setFollowLive] = useState(false) 718 - 719 - // Re-init when we navigate to a different file. 720 - const fileId = file?.id ?? null 721 - useEffect(() => { 722 - setMode('view') 723 - setDraft(file?.content ?? '') 724 - setBasedOn(file?.content ?? '') 725 - setRenaming(false) 726 - setNameDraft(file?.name ?? '') 727 - setFollowLive(false) 728 - }, [fileId]) 729 - 730 - // While following live, mirror file.content into the displayed text on 731 - // every doc update. 732 - useEffect(() => { 733 - if (followLive && file) { 734 - setDraft(file.content) 735 - setBasedOn(file.content) 736 - } 737 - }, [followLive, file?.content]) 738 - 739 - if (!file) { 740 - return ( 741 - <PaneShell 742 - title="No file selected" 743 - onClose={onClose} 744 - > 745 - <div style={{ padding: 16, color: 'var(--mc-fg-faint)', fontSize: 13 }}> 746 - Pick a file from the left sidebar. 747 - </div> 748 - </PaneShell> 749 - ) 750 - } 751 - 752 - const commitRename = async (): Promise<void> => { 753 - const next = nameDraft.trim() 754 - if (!next || next === file.name) { 755 - setRenaming(false) 756 - setNameDraft(file.name) 757 - return 758 - } 759 - await window.mc.file.update(file.id, { name: next }) 760 - setRenaming(false) 761 - } 762 - const cancelRename = (): void => { 763 - setRenaming(false) 764 - setNameDraft(file.name) 765 - } 766 - 767 - const dirty = mode === 'edit' && draft !== basedOn 768 - const peerChanged = mode === 'edit' && file.content !== basedOn 769 - 770 - const save = async (): Promise<void> => { 771 - if (!dirty || saving) return 772 - setSaving(true) 773 - const r = await window.mc.file.update(file.id, { content: draft }) 774 - setSaving(false) 775 - if (r.ok) { 776 - setBasedOn(draft) 777 - setMode('view') 778 - } 779 - } 780 - 781 - const discard = (): void => { 782 - setDraft(file.content) 783 - setBasedOn(file.content) 784 - setMode('view') 785 - } 786 - 787 - const del = async (): Promise<void> => { 788 - if (!window.confirm(`Delete "${file.name}"?`)) return 789 - const r = await window.mc.file.delete(file.id) 790 - if (r.ok) onClose() 791 - else window.alert(r.error ?? 'delete failed') 792 - } 793 - 794 - const subtitle = 795 - `${isMain ? 'main file · ' : ''}updated by ${ 796 - members[file.updatedBy]?.displayName ?? file.updatedBy 797 - } ${formatRelative(file.updatedAt)}` 798 - 799 - const titleNode = renaming ? ( 800 - <input 801 - autoFocus 802 - value={nameDraft} 803 - onChange={(e) => setNameDraft(e.target.value)} 804 - onBlur={commitRename} 805 - onKeyDown={(e) => { 806 - if (e.key === 'Enter') commitRename() 807 - if (e.key === 'Escape') cancelRename() 808 - }} 809 - style={{ 810 - fontSize: 13, 811 - fontWeight: 500, 812 - padding: '2px 4px', 813 - width: '100%', 814 - boxSizing: 'border-box', 815 - }} 816 - /> 817 - ) : ( 818 - <span 819 - onClick={() => setRenaming(true)} 820 - title="Click to rename" 821 - style={{ cursor: 'text' }} 822 - > 823 - {file.name} 824 - </span> 825 - ) 826 - 827 - return ( 828 - <PaneShell title={titleNode} subtitle={subtitle} onClose={onClose}> 829 - <div 830 - style={{ 831 - display: 'flex', 832 - gap: 8, 833 - padding: '6px 12px', 834 - borderBottom: '1px solid var(--mc-border-faint)', 835 - background: 'var(--mc-bg-elev)', 836 - fontSize: 12, 837 - alignItems: 'center', 838 - }} 839 - > 840 - <button 841 - onClick={() => setMode('view')} 842 - style={{ ...linkBtn, fontWeight: mode === 'view' ? 600 : 400 }} 843 - > 844 - Preview 845 - </button> 846 - <button 847 - onClick={() => setMode('edit')} 848 - style={{ ...linkBtn, fontWeight: mode === 'edit' ? 600 : 400 }} 849 - > 850 - Edit 851 - </button> 852 - {file.history && file.history.length > 0 && ( 853 - <button 854 - onClick={() => setMode('history')} 855 - style={{ ...linkBtn, fontWeight: mode === 'history' ? 600 : 400 }} 856 - > 857 - History ({file.history.length}) 858 - </button> 859 - )} 860 - {mode === 'edit' && ( 861 - <> 862 - <span style={{ width: 1, height: 12, background: 'var(--mc-border)' }} /> 863 - <button 864 - onClick={save} 865 - disabled={!dirty || saving || followLive} 866 - style={linkBtn} 867 - > 868 - {saving ? 'Saving…' : 'Save'} 869 - </button> 870 - <button 871 - onClick={discard} 872 - disabled={!dirty || followLive} 873 - style={linkBtn} 874 - > 875 - Discard 876 - </button> 877 - <button 878 - onClick={() => setFollowLive((v) => !v)} 879 - style={{ 880 - ...linkBtn, 881 - fontWeight: followLive ? 600 : 400, 882 - }} 883 - title="Mirror live file content (read-only). Useful for watching the agent rewrite the file." 884 - > 885 - {followLive ? '● Following' : '○ Follow live'} 886 - </button> 887 - {peerChanged && !followLive && ( 888 - <> 889 - <button 890 - onClick={() => { 891 - setDraft(file.content) 892 - setBasedOn(file.content) 893 - }} 894 - style={linkBtn} 895 - title="Discard your edits and reload the current file content" 896 - > 897 - Reset to current 898 - </button> 899 - <span style={{ color: 'var(--mc-warning)', fontSize: 11 }}> 900 - ⚠ peer updated this file 901 - </span> 902 - </> 903 - )} 904 - </> 905 - )} 906 - {!isMain && ( 907 - <button 908 - onClick={del} 909 - style={{ ...linkBtn, marginLeft: 'auto', color: 'var(--mc-danger)' }} 910 - > 911 - Delete 912 - </button> 913 - )} 914 - </div> 915 - <div style={{ flex: 1, overflowY: 'auto' }}> 916 - {mode === 'view' && ( 917 - <div className="mc-md" style={{ padding: 16 }}> 918 - <ReactMarkdown remarkPlugins={[remarkGfm]}> 919 - {file.content} 920 - </ReactMarkdown> 921 - </div> 922 - )} 923 - {mode === 'edit' && ( 924 - <textarea 925 - value={draft} 926 - readOnly={followLive} 927 - onChange={(e) => setDraft(e.target.value)} 928 - onKeyDown={(e) => { 929 - if ((e.metaKey || e.ctrlKey) && e.key === 's') { 930 - e.preventDefault() 931 - save() 932 - } 933 - }} 934 - style={{ 935 - width: '100%', 936 - height: '100%', 937 - boxSizing: 'border-box', 938 - border: 'none', 939 - outline: 'none', 940 - padding: 14, 941 - fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace', 942 - fontSize: 13, 943 - resize: 'none', 944 - background: followLive ? 'var(--mc-bg-elev)' : 'var(--mc-bg)', 945 - color: 'var(--mc-fg)', 946 - cursor: followLive ? 'default' : 'text', 947 - }} 948 - /> 949 - )} 950 - {mode === 'history' && ( 951 - <HistoryList 952 - file={file} 953 - members={members} 954 - onRestore={async (content) => { 955 - await window.mc.file.update(file.id, { content }) 956 - setMode('view') 957 - }} 958 - /> 959 - )} 960 - </div> 961 - </PaneShell> 962 - ) 963 - } 964 - 965 - function HistoryList({ 966 - file, 967 - members, 968 - onRestore, 969 - }: { 970 - file: ProjectFile 971 - members: ProjectDoc['members'] 972 - onRestore: (content: string) => Promise<void> | void 973 - }): React.ReactElement { 974 - const entries = [...(file.history ?? [])].reverse() // newest first 975 - if (entries.length === 0) { 976 - return ( 977 - <div style={{ padding: 16, color: 'var(--mc-fg-faint)', fontSize: 13 }}> 978 - No prior versions. 979 - </div> 980 - ) 981 - } 982 - return ( 983 - <div style={{ padding: 12 }}> 984 - <div style={{ fontSize: 11, color: 'var(--mc-fg-muted)', marginBottom: 8 }}> 985 - Most recent first. Up to 5 versions are kept. 986 - </div> 987 - {entries.map((h, i) => ( 988 - <div 989 - key={i} 990 - style={{ 991 - border: '1px solid var(--mc-border)', 992 - borderRadius: 6, 993 - padding: 10, 994 - marginBottom: 10, 995 - }} 996 - > 997 - <div 998 - style={{ 999 - display: 'flex', 1000 - justifyContent: 'space-between', 1001 - alignItems: 'center', 1002 - fontSize: 11, 1003 - color: 'var(--mc-fg-muted)', 1004 - marginBottom: 6, 1005 - }} 1006 - > 1007 - <span> 1008 - {members[h.updatedBy]?.displayName ?? h.updatedBy} ·{' '} 1009 - {formatRelative(h.updatedAt)} · {h.content.length} chars 1010 - </span> 1011 - <button 1012 - onClick={() => onRestore(h.content)} 1013 - style={linkBtn} 1014 - title="Replace current content with this version" 1015 - > 1016 - Restore 1017 - </button> 1018 - </div> 1019 - <pre 1020 - style={{ 1021 - maxHeight: 200, 1022 - overflow: 'auto', 1023 - margin: 0, 1024 - fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace', 1025 - fontSize: 12, 1026 - background: 'var(--mc-bg-card)', 1027 - padding: 8, 1028 - borderRadius: 4, 1029 - whiteSpace: 'pre-wrap', 1030 - }} 1031 - > 1032 - {h.content.slice(0, 1200)} 1033 - {h.content.length > 1200 ? '\n…' : ''} 1034 - </pre> 1035 - </div> 1036 - ))} 1037 - </div> 1038 - ) 1039 - } 1040 - 1041 - function PaneShell({ 1042 - title, 1043 - subtitle, 1044 - onClose, 1045 - children, 1046 - }: { 1047 - title: React.ReactNode 1048 - subtitle?: string 1049 - onClose: () => void 1050 - children: React.ReactNode 1051 - }): React.ReactElement { 1052 - return ( 1053 - <aside 1054 - style={{ 1055 - height: '100%', 1056 - boxSizing: 'border-box', 1057 - display: 'flex', 1058 - flexDirection: 'column', 1059 - }} 1060 - > 1061 - <div 1062 - style={{ 1063 - display: 'flex', 1064 - justifyContent: 'space-between', 1065 - alignItems: 'center', 1066 - padding: '6px 12px', 1067 - borderBottom: '1px solid var(--mc-border-faint)', 1068 - background: 'var(--mc-bg-elev)', 1069 - }} 1070 - > 1071 - <div style={{ flex: 1, minWidth: 0 }}> 1072 - <div style={{ fontSize: 13, fontWeight: 500 }}>{title}</div> 1073 - {subtitle && ( 1074 - <div style={{ fontSize: 10, color: 'var(--mc-fg-faint)' }}>{subtitle}</div> 1075 - )} 1076 - </div> 1077 - <button onClick={onClose} style={linkBtn}> 1078 - × 1079 - </button> 1080 - </div> 1081 - {children} 1082 - </aside> 1083 - ) 1084 - } 1085 - 1086 - function formatRelative(ts: number): string { 1087 - const delta = Date.now() - ts 1088 - if (delta < 60_000) return 'just now' 1089 - if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m ago` 1090 - if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)}h ago` 1091 - return `${Math.floor(delta / 86_400_000)}d ago` 1092 - } 1093 - 1094 - const resizeHandleStyle: React.CSSProperties = { 1095 - width: 4, 1096 - background: 'var(--mc-resize)', 1097 - cursor: 'col-resize', 1098 - } 1099 - 1100 - function ConversationView({ 1101 - conv, 1102 - members, 1103 - identity, 1104 - }: { 1105 - conv: Conversation 1106 - members: ProjectDoc['members'] 1107 - identity: Identity 1108 - }): React.ReactElement { 1109 - const [text, setText] = useState('') 1110 - // Which leaf of the DAG is currently displayed. When new peer turns arrive 1111 - // we follow the latest leaf unless the user has explicitly picked a branch. 1112 - const [selectedLeafId, setSelectedLeafId] = useState<string | null>(null) 1113 - // Reset selection when switching conversations. 1114 - useEffect(() => { 1115 - setSelectedLeafId(null) 1116 - }, [conv.id]) 1117 - 1118 - const effectiveLeafId = 1119 - selectedLeafId && conv.turns[selectedLeafId] 1120 - ? selectedLeafId 1121 - : latestLeafId(conv) 1122 - const linear = effectiveLeafId ? linearizeFromLeaf(conv, effectiveLeafId) : [] 1123 - 1124 - // Track in-flight stream from this client to disable send while waiting. 1125 - // Peers' in-flight streams are visible via turn.status === 'streaming' but 1126 - // don't block local sending. 1127 - const [localSending, setLocalSending] = useState(false) 1128 - 1129 - const send = async (): Promise<void> => { 1130 - if (!text.trim() || localSending) return 1131 - setLocalSending(true) 1132 - const t = text 1133 - setText('') 1134 - try { 1135 - await window.mc.turn.send(conv.id, t) 1136 - } finally { 1137 - setLocalSending(false) 1138 - } 1139 - } 1140 - 1141 - return ( 1142 - <> 1143 - <div style={{ flex: 1, overflowY: 'auto', padding: 20 }}> 1144 - {linear.map((turn, i) => { 1145 - const isStreaming = turn.status === 'streaming' 1146 - const isError = turn.status === 'error' 1147 - // Branch nav: if `turn` has multiple children in the DAG, show a 1148 - // selector between this turn and the next one in the chain. 1149 - const children = childrenOf(conv, turn.id) 1150 - const nextInChain = linear[i + 1] 1151 - const branchIdx = nextInChain 1152 - ? children.findIndex((c) => c.id === nextInChain.id) 1153 - : -1 1154 - return ( 1155 - <React.Fragment key={turn.id}> 1156 - <div 1157 - style={{ 1158 - marginBottom: 16, 1159 - padding: 12, 1160 - background: 1161 - turn.role === 'user' 1162 - ? 'var(--mc-bg-card)' 1163 - : isError 1164 - ? 'var(--mc-bg-card-err)' 1165 - : 'var(--mc-bg-card-asst)', 1166 - borderRadius: 6, 1167 - }} 1168 - > 1169 - <div 1170 - style={{ fontSize: 11, color: 'var(--mc-fg-muted)', marginBottom: 4 }} 1171 - > 1172 - {turn.role === 'user' 1173 - ? `${members[turn.author]?.displayName ?? turn.author}${ 1174 - turn.author === identity.userId ? ' (you)' : '' 1175 - }` 1176 - : `Claude — billed to ${ 1177 - members[turn.author]?.displayName ?? turn.author 1178 - }${isStreaming ? ' · streaming' : ''}`} 1179 - </div> 1180 - {turn.role === 'assistant' ? ( 1181 - <div className="mc-md"> 1182 - <ReactMarkdown remarkPlugins={[remarkGfm]}> 1183 - {turn.text} 1184 - </ReactMarkdown> 1185 - {isStreaming && ( 1186 - <span 1187 - style={{ 1188 - display: 'inline-block', 1189 - marginLeft: 2, 1190 - animation: 'mc-blink 1s steps(2) infinite', 1191 - }} 1192 - > 1193 - 1194 - </span> 1195 - )} 1196 - </div> 1197 - ) : ( 1198 - <div style={{ whiteSpace: 'pre-wrap' }}>{turn.text}</div> 1199 - )} 1200 - </div> 1201 - {children.length > 1 && branchIdx >= 0 && ( 1202 - <BranchNav 1203 - total={children.length} 1204 - index={branchIdx} 1205 - onPick={(newIdx) => { 1206 - const pickedChild = children[newIdx] 1207 - if (pickedChild) { 1208 - setSelectedLeafId( 1209 - latestLeafInSubtree(conv, pickedChild.id), 1210 - ) 1211 - } 1212 - }} 1213 - /> 1214 - )} 1215 - </React.Fragment> 1216 - ) 1217 - })} 1218 - </div> 1219 - <div 1220 - style={{ 1221 - borderTop: '1px solid var(--mc-border)', 1222 - padding: 12, 1223 - display: 'flex', 1224 - gap: 8, 1225 - }} 1226 - > 1227 - <textarea 1228 - value={text} 1229 - onChange={(e) => setText(e.target.value)} 1230 - onKeyDown={(e) => { 1231 - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) send() 1232 - }} 1233 - placeholder="Message Claude (Cmd/Ctrl-Enter to send)" 1234 - style={{ flex: 1, padding: 8, minHeight: 60 }} 1235 - /> 1236 - <button 1237 - onClick={send} 1238 - disabled={localSending || !text.trim()} 1239 - style={buttonStyle} 1240 - > 1241 - Send 1242 - </button> 1243 - </div> 1244 - <style>{` 1245 - @keyframes mc-blink { to { visibility: hidden; } } 1246 - .mc-md { line-height: 1.5; } 1247 - .mc-md > :first-child { margin-top: 0; } 1248 - .mc-md > :last-child { margin-bottom: 0; } 1249 - .mc-md p { margin: 0.6em 0; } 1250 - .mc-md h1, .mc-md h2, .mc-md h3, .mc-md h4 { margin: 1em 0 0.4em; line-height: 1.25; } 1251 - .mc-md h1 { font-size: 1.5em; } 1252 - .mc-md h2 { font-size: 1.3em; } 1253 - .mc-md h3 { font-size: 1.15em; } 1254 - .mc-md h4 { font-size: 1em; } 1255 - .mc-md ul, .mc-md ol { margin: 0.5em 0; padding-left: 1.6em; } 1256 - .mc-md li { margin: 0.2em 0; } 1257 - .mc-md li > p { margin: 0.2em 0; } 1258 - .mc-md code { 1259 - font-family: ui-monospace, "SF Mono", Menlo, monospace; 1260 - font-size: 0.92em; 1261 - background: var(--mc-bg-code-inline); 1262 - padding: 0.1em 0.35em; 1263 - border-radius: 3px; 1264 - } 1265 - .mc-md pre { 1266 - background: var(--mc-bg-pre); 1267 - color: var(--mc-fg-pre); 1268 - padding: 12px 14px; 1269 - border-radius: 6px; 1270 - overflow-x: auto; 1271 - margin: 0.7em 0; 1272 - } 1273 - .mc-md pre code { 1274 - background: transparent; 1275 - padding: 0; 1276 - color: inherit; 1277 - font-size: 0.9em; 1278 - } 1279 - .mc-md blockquote { 1280 - margin: 0.6em 0; 1281 - padding-left: 12px; 1282 - border-left: 3px solid var(--mc-border-quote); 1283 - color: var(--mc-fg-muted); 1284 - } 1285 - .mc-md table { 1286 - border-collapse: collapse; 1287 - margin: 0.6em 0; 1288 - } 1289 - .mc-md th, .mc-md td { 1290 - border: 1px solid var(--mc-border); 1291 - padding: 4px 8px; 1292 - text-align: left; 1293 - } 1294 - .mc-md th { background: var(--mc-bg-th); } 1295 - .mc-md a { color: var(--mc-link); } 1296 - .mc-md hr { border: none; border-top: 1px solid var(--mc-border); margin: 1em 0; } 1297 - `}</style> 1298 - </> 1299 - ) 1300 - } 1301 - 1302 - // Walk back from a leaf turn to the root; return chronological order. 1303 - function linearizeFromLeaf(conv: Conversation, leafId: string): Turn[] { 1304 - const chain: Turn[] = [] 1305 - let cur: string | null = leafId 1306 - while (cur) { 1307 - const t: Turn | undefined = conv.turns[cur] 1308 - if (!t) break 1309 - chain.push(t) 1310 - cur = t.parentId 1311 - } 1312 - return chain.reverse() 1313 - } 1314 - 1315 - // The most recent leaf turn id, or null if the conversation is empty. 1316 - function latestLeafId(conv: Conversation): string | null { 1317 - const turns = Object.values(conv.turns) 1318 - if (turns.length === 0) return null 1319 - const hasChild = new Set<string>() 1320 - for (const t of turns) if (t.parentId) hasChild.add(t.parentId) 1321 - const leaves = turns 1322 - .filter((t) => !hasChild.has(t.id)) 1323 - .sort((a, b) => b.createdAt - a.createdAt) 1324 - return leaves[0]?.id ?? null 1325 - } 1326 - 1327 - // Walk down from a turn into the subtree, picking the latest child at each 1328 - // step, until we hit a leaf. Used when the user switches branches: we want 1329 - // to jump to the tip of the chosen branch. 1330 - function latestLeafInSubtree(conv: Conversation, rootId: string): string { 1331 - let cur = rootId 1332 - while (true) { 1333 - const kids = childrenOf(conv, cur) 1334 - if (kids.length === 0) return cur 1335 - const latest = kids.reduce((a, b) => 1336 - a.createdAt > b.createdAt ? a : b, 1337 - ) 1338 - cur = latest.id 1339 - } 1340 - } 1341 - 1342 - function childrenOf(conv: Conversation, parentId: string): Turn[] { 1343 - return Object.values(conv.turns) 1344 - .filter((t) => t.parentId === parentId) 1345 - .sort((a, b) => a.createdAt - b.createdAt) 1346 - } 1347 - 1348 - function BranchNav({ 1349 - total, 1350 - index, 1351 - onPick, 1352 - }: { 1353 - total: number 1354 - index: number 1355 - onPick: (newIndex: number) => void 1356 - }): React.ReactElement { 1357 - const wrap = (n: number): number => ((n % total) + total) % total 1358 - return ( 1359 - <div 1360 - style={{ 1361 - display: 'flex', 1362 - alignItems: 'center', 1363 - gap: 6, 1364 - margin: '-8px 0 16px', 1365 - fontSize: 11, 1366 - color: 'var(--mc-fg-muted)', 1367 - }} 1368 - > 1369 - <button 1370 - onClick={() => onPick(wrap(index - 1))} 1371 - style={{ ...linkBtn, fontSize: 14, lineHeight: 1 }} 1372 - title="Previous branch" 1373 - > 1374 - 1375 - </button> 1376 - <span> 1377 - branch {index + 1} / {total} 1378 - </span> 1379 - <button 1380 - onClick={() => onPick(wrap(index + 1))} 1381 - style={{ ...linkBtn, fontSize: 14, lineHeight: 1 }} 1382 - title="Next branch" 1383 - > 1384 - 1385 - </button> 1386 - </div> 1387 - ) 1388 - } 1389 - 1390 - const inputStyle: React.CSSProperties = { 1391 - display: 'block', 1392 - width: '100%', 1393 - padding: 8, 1394 - marginBottom: 8, 1395 - fontSize: 14, 1396 - boxSizing: 'border-box', 1397 - } 1398 - const buttonStyle: React.CSSProperties = { 1399 - padding: '8px 16px', 1400 - fontSize: 14, 1401 - cursor: 'pointer', 1402 - } 1403 - const linkBtn: React.CSSProperties = { 1404 - background: 'none', 1405 - border: 'none', 1406 - color: 'var(--mc-link)', 1407 - cursor: 'pointer', 1408 - padding: 0, 1409 - fontSize: 12, 1410 - }
src/renderer/src/main.tsx src/main.tsx
+114 -72
src/shared/schema.ts
··· 1 - // Schema shared between main and renderer. 2 - // Plain TS types — the Automerge doc has the same shape. 3 - // Conventions: 4 - // - Fields marked `immutable` in comments are set once at creation, never rewritten. 5 - // - Mutable fields use last-writer-wins by relying on Automerge's default semantics. 6 - // - Sets are modeled as `{ [id: string]: true }` to avoid array-merge surprises. 1 + // Types shared between server and browser. SQL rows are converted to / from 2 + // these shapes at the persistence boundary. 7 3 8 4 export type UserId = string // sha256(email).slice(0, 16), hex 9 5 export type Timestamp = number // Date.now() 10 6 7 + export interface Identity { 8 + userId: UserId 9 + email: string 10 + displayName: string 11 + } 12 + 13 + export interface ProjectMeta { 14 + id: string 15 + name: string 16 + role?: 'owner' | 'member' 17 + } 18 + 11 19 export interface Member { 12 - // immutable 20 + userId: UserId 13 21 email: string 14 22 displayName: string 23 + role: 'owner' | 'member' 15 24 joinedAt: Timestamp 16 - // mutable (LWW) 17 - role: 'owner' | 'member' 18 25 lastSeen: Timestamp 19 26 } 20 27 28 + export interface Conversation { 29 + id: string 30 + title: string 31 + archived: boolean 32 + createdAt: Timestamp 33 + createdBy: UserId 34 + } 35 + 21 36 export interface Turn { 22 - // Immutable identity. 23 37 id: string 38 + convId: string 24 39 parentId: string | null 25 40 author: UserId 26 41 role: 'user' | 'assistant' 27 42 createdAt: Timestamp 28 - 29 - // `text` is mutable while `status === 'streaming'` (the originating client 30 - // grows it as tokens arrive). Once status flips to 'complete' or 'error', 31 - // it is never rewritten. User turns omit status and are always complete. 32 43 text: string 33 44 status?: 'streaming' | 'complete' | 'error' 34 - 35 - // Assistant-only metadata, set on completion. 36 45 modelId?: string 37 46 inputTokens?: number 38 47 outputTokens?: number ··· 40 49 } 41 50 42 51 export interface ProjectFile { 43 - // Immutable 44 52 id: string 45 - createdBy: UserId 46 - createdAt: Timestamp 47 - // Mutable (LWW). For v1 the whole content overwrites on each save — fine 48 - // for an agent-maintained doc that's edited rarely. To support real 49 - // collaborative editing later, upgrade `content` to Automerge.Text. 50 53 name: string 51 54 content: string 52 - updatedBy: UserId 53 - updatedAt: Timestamp 54 - // Audit trail: the last N versions of `content` before the current one. 55 - // Capped to MAX_FILE_HISTORY entries (oldest dropped). Optional for 56 - // backward-compat with files written by older schema. 57 - history?: Array<{ 58 - content: string 59 - updatedBy: UserId 60 - updatedAt: Timestamp 61 - }> 62 - } 63 - 64 - export interface Conversation { 65 - // immutable 66 - id: string 67 55 createdAt: Timestamp 68 56 createdBy: UserId 69 - // mutable 70 - title: string 71 - archived: boolean 72 - // grow-only 73 - rootTurnIds: { [id: string]: true } 74 - turns: { [id: string]: Turn } 57 + updatedAt: Timestamp 58 + updatedBy: UserId 59 + binary?: { 60 + sha256: string 61 + mimeType: string 62 + sizeBytes: number 63 + } 75 64 } 76 65 77 - export interface ProjectDoc { 78 - // immutable 79 - id: string 80 - createdAt: Timestamp 81 - createdBy: UserId 82 - // mutable (LWW) 83 - name: string 84 - description: string 85 - systemPrompt: string 86 - defaultModel: string 87 - // grow-only maps 88 - members: { [userId: string]: Member } 89 - conversations: { [convId: string]: Conversation } 90 - files: { [fileId: string]: ProjectFile } 91 - // The single "main" file — auto-created on first open if absent. Holds the 92 - // project's running summary, facts, decisions; agent is expected to keep 93 - // this updated. Other files in `files` are "working files". 94 - mainFileId: string | null 66 + export interface FileHistoryEntry { 67 + ver: number 68 + content: string 69 + updatedAt: Timestamp 70 + updatedBy: UserId 95 71 } 96 72 97 - // Non-CRDT types used over IPC. 73 + // Snapshot pushed on WebSocket subscribe. Everything the renderer needs to 74 + // render a project's UI without further round trips. 75 + export interface ProjectSnapshot { 76 + project: { 77 + id: string 78 + name: string 79 + description: string 80 + systemPrompt: string 81 + defaultModel: string 82 + mainFileId: string | null 83 + } 84 + members: Member[] 85 + files: ProjectFile[] 86 + conversations: Conversation[] 87 + turns: Turn[] 88 + } 98 89 99 - export interface Identity { 100 - email: string 101 - userId: UserId 102 - displayName: string 103 - } 90 + // Outbound WebSocket events (server → client). 91 + export type WsServerEvent = 92 + | { type: 'snapshot'; snapshot: ProjectSnapshot } 93 + | { type: 'conversation'; conversation: Conversation } 94 + | { type: 'turn'; turn: Turn } 95 + | { type: 'file'; file: ProjectFile } 96 + | { type: 'file:deleted'; fileId: string } 97 + | { type: 'main'; fileId: string | null } 98 + | { type: 'member'; member: Member } 99 + | { type: 'presence'; userId: UserId; ts: Timestamp } 100 + | { type: 'error'; message: string } 104 101 105 - export interface ProjectMeta { 106 - id: string 107 - name: string 108 - syncFolder: string // absolute local path 109 - } 102 + // Inbound WebSocket events (client → server). 103 + export type WsClientEvent = 104 + | { type: 'subscribe'; projectId: string } 105 + | { type: 'unsubscribe'; projectId: string } 106 + | { type: 'conversation:create'; projectId: string; title: string } 107 + | { 108 + type: 'conversation:update' 109 + projectId: string 110 + convId: string 111 + fields: Partial<Pick<Conversation, 'title' | 'archived'>> 112 + } 113 + | { 114 + type: 'turn:create' 115 + projectId: string 116 + convId: string 117 + turn: Omit<Turn, 'convId'> 118 + } 119 + | { 120 + type: 'turn:update' 121 + projectId: string 122 + turnId: string 123 + fields: Partial< 124 + Pick< 125 + Turn, 126 + 'text' | 'status' | 'modelId' | 'inputTokens' | 'outputTokens' 127 + > 128 + > 129 + } 130 + | { 131 + type: 'file:create' 132 + projectId: string 133 + file: Pick<ProjectFile, 'id' | 'name' | 'content'> & { 134 + binary?: ProjectFile['binary'] 135 + } 136 + } 137 + | { 138 + type: 'file:update' 139 + projectId: string 140 + fileId: string 141 + fields: Partial<Pick<ProjectFile, 'name' | 'content'>> 142 + } 143 + | { 144 + type: 'file:patch' 145 + projectId: string 146 + fileId: string 147 + patches: Array<{ search: string; replace: string }> 148 + } 149 + | { type: 'file:delete'; projectId: string; fileId: string } 150 + | { type: 'main:set'; projectId: string; fileId: string } 151 + | { type: 'presence'; projectId: string }
+1 -1
tsconfig.json
··· 1 1 { 2 2 "files": [], 3 3 "references": [ 4 - { "path": "./tsconfig.node.json" }, 4 + { "path": "./tsconfig.server.json" }, 5 5 { "path": "./tsconfig.web.json" } 6 6 ] 7 7 }
+3 -2
tsconfig.node.json tsconfig.server.json
··· 5 5 "moduleResolution": "Bundler", 6 6 "target": "ES2022", 7 7 "strict": true, 8 - "noUncheckedIndexedAccess": false, 9 8 "esModuleInterop": true, 10 9 "resolveJsonModule": true, 11 10 "skipLibCheck": true, 11 + "outDir": "dist-server", 12 + "rootDir": ".", 12 13 "types": ["node"] 13 14 }, 14 - "include": ["src/main/**/*", "src/preload/**/*", "src/shared/**/*", "electron.vite.config.ts"] 15 + "include": ["server/**/*", "src/shared/**/*", "vite.config.ts"] 15 16 }
+1 -1
tsconfig.web.json
··· 11 11 "skipLibCheck": true, 12 12 "types": [] 13 13 }, 14 - "include": ["src/renderer/**/*", "src/shared/**/*"] 14 + "include": ["src/**/*"] 15 15 }
+17
vite.config.ts
··· 1 + import { defineConfig } from 'vite' 2 + import react from '@vitejs/plugin-react' 3 + 4 + export default defineConfig({ 5 + plugins: [react()], 6 + server: { 7 + port: 5173, 8 + proxy: { 9 + '/api': 'http://localhost:3001', 10 + '/ws': { target: 'ws://localhost:3001', ws: true }, 11 + }, 12 + }, 13 + build: { 14 + outDir: 'dist-web', 15 + emptyOutDir: true, 16 + }, 17 + })