diff --git a/.cursor/.gitignore b/.cursor/.gitignore deleted file mode 100644 index 8bf7cc27..00000000 --- a/.cursor/.gitignore +++ /dev/null @@ -1 +0,0 @@ -plans/ diff --git a/.docs/architecture.md b/.docs/architecture.md deleted file mode 100644 index 0b290026..00000000 --- a/.docs/architecture.md +++ /dev/null @@ -1,143 +0,0 @@ -# Architecture - -Cafe Code runs as an **Electron desktop app** backed by a Node.js WebSocket server that wraps `codex app-server` (JSON-RPC over stdio) and serves the React renderer assets locally. - -``` -┌─────────────────────────────────┐ -│ Electron renderer (React/Vite) │ -│ wsTransport (state machine) │ -│ Typed push decode at boundary │ -└──────────┬──────────────────────┘ - │ ws://localhost:3773 -┌──────────▼──────────────────────┐ -│ apps/server (Node.js) │ -│ WebSocket + HTTP static server │ -│ ServerPushBus (ordered pushes) │ -│ ServerReadiness (startup gate) │ -│ OrchestrationEngine │ -│ ProviderService │ -│ CheckpointReactor │ -│ RuntimeReceiptBus │ -└──────────┬──────────────────────┘ - │ JSON-RPC over stdio -┌──────────▼──────────────────────┐ -│ codex app-server │ -└─────────────────────────────────┘ -``` - -## Components - -- **Electron renderer**: The React app renders session state, owns the client-side WebSocket transport, and treats typed push events as the boundary between server runtime details and UI state. - -- **Server**: `apps/server` is the main coordinator. It serves the renderer assets, accepts WebSocket requests, waits for startup readiness before welcoming clients, and sends all outbound pushes through a single ordered push path. - -- **Provider runtime**: `codex app-server` does the actual provider/session work. The server talks to it over JSON-RPC on stdio and translates those runtime events into the app's orchestration model. - -- **Background workers**: Long-running async flows such as runtime ingestion, command reaction, and checkpoint processing run as queue-backed workers. This keeps work ordered, reduces timing races, and gives tests a deterministic way to wait for the system to go idle. - -- **Runtime signals**: The server emits lightweight typed receipts when important async milestones finish, such as checkpoint capture, diff finalization, or a turn becoming fully quiescent. Tests and orchestration code wait on these signals instead of polling internal state. - -## Event Lifecycle - -### Startup and client connect - -```mermaid -sequenceDiagram - participant Browser - participant Transport as WsTransport - participant Server as wsServer - participant Layers as serverLayers - participant Ready as ServerReadiness - participant Push as ServerPushBus - - Browser->>Transport: Load app and open WebSocket - Transport->>Server: Connect - Server->>Layers: Start runtime services - Server->>Ready: Wait for startup barriers - Ready-->>Server: Ready - Server->>Push: Publish server.welcome - Push-->>Transport: Ordered welcome push - Transport-->>Browser: Hydrate initial state -``` - -1. The browser boots [`WsTransport`][1] and registers typed listeners in [`wsNativeApi`][2]. -2. The server accepts the connection in [`wsServer`][3] and brings up the runtime graph defined in [`serverLayers`][7]. -3. [`ServerReadiness`][4] waits until the key startup barriers are complete. -4. Once the server is ready, [`wsServer`][3] sends `server.welcome` from the contracts in [`ws.ts`][6] through [`ServerPushBus`][5]. -5. The browser receives that ordered push through [`WsTransport`][1], and [`wsNativeApi`][2] uses it to seed local client state. - -### User turn flow - -```mermaid -sequenceDiagram - participant Browser - participant Transport as WsTransport - participant Server as wsServer - participant Provider as ProviderService - participant Codex as codex app-server - participant Ingest as ProviderRuntimeIngestion - participant Engine as OrchestrationEngine - participant Push as ServerPushBus - - Browser->>Transport: Send user action - Transport->>Server: Typed WebSocket request - Server->>Provider: Route request - Provider->>Codex: JSON-RPC over stdio - Codex-->>Ingest: Provider runtime events - Ingest->>Engine: Normalize into orchestration events - Engine-->>Server: Domain events - Server->>Push: Publish orchestration.domainEvent - Push-->>Browser: Typed push -``` - -1. A user action in the browser becomes a typed request through [`WsTransport`][1] and the browser API layer in [`nativeApi`][12]. -2. [`wsServer`][3] decodes that request using the shared WebSocket contracts in [`ws.ts`][6] and routes it to the right service. -3. [`ProviderService`][8] starts or resumes a session and talks to `codex app-server` over JSON-RPC on stdio. -4. Provider-native events are pulled back into the server by [`ProviderRuntimeIngestion`][9], which converts them into orchestration events. -5. [`OrchestrationEngine`][10] persists those events, updates the read model, and exposes them as domain events. -6. [`wsServer`][3] pushes those updates to the browser through [`ServerPushBus`][5] on channels defined in [`orchestration.ts`][11]. - -### Async completion flow - -```mermaid -sequenceDiagram - participant Server as wsServer - participant Worker as Queue-backed workers - participant Cmd as ProviderCommandReactor - participant Checkpoint as CheckpointReactor - participant Receipt as RuntimeReceiptBus - participant Push as ServerPushBus - participant Browser - - Server->>Worker: Enqueue follow-up work - Worker->>Cmd: Process provider commands - Worker->>Checkpoint: Process checkpoint tasks - Checkpoint->>Receipt: Publish completion receipt - Cmd-->>Server: Produce orchestration changes - Checkpoint-->>Server: Produce orchestration changes - Server->>Push: Publish resulting state updates - Push-->>Browser: User-visible push -``` - -1. Some work continues after the initial request returns, especially in [`ProviderRuntimeIngestion`][9], [`ProviderCommandReactor`][13], and [`CheckpointReactor`][14]. -2. These flows run as queue-backed workers using [`DrainableWorker`][16], which helps keep side effects ordered and test synchronization deterministic. -3. When a milestone completes, the server emits a typed receipt on [`RuntimeReceiptBus`][15], such as checkpoint completion or turn quiescence. -4. Tests and orchestration code wait on those receipts instead of polling git state, projections, or timers. -5. Any user-visible state changes produced by that async work still go back through [`wsServer`][3] and [`ServerPushBus`][5]. - -[1]: ../apps/web/src/wsTransport.ts -[2]: ../apps/web/src/wsNativeApi.ts -[3]: ../apps/server/src/wsServer.ts -[4]: ../apps/server/src/wsServer/readiness.ts -[5]: ../apps/server/src/wsServer/pushBus.ts -[6]: ../packages/contracts/src/ws.ts -[7]: ../apps/server/src/serverLayers.ts -[8]: ../apps/server/src/provider/Layers/ProviderService.ts -[9]: ../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts -[10]: ../apps/server/src/orchestration/Layers/OrchestrationEngine.ts -[11]: ../packages/contracts/src/orchestration.ts -[12]: ../apps/web/src/nativeApi.ts -[13]: ../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts -[14]: ../apps/server/src/orchestration/Layers/CheckpointReactor.ts -[15]: ../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts -[16]: ../packages/shared/src/DrainableWorker.ts diff --git a/.docs/ci.md b/.docs/ci.md deleted file mode 100644 index 71f1db4e..00000000 --- a/.docs/ci.md +++ /dev/null @@ -1,6 +0,0 @@ -# CI quality gates - -- `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. -- `.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), and Windows (`x64`) desktop artifacts from a single `v*.*.*` tag and publishes one GitHub release. -- The release workflow auto-enables signing only when secrets are present: Apple credentials for macOS and Azure Trusted Signing credentials for Windows. Without secrets, it still releases unsigned artifacts. -- See `docs/release.md` for full release/signing setup checklist. diff --git a/.docs/codex-prerequisites.md b/.docs/codex-prerequisites.md deleted file mode 100644 index bb1b45c7..00000000 --- a/.docs/codex-prerequisites.md +++ /dev/null @@ -1,5 +0,0 @@ -# Codex prerequisites - -- Install Codex CLI so `codex` is on your PATH. -- Authenticate Codex before running Cafe Code (for example via API key or ChatGPT auth supported by Codex). -- Cafe Code starts the server via `codex app-server` per session. diff --git a/.docs/encyclopedia.md b/.docs/encyclopedia.md deleted file mode 100644 index 620db0a6..00000000 --- a/.docs/encyclopedia.md +++ /dev/null @@ -1,180 +0,0 @@ -# Encyclopedia - -This is a living glossary for Cafe Code. It explains what common terms mean in this codebase. - -## Table of contents - -- [Project and workspace](#project-and-workspace) -- [Thread timeline](#thread-timeline) -- [Orchestration](#orchestration) -- [Provider runtime](#provider-runtime) -- [Checkpointing](#checkpointing) - -## Concepts - -### Project and workspace - -#### Project - -The top-level workspace record in the app. In [the orchestration contracts][1], a project has a `workspaceRoot`, a title, and one or more threads. See [workspace-layout.md][2]. - -#### Workspace root - -The root filesystem path for a project. In [the orchestration model][1], it is the base directory for branches and optional worktrees. See [workspace-layout.md][2]. - -#### Worktree - -A Git worktree used as an isolated workspace for a thread. If a thread has a `worktreePath` in [the contracts][1], it runs there instead of in the main working tree. Git operations live in [GitCore.ts][3]. - -### Thread timeline - -#### Thread - -The main durable unit of conversation and workspace history. In [the orchestration contracts][1], a thread holds messages, activities, checkpoints, and session-related state. See [projector.ts][4]. - -#### Turn - -A single user-to-assistant work cycle inside a thread. It starts with user input and ends when follow-up work like checkpointing settles. See [the contracts][1], [ProviderRuntimeIngestion.ts][5], and [CheckpointReactor.ts][6]. - -#### Activity - -A user-visible log item attached to a thread. In [the contracts][1], activities cover important non-message events like approvals, tool actions, and failures. They are projected into thread state in [projector.ts][4]. - -### Orchestration - -Orchestration is the server-side domain layer that turns runtime activity into stable app state. The main entry point is [OrchestrationEngine.ts][7], with core logic in [decider.ts][8] and [projector.ts][4]. - -#### Aggregate - -The domain object a command or event belongs to. In [the contracts][1], that is usually `project` or `thread`. See [decider.ts][8]. - -#### Command - -A typed request to change domain state. In [the contracts][1], commands are validated in [commandInvariants.ts][9] and turned into events by [decider.ts][8]. -Examples include `thread.create`, `thread.turn.start`, and `thread.checkpoint.revert`. - -#### Domain Event - -A persisted fact that something already happened. In [the contracts][1], events are the source of truth, and [projector.ts][4] shows how they are applied. -Examples include `thread.created`, `thread.message-sent`, and `thread.turn-diff-completed`. - -#### Decider - -The pure orchestration logic that turns commands plus current state into events. The core implementation is in [decider.ts][8], with preconditions in [commandInvariants.ts][9]. - -#### Projection - -A read-optimized view derived from events. See [projector.ts][4], [ProjectionPipeline.ts][11], and [ProjectionSnapshotQuery.ts][10]. - -#### Projector - -The logic that applies domain events to the read model or projection tables. See [projector.ts][4] and [ProjectionPipeline.ts][11]. - -#### Read model - -The current materialized view of orchestration state. In [the contracts][1], it holds projects, threads, messages, activities, checkpoints, and session state. See [ProjectionSnapshotQuery.ts][10] and [OrchestrationEngine.ts][7]. - -#### Reactor - -A side-effecting service that handles follow-up work after events or runtime signals. Examples include [CheckpointReactor.ts][6], [ProviderCommandReactor.ts][12], and [ProviderRuntimeIngestion.ts][5]. - -#### Receipt - -A lightweight typed runtime signal emitted when an async milestone completes. See [RuntimeReceiptBus.ts][13]. -Examples include `checkpoint.baseline.captured`, `checkpoint.diff.finalized`, and `turn.processing.quiesced`, which are emitted by flows such as [CheckpointReactor.ts][6]. - -#### Quiesced - -"Quiesced" means a turn has gone quiet and stable. In [the receipt schema][13], it means the follow-up work has settled, including work in [CheckpointReactor.ts][6]. - -### Provider runtime - -The live backend agent implementation and its event stream. The main service is [ProviderService.ts][14], the adapter contract is [ProviderAdapter.ts][15], and the overview is in [provider-architecture.md][16]. - -#### Provider - -The backend agent runtime that actually performs work. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17]. - -#### Session - -The live provider-backed runtime attached to a thread. Session shape is in [the orchestration contracts][1], and lifecycle is managed in [ProviderService.ts][14]. - -#### Runtime mode - -The safety/access mode for a thread or session. In [the contracts][1], the main values are `approval-required` and `full-access`. See [runtime-modes.md][18]. - -#### Interaction mode - -The agent interaction style for a thread. In [the contracts][1], the main values are `default` and `plan`. See [runtime-modes.md][18]. - -#### Assistant delivery mode - -Controls how assistant text reaches the thread timeline. In [the contracts][1], `streaming` updates incrementally and `buffered` delivers a completed result. See [ProviderService.ts][14]. - -#### Snapshot - -A point-in-time view of state. The word is used in multiple layers, including orchestration, provider, and checkpointing. See [ProjectionSnapshotQuery.ts][10], [ProviderAdapter.ts][15], and [CheckpointStore.ts][19]. - -### Checkpointing - -Checkpointing captures workspace state over time so the app can diff turns and restore earlier points. The main pieces are [CheckpointStore.ts][19], [CheckpointDiffQuery.ts][20], and [CheckpointReactor.ts][6]. - -#### Checkpoint - -A saved snapshot of a thread workspace at a particular turn. In practice it is a hidden Git ref in [CheckpointStore.ts][19] plus a projected summary from [ProjectionCheckpoints.ts][21]. Capture and lifecycle work happen in [CheckpointReactor.ts][6]. - -#### Checkpoint ref - -The durable identifier for a filesystem checkpoint, stored as a Git ref. It is typed in [the contracts][1], constructed in [Utils.ts][22], and used by [CheckpointStore.ts][19]. - -#### Checkpoint baseline - -The starting checkpoint for diffing a thread timeline. This flow is surfaced through [RuntimeReceiptBus.ts][13], coordinated in [CheckpointReactor.ts][6], and supported by [Utils.ts][22]. - -#### Checkpoint diff - -The patch difference between two checkpoints. Query logic lives in [CheckpointDiffQuery.ts][20], diff parsing lives in [Diffs.ts][23], and finalization is coordinated by [CheckpointReactor.ts][6]. - -#### Turn diff - -The file patch and changed-file summary for one turn. It is usually computed in [CheckpointDiffQuery.ts][20], represented in [the contracts][1], and recorded into thread state by [projector.ts][4]. - -## Practical Shortcuts - -- If you see `requested`, think "intent recorded". -- If you see `completed`, think "result applied". -- If you see `receipt`, think "async milestone signal". -- If you see `checkpoint`, think "workspace snapshot for diff/restore". -- If you see `quiesced`, think "all relevant follow-up work has gone idle". - -## Related Docs - -- [architecture.md][24] -- [provider-architecture.md][16] -- [runtime-modes.md][18] -- [workspace-layout.md][2] - -[1]: ../packages/contracts/src/orchestration.ts -[2]: ./workspace-layout.md -[3]: ../apps/server/src/git/Layers/GitCore.ts -[4]: ../apps/server/src/orchestration/projector.ts -[5]: ../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts -[6]: ../apps/server/src/orchestration/Layers/CheckpointReactor.ts -[7]: ../apps/server/src/orchestration/Layers/OrchestrationEngine.ts -[8]: ../apps/server/src/orchestration/decider.ts -[9]: ../apps/server/src/orchestration/commandInvariants.ts -[10]: ../apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts -[11]: ../apps/server/src/orchestration/Layers/ProjectionPipeline.ts -[12]: ../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts -[13]: ../apps/server/src/orchestration/Services/RuntimeReceiptBus.ts -[14]: ../apps/server/src/provider/Layers/ProviderService.ts -[15]: ../apps/server/src/provider/Services/ProviderAdapter.ts -[16]: ./provider-architecture.md -[17]: ../apps/server/src/provider/Layers/CodexAdapter.ts -[18]: ./runtime-modes.md -[19]: ../apps/server/src/checkpointing/Services/CheckpointStore.ts -[20]: ../apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts -[21]: ../apps/server/src/persistence/Services/ProjectionCheckpoints.ts -[22]: ../apps/server/src/checkpointing/Utils.ts -[23]: ../apps/server/src/checkpointing/Diffs.ts -[24]: ./architecture.md diff --git a/.docs/provider-architecture.md b/.docs/provider-architecture.md deleted file mode 100644 index dc539a78..00000000 --- a/.docs/provider-architecture.md +++ /dev/null @@ -1,30 +0,0 @@ -# Provider architecture - -The Electron renderer communicates with the server via WebSocket using a simple JSON-RPC-style protocol: - -- **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` -- **Push events**: typed envelopes with `channel`, `sequence` (monotonic per connection), and channel-specific `data` - -Push channels: `server.welcome`, `server.configUpdated`, `terminal.event`, `orchestration.domainEvent`. Payloads are schema-validated at the transport boundary (`wsTransport.ts`). Decode failures produce structured `WsDecodeDiagnostic` with `code`, `reason`, and path info. - -Methods mirror the `NativeApi` interface defined in `@cafecode/contracts`: - -- `providers.startSession`, `providers.sendTurn`, `providers.interruptTurn` -- `providers.respondToRequest`, `providers.stopSession` -- `shell.openInEditor`, `server.getConfig` - -Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. - -## Client transport - -`wsTransport.ts` manages connection state: `connecting` → `open` → `reconnecting` → `closed` → `disposed`. Outbound requests are queued while disconnected and flushed on reconnect. Inbound pushes are decoded and validated at the boundary, then cached per channel. Subscribers can opt into `replayLatest` to receive the last push on subscribe. - -## Server-side orchestration layers - -Provider runtime events flow through queue-based workers: - -1. **ProviderRuntimeIngestion** — consumes provider runtime streams, emits orchestration commands -2. **ProviderCommandReactor** — reacts to orchestration intent events, dispatches provider calls -3. **CheckpointReactor** — captures git checkpoints on turn start/complete, publishes runtime receipts - -All three use `DrainableWorker` internally and expose `drain()` for deterministic test synchronization. diff --git a/.docs/quick-start.md b/.docs/quick-start.md deleted file mode 100644 index bfee1120..00000000 --- a/.docs/quick-start.md +++ /dev/null @@ -1,22 +0,0 @@ -# Quick start - -```bash -# Development (with hot reload) -bun run dev - -# Desktop development -bun run dev:desktop - -# Desktop development on an isolated port set -CAFE_CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop - -# Production -bun run build -bun run start - -# Build a shareable macOS .dmg (arm64 by default) -bun run dist:desktop:dmg - -# Or from any project directory after publishing: -npx cafe-code -``` diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md deleted file mode 100644 index 98306352..00000000 --- a/.docs/remote-architecture.md +++ /dev/null @@ -1,358 +0,0 @@ -# Remote Architecture - -This document describes the target architecture for first-class remote environments in Cafe Code. - -It is intentionally architecture-first. It does not define a complete implementation plan or user-facing rollout checklist. The goal is to establish the core model so remote support can be added without another broad rewrite. - -## Goals - -- Treat remote environments as first-class product primitives, not special cases. -- Support multiple ways to reach the same environment. -- Keep the Cafe Code server as the execution boundary. -- Let desktop and future clients share the same conceptual model. -- Avoid introducing a local control plane unless product pressure proves it is necessary. - -## Non-goals - -- Replacing the existing WebSocket server boundary with a custom transport protocol. -- Making SSH the only remote story. -- Syncing provider auth across machines. -- Shipping every access method in the first iteration. - -## High-level architecture - -Cafe Code already has a clean runtime boundary: the client talks to a Cafe Code server over HTTP/WebSocket, and the server owns orchestration, providers, terminals, git, and filesystem operations. - -Remote support should preserve that boundary. - -```text -┌──────────────────────────────────────────────┐ -│ Client (desktop / future clients) │ -│ │ -│ - known environments │ -│ - connection manager │ -│ - environment-aware routing │ -└───────────────┬──────────────────────────────┘ - │ - │ resolves one access endpoint - │ -┌───────────────▼──────────────────────────────┐ -│ Access method │ -│ │ -│ - direct ws / wss │ -│ - tunneled ws / wss │ -│ - desktop-managed ssh bootstrap + forward │ -└───────────────┬──────────────────────────────┘ - │ - │ connects to one Cafe Code server - │ -┌───────────────▼──────────────────────────────┐ -│ Execution environment = one Cafe Code server │ -│ │ -│ - environment identity │ -│ - provider state │ -│ - projects / threads / terminals │ -│ - git / filesystem / process runtime │ -└──────────────────────────────────────────────┘ -``` - -The important decision is that remoteness is expressed at the environment connection layer, not by splitting the Cafe Code runtime itself. - -## Domain model - -### ExecutionEnvironment - -An `ExecutionEnvironment` is one running Cafe Code server instance. - -It is the unit that owns: - -- provider availability and auth state -- model availability -- projects and threads -- terminal processes -- filesystem access -- git operations -- server settings - -It is identified by a stable `environmentId`. - -This is the shared cross-client primitive. Desktop and future clients should all reason about the same concept here. - -### KnownEnvironment - -A `KnownEnvironment` is a client-side saved entry for an environment the client knows how to reach. - -It is not server-authored. It is local to a device or client profile. - -Examples: - -- a saved LAN URL -- a saved public `wss://` endpoint -- a desktop-managed SSH host entry -- a saved tunneled environment - -A known environment may or may not know the target `environmentId` before first successful connect. - -### AccessEndpoint - -An `AccessEndpoint` is one concrete way to reach a known environment. - -This is the key abstraction that keeps SSH from taking over the model. - -A single environment may have many endpoints: - -- `wss://cafe-code.example.com` -- `ws://10.0.0.25:3773` -- a tunneled relay URL -- a desktop-managed SSH tunnel that resolves to a local forwarded WebSocket URL - -The environment stays the same. Only the access path changes. - -### AdvertisedEndpoint - -An `AdvertisedEndpoint` is a server or desktop-authored candidate endpoint for an environment. It is how the backend tells the client which URLs may be useful for pairing and reconnecting. - -`AdvertisedEndpoint` is deliberately narrower than the full access model: - -- it describes a concrete HTTP and WebSocket base URL pair -- it can mark the endpoint as default, available, or unavailable -- it includes reachability hints such as loopback, LAN, private, public, or tunnel - -Clients should treat advertised endpoints as hints, not as proof that a route works from the current device. The final connection attempt still decides whether the endpoint is reachable. - -The UI presents one default advertised endpoint in the network-access summary and keeps the rest behind an expandable advanced list. The default controls pairing QR codes and primary copy actions. Users can override it, but that override is a UI preference, not backend configuration. - -Persist the override by stable endpoint kind rather than raw URL whenever possible. For example, a LAN endpoint should be stored as the desktop LAN endpoint preference, not as `192.168.x.y`, because the address can change when the user switches networks. Provider endpoints should use provider-specific stable keys such as Tailscale IP or Tailscale MagicDNS HTTPS. Custom endpoints may fall back to their concrete identity. - -When no user default is saved, endpoint selection should prefer: - -1. explicitly default endpoints -2. non-loopback endpoints -3. loopback endpoints only for same-machine clients - -This keeps endpoint discovery centralized without making any one provider, such as Tailscale or a future tunnel service, part of the core environment model. - -### Endpoint providers - -Endpoint providers are add-ons that contribute advertised endpoints for the current environment. - -The provider boundary is intentionally outside the core environment model: - -- core owns `ExecutionEnvironment`, saved environments, pairing, and connection lifecycle -- providers discover or synthesize endpoints -- providers return normalized `AdvertisedEndpoint` records -- the UI and pairing logic select from those records without knowing provider-specific commands - -The first provider is Tailscale. It can discover Tailnet IP and MagicDNS addresses from the local machine and publish them as additional endpoint candidates. Future providers, such as a hosted tunnel service, should plug into the same shape rather than adding a separate remote environment path. - -Provider-specific confidence should remain a hint. A Tailscale endpoint still needs a successful browser or desktop connection before the client treats it as connected. - -### RepositoryIdentity - -`RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments. - -It is not used for routing. It is only used for UI grouping and correlation between local and remote clones of the same repository. - -### Workspace / Project - -The current `Project` model remains environment-local. - -That means: - -- a local clone and a remote clone are different projects -- they may share a `RepositoryIdentity` -- threads still bind to one project in one environment - -## Access methods - -Access methods answer one question: - -How does the client speak WebSocket to a Cafe Code server? - -They do not answer: - -- how the server got started -- who manages the server process -- whether the environment is local or remote - -### 1. Direct WebSocket access - -Examples: - -- `ws://10.0.0.15:3773` -- `wss://cafe-code.example.com` - -This is the base model and should be the first-class default. - -Benefits: - -- works for desktop and future clients -- no client-specific process management required -- best fit for self-managed remote Cafe Code deployments - -Browser security rules are part of this access method. A secure browser context can connect to `wss://` backends, but it cannot connect to plain `ws://` or `http://` LAN backends because that would be mixed content. - -### 2. Tunneled WebSocket access - -Examples: - -- public relay URLs -- private network relay URLs -- local tunnel products such as pipenet - -This is still direct WebSocket access from the client's perspective. The difference is that the route is mediated by a tunnel or relay. - -For Cafe Code, tunnels are best modeled as another `AccessEndpoint`, not as a different kind of environment. - -This is especially useful when: - -- the host is behind NAT -- inbound ports are unavailable -- mobile must reach a desktop-hosted environment -- a machine should be reachable without exposing raw LAN or public ports - -Tailscale-backed access sits here architecturally even though the current implementation is endpoint discovery rather than a Cafe Code-managed tunnel. It contributes private-network endpoints and lets the existing HTTP/WebSocket client path do the actual connection. - -### 3. Desktop-managed SSH access - -SSH is an access and launch helper, not a separate environment type. - -The desktop main process can use SSH to: - -- reach a machine -- probe it -- launch or reuse a remote Cafe Code server -- establish a local port forward - -After that, the renderer should still connect using an ordinary WebSocket URL against the forwarded local port. - -This keeps the renderer transport model consistent with every other access method. - -The desktop main process owns the SSH bridge because it can spawn local SSH processes, manage askpass prompts, write temporary launch scripts, and clean up forwards. The renderer receives a saved environment record and connects through the forwarded URL; it should not need SSH-specific RPC paths for normal environment traffic. - -## Launch methods - -Launch methods answer a different question: - -How does a Cafe Code server come to exist on the target machine? - -Launch and access should stay separate in the design. - -### 1. Pre-existing server - -The simplest launch method is no launch at all. - -The user or operator already runs Cafe Code on the target machine, and the client connects through a direct or tunneled WebSocket endpoint. - -This should be the first remote mode shipped because it validates the environment model with minimal extra machinery. - -### 2. Desktop-managed remote launch over SSH - -This is the main place where Zed is a useful reference. - -Useful ideas to borrow from Zed: - -- remote probing -- platform detection -- session directories with pid/log metadata -- reconnect-friendly launcher behavior -- desktop-owned connection UX - -What should be different in Cafe Code: - -- no custom stdio/socket proxy protocol between renderer and remote runtime -- no attempt to make the remote runtime look like an editor transport -- keep the final client-to-server connection as WebSocket - -The recommended Cafe Code flow is: - -1. Desktop connects over SSH. -2. Desktop probes the remote machine and verifies Cafe Code availability. -3. Desktop launches or reuses a remote Cafe Code server. -4. Desktop establishes local port forwarding. -5. Renderer connects to the forwarded WebSocket endpoint as a normal environment. - -The saved environment should remember that it was created by desktop SSH launch only for reconnect and lifecycle UX. That metadata should not change the server protocol or the environment identity model. - -Failure handling should be explicit: - -- SSH authentication failure should surface before any environment is saved -- remote launch failure should include remote logs or the launcher command output when available -- forwarded-port failure should leave the saved environment disconnected rather than falling back to an unrelated endpoint -- reconnect should attempt to restore the SSH bridge before reconnecting the normal WebSocket client - -### 3. Client-managed local publish - -This is the inverse of remote launch: a local Cafe Code server is already running, and the client publishes it through a tunnel. - -This is useful for: - -- exposing a desktop-hosted environment to mobile -- temporary remote access without changing router or firewall settings - -This is still a launch concern, not a new environment kind. - -## Why access and launch must stay separate - -These concerns are easy to conflate, but separating them prevents architectural drift. - -Examples: - -- A manually hosted Cafe Code server might be reached through direct `wss`. -- The same server might also be reachable through a tunnel. -- An SSH-managed server might be launched over SSH but then reached through forwarded WebSocket. -- A local desktop server might be published through a tunnel for mobile. - -In all of those cases, the `ExecutionEnvironment` is the same kind of thing. - -Only the launch and access paths differ. - -## Security model - -Remote support must assume that some environments will be reachable over untrusted networks. - -That means: - -- remote-capable environments should require explicit authentication -- tunnel exposure should not rely on obscurity -- client-saved endpoints should carry enough auth metadata to reconnect safely - -Cafe Code already supports a WebSocket auth token on the server. That should become a first-class part of environment access rather than remaining an incidental query parameter convention. - -For publicly reachable environments, authenticated access should be treated as required. - -Pairing should remain direct to the target backend. Pairing tokens should stay in the URL hash so they are not sent in HTTP requests, and clients must not imply that an HTTP backend is safe or reachable from an HTTPS browser context. - -## Relationship to Zed - -Zed is a useful reference implementation for managed remote launch and reconnect behavior. - -The relevant lessons are: - -- remote bootstrap should be explicit -- reconnect should be first-class -- connection UX belongs in the client shell -- runtime ownership should stay clearly on the remote host - -The important mismatch is transport shape. - -Zed needs a custom proxy/server protocol because its remote boundary sits below the editor and project runtime. - -Cafe Code should not copy that part. - -Cafe Code already has the right runtime boundary: - -- one Cafe Code server per environment -- ordinary HTTP/WebSocket between client and environment - -So Cafe Code should borrow Zed's launch discipline, not its transport protocol. - -## Recommended rollout - -1. First-class known environments and access endpoints. -2. Direct `ws` / `wss` remote environments. -3. Authenticated tunnel-backed environments. -4. Desktop-managed SSH launch and forwarding. -5. Multi-environment UI improvements after the base runtime path is proven. - -This ordering keeps the architecture network-first and transport-agnostic while still leaving room for richer managed remote flows. diff --git a/.docs/runtime-modes.md b/.docs/runtime-modes.md deleted file mode 100644 index d0f78082..00000000 --- a/.docs/runtime-modes.md +++ /dev/null @@ -1,6 +0,0 @@ -# Runtime modes - -Cafe Code has a global runtime mode switch in the chat toolbar: - -- **Full access** (default): starts sessions with `approvalPolicy: never` and `sandboxMode: danger-full-access`. -- **Supervised**: starts sessions with `approvalPolicy: on-request` and `sandboxMode: workspace-write`, then prompts in-app for command/file approvals. diff --git a/.docs/scripts.md b/.docs/scripts.md deleted file mode 100644 index 3e8f5b45..00000000 --- a/.docs/scripts.md +++ /dev/null @@ -1,42 +0,0 @@ -# Scripts - -- `bun run dev` — Starts contracts, server, and renderer in `turbo watch` mode. -- `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution). -- `bun run dev:web` — Starts just the Vite dev server for the Electron renderer. -- Dev commands default `CAFE_CODE_HOME` to `~/.cafecode/dev` while still falling back to an existing `~/.t3/dev` during migration. -- Override server CLI-equivalent flags from root dev commands with `--`, for example: - `bun run dev -- --base-dir ~/.t3-2` -- `bun run start` — Runs the production server (serves built renderer assets as static files). -- `bun run build` — Builds contracts, renderer, and server through Turbo. -- `bun run typecheck` — Strict TypeScript checks for all packages. -- `bun run test` — Runs workspace tests. -- `bun run dist:desktop:artifact -- --platform --target --arch ` — Builds a desktop artifact for a specific platform/target/arch. -- `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. -- `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. -- `bun run dist:desktop:linux` — Builds a Linux AppImage into `./release`. -- `bun run dist:desktop:win` — Builds a Windows NSIS installer into `./release`. - -## Desktop `.dmg` packaging notes - -- Default build is unsigned/not notarized for local sharing. -- The DMG build uses `assets/macos-icon-1024.png` as the production app icon source. -- Desktop production windows load the bundled UI from `cafecode://app/index.html` (not a `127.0.0.1` document URL). -- Desktop packaging includes `apps/server/dist` (the `cafe-code` backend) and starts it on loopback with an auth token for WebSocket/API traffic. -- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. -- To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` -- To allow code-signing/notarization when configured in CI/secrets, add: `--signed`. -- Windows `--signed` uses Azure Trusted Signing and expects: - `AZURE_TRUSTED_SIGNING_ENDPOINT`, `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME`, - `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME`, and `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME`. -- Azure authentication env vars are also required (for example service principal with secret): - `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`. - -## Running multiple dev instances - -Set `CAFE_CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together. - -- Default ports: server `3773`, renderer dev server `5733` -- Shifted ports: `base + offset` (offset is hashed from `CAFE_CODE_DEV_INSTANCE`) -- Example: `CAFE_CODE_DEV_INSTANCE=branch-a bun run dev:desktop` - -If you want full control instead of hashing, set `CAFE_CODE_PORT_OFFSET` to a numeric offset. diff --git a/.docs/workspace-layout.md b/.docs/workspace-layout.md deleted file mode 100644 index e86c4375..00000000 --- a/.docs/workspace-layout.md +++ /dev/null @@ -1,7 +0,0 @@ -# Workspace layout - -- `/apps/server`: Node.js WebSocket server. Wraps Codex app-server and serves the built renderer assets to Electron. -- `/apps/web`: React + Vite renderer. Session control, conversation, and provider event rendering. Connects to the server via WebSocket. -- `/apps/desktop`: Electron shell. Spawns a desktop-scoped `cafe-code` backend process and loads the renderer. -- `/packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. -- `/packages/shared`: Shared runtime utilities consumed by both server and renderer. Uses explicit subpath exports (e.g. `@cafecode/shared/git`, `@cafecode/shared/DrainableWorker`) — no barrel index. diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td deleted file mode 100644 index 73376110..00000000 --- a/.github/VOUCHED.td +++ /dev/null @@ -1,35 +0,0 @@ -# Trust list for this repository. -# -# External contributors listed here are treated as trusted by the vouch -# workflow. Collaborators with write access are automatically trusted and -# do not need to be duplicated in this file. -# -# Syntax: -# github:username -# -github:username reason for denouncement -# -# Keep entries sorted alphabetically. -github:adityavardhansharma -github:binbandit -github:chuks-qua -github:cursoragent -github:gbarros-dev -github:github-actions[bot] -github:hwanseoc -github:jamesx0416 -github:jasonLaster -github:JoeEverest -github:maria-rcks -github:nmggithub -github:Noojuno -github:notkainoa -github:PatrickBauer -github:realAhmedRoach -github:shiroyasha9 -github:Yash-Singh1 -github:eggfriedrice24 -github:Ymit24 -github:shivamhwp -github:jappyjan -github:justsomelegs -github:UtkarshUsername diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3329b1d..79a3914d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,15 +2,19 @@ name: CI on: pull_request: + branches: + - dev + - main push: branches: + - dev - main jobs: quality: - name: Format, Lint, Typecheck, Test, Browser Test, Build - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + name: Format, Lint, Typecheck, Test, Build + runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 @@ -35,14 +39,6 @@ jobs: restore-keys: | ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}- - - name: Cache Playwright browsers - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-playwright- - - name: Install dependencies run: bun install --frozen-lockfile @@ -58,42 +54,5 @@ jobs: - name: Test run: bun run test - - name: Install browser test runtime - run: | - cd apps/web - bunx playwright install --with-deps chromium - - - name: Browser test - run: bun run --cwd apps/web test:browser - - name: Build desktop pipeline run: bun run build:desktop - - - name: Verify preload bundle output - run: | - test -f apps/desktop/dist-electron/preload.cjs - grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs - - release_smoke: - name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: package.json - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Exercise release-only workflow steps - run: node scripts/release-smoke.ts diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml deleted file mode 100644 index c4abb08b..00000000 --- a/.github/workflows/pr-vouch.yml +++ /dev/null @@ -1,199 +0,0 @@ -name: PR Vouch - -on: - pull_request_target: - types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] - issue_comment: - types: [created] - push: - branches: - - main - paths: - - .github/VOUCHED.td - - .github/workflows/pr-vouch.yml - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - collect-targets: - name: Collect PR targets - runs-on: ubuntu-24.04 - outputs: - targets: ${{ steps.collect.outputs.targets }} - steps: - - id: collect - uses: actions/github-script@v8 - with: - script: | - if (context.eventName === "pull_request_target") { - const pr = context.payload.pull_request; - core.setOutput("targets", JSON.stringify([{ number: pr.number, user: pr.user.login }])); - return; - } - - if (context.eventName === "issue_comment") { - const issue = context.payload.issue; - const body = context.payload.comment?.body ?? ""; - if (!issue?.pull_request || !body.includes("/recheck-vouch")) { - core.setOutput("targets", "[]"); - return; - } - - core.setOutput( - "targets", - JSON.stringify([{ number: issue.number, user: issue.user.login }]), - ); - return; - } - - const pulls = await github.paginate(github.rest.pulls.list, { - owner: context.repo.owner, - repo: context.repo.repo, - state: "open", - per_page: 100, - }); - - const targets = pulls.map((pull) => ({ - number: pull.number, - user: pull.user.login, - })); - core.setOutput("targets", JSON.stringify(targets)); - - label: - name: Label PR ${{ matrix.target.number }} - needs: collect-targets - if: ${{ needs.collect-targets.outputs.targets != '[]' }} - runs-on: ubuntu-24.04 - concurrency: - group: pr-vouch-${{ matrix.target.number }} - cancel-in-progress: true - strategy: - fail-fast: false - matrix: - target: ${{ fromJson(needs.collect-targets.outputs.targets) }} - steps: - - id: vouch - name: Check PR author trust - uses: mitchellh/vouch/action/check-user@v1 - with: - user: ${{ matrix.target.user }} - allow-fail: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Sync PR labels - uses: actions/github-script@v8 - env: - PR_NUMBER: ${{ matrix.target.number }} - VOUCH_STATUS: ${{ steps.vouch.outputs.status }} - with: - script: | - const issueNumber = Number(process.env.PR_NUMBER); - const status = process.env.VOUCH_STATUS; - const managedLabels = [ - { - name: "vouch:trusted", - color: "1f883d", - description: "PR author is trusted by repo permissions or the VOUCHED list.", - }, - { - name: "vouch:unvouched", - color: "fbca04", - description: "PR author is not yet trusted in the VOUCHED list.", - }, - { - name: "vouch:denounced", - color: "d1242f", - description: "PR author is explicitly blocked by the VOUCHED list.", - }, - ]; - - const managedLabelNames = new Set(managedLabels.map((label) => label.name)); - - for (const label of managedLabels) { - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - }); - - if ( - existing.color !== label.color || - (existing.description ?? "") !== label.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - } - - const nextLabelName = - status === "denounced" - ? "vouch:denounced" - : ["bot", "collaborator", "vouched"].includes(status) - ? "vouch:trusted" - : "vouch:unvouched"; - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - - for (const label of currentLabels) { - if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { - continue; - } - - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: label.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } - - if (!currentLabels.some((label) => label.name === nextLabelName)) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [nextLabelName], - }); - } - - core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 2919f352..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,712 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*.*.*" - - "!v*-nightly.*" - schedule: - - cron: "0 */3 * * *" - workflow_dispatch: - inputs: - channel: - description: "Release channel" - required: false - default: stable - type: choice - options: - - stable - - nightly - version: - description: "Release version (for example 1.2.3 or v1.2.3)" - required: false - type: string - -permissions: - contents: read - id-token: none - -jobs: - check_changes: - name: Check for changes since last nightly - if: github.event_name == 'schedule' - runs-on: blacksmith-8vcpu-ubuntu-2404 - outputs: - has_changes: ${{ steps.check.outputs.has_changes }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - id: check - name: Compare HEAD to last nightly tag - run: | - last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) - if [[ -z "$last_nightly_tag" ]]; then - echo "No previous nightly tag found. Proceeding with release." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}") - head_sha=$(git rev-parse HEAD) - - if [[ "$last_nightly_sha" == "$head_sha" ]]; then - echo "No changes on main since last nightly release ($last_nightly_tag). Skipping." - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi - - preflight: - name: Preflight - needs: [check_changes] - if: | - !failure() && !cancelled() && - (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - outputs: - release_channel: ${{ steps.release_meta.outputs.release_channel }} - version: ${{ steps.release_meta.outputs.version }} - tag: ${{ steps.release_meta.outputs.tag }} - release_name: ${{ steps.release_meta.outputs.name }} - short_sha: ${{ steps.release_meta.outputs.short_sha }} - previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} - cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} - is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} - make_latest: ${{ steps.release_meta.outputs.make_latest }} - ref: ${{ github.sha }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: package.json - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - - name: Install dependencies - run: bun install --frozen-lockfile - - - id: release_meta - name: Resolve release version - shell: bash - env: - DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} - DISPATCH_VERSION: ${{ github.event.inputs.version }} - NIGHTLY_DATE: ${{ github.run_started_at }} - NIGHTLY_SHA: ${{ github.sha }} - NIGHTLY_RUN_NUMBER: ${{ github.run_number }} - run: | - if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then - nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" - - node scripts/resolve-nightly-release.ts \ - --date "$nightly_date" \ - --run-number "$NIGHTLY_RUN_NUMBER" \ - --sha "$NIGHTLY_SHA" \ - --github-output - - echo "release_channel=nightly" >> "$GITHUB_OUTPUT" - echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - echo "make_latest=false" >> "$GITHUB_OUTPUT" - else - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - raw="${DISPATCH_VERSION}" - if [[ -z "$raw" ]]; then - echo "workflow_dispatch stable releases require the version input." >&2 - exit 1 - fi - else - raw="${GITHUB_REF_NAME}" - fi - - version="${raw#v}" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid release version: $raw" >&2 - exit 1 - fi - - echo "release_channel=stable" >> "$GITHUB_OUTPUT" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=v$version" >> "$GITHUB_OUTPUT" - echo "name=Cafe Code v$version" >> "$GITHUB_OUTPUT" - echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - echo "make_latest=true" >> "$GITHUB_OUTPUT" - else - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - echo "make_latest=false" >> "$GITHUB_OUTPUT" - fi - fi - - - name: Lint - run: bun run lint - - - name: Typecheck - run: bun run typecheck - - - name: Test - run: bun run test - - - id: previous_tag - name: Resolve previous release tag - run: | - node scripts/resolve-previous-release-tag.ts \ - --channel "${{ steps.release_meta.outputs.release_channel }}" \ - --current-tag "${{ steps.release_meta.outputs.tag }}" \ - --github-output - - build: - name: Build ${{ matrix.label }} - needs: preflight - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - label: macOS arm64 - runner: blacksmith-12vcpu-macos-26 - platform: mac - target: dmg - arch: arm64 - - label: macOS x64 - runner: blacksmith-12vcpu-macos-26 - platform: mac - target: dmg - arch: x64 - - label: Linux x64 - runner: blacksmith-32vcpu-ubuntu-2404 - platform: linux - target: AppImage - arch: x64 - - label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 - platform: win - target: nsis - arch: x64 - # - label: Windows arm64 - # runner: windows-11-arm - # platform: win - # target: nsis - # arch: arm64 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: package.json - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - - name: Install Spectre-mitigated MSVC libs - if: matrix.platform == 'win' - shell: pwsh - run: | - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - $installPath = & $vswhere -products * -latest -property installationPath - $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" - $proc = Start-Process -FilePath $setupExe ` - -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` - -Wait -PassThru -NoNewWindow - if ($null -eq $proc -or $proc.ExitCode -ne 0) { - $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } - Write-Error "Visual Studio Installer failed with exit code $code" - exit $code - } - - - name: Prepare Azure Trusted Signing - if: matrix.platform == 'win' - shell: pwsh - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} - run: | - $ErrorActionPreference = "Stop" - - $requiredSecrets = @( - $env:AZURE_TENANT_ID, - $env:AZURE_CLIENT_ID, - $env:AZURE_CLIENT_SECRET, - $env:AZURE_TRUSTED_SIGNING_ENDPOINT, - $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, - $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, - $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME - ) - if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { - Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." - exit 0 - } - - try { - Install-PackageProvider ` - -Name NuGet ` - -MinimumVersion 2.8.5.201 ` - -Force ` - -Scope CurrentUser ` - -ErrorAction Stop - } catch { - Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" - } - - Install-Module ` - -Name TrustedSigning ` - -MinimumVersion 0.5.0 ` - -Force ` - -AllowClobber ` - -Repository PSGallery ` - -Scope CurrentUser ` - -ErrorAction Stop - - Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force - Get-Command Invoke-TrustedSigning -ErrorAction Stop - - $moduleRoots = @( - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") - ) - $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | - Where-Object { $_ -and (Test-Path $_) } | - Select-Object -Unique - "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV - - - name: Build desktop artifact - shell: bash - env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} - run: | - args=( - --platform "${{ matrix.platform }}" - --target "${{ matrix.target }}" - --arch "${{ matrix.arch }}" - --build-version "${{ needs.preflight.outputs.version }}" - --verbose - ) - - has_all() { - for value in "$@"; do - if [[ -z "$value" ]]; then - return 1 - fi - done - return 0 - } - - if [[ "${{ matrix.platform }}" == "mac" ]]; then - if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then - key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - echo "macOS signing enabled." - args+=(--signed) - else - echo "macOS signing disabled (missing one or more Apple signing secrets)." - fi - elif [[ "${{ matrix.platform }}" == "win" ]]; then - if has_all \ - "$AZURE_TENANT_ID" \ - "$AZURE_CLIENT_ID" \ - "$AZURE_CLIENT_SECRET" \ - "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ - "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ - "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ - "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then - echo "Windows signing enabled (Azure Trusted Signing)." - args+=(--signed) - else - echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." - fi - else - echo "Signing disabled for ${{ matrix.platform }}." - fi - - bun run dist:desktop:artifact -- "${args[@]}" - - - name: Collect release assets - shell: bash - run: | - set -euo pipefail - mkdir -p release-publish - - shopt -s nullglob - for pattern in \ - "release/*.dmg" \ - "release/*.zip" \ - "release/*.AppImage" \ - "release/*.exe" \ - "release/*.blockmap" \ - "release/*.yml"; do - for file in $pattern; do - cp "$file" release-publish/ - done - done - - if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then - shopt -s nullglob - for manifest in release-publish/*-mac.yml; do - mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml" - done - fi - - # Enable if Windows arm64 builds are enabled. - # Windows updater metadata is channel-specific (for example - # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the - # release job can merge matching arm64/x64 manifests back into one - # canonical manifest per channel. - # if [[ "${{ matrix.platform }}" == "win" ]]; then - # shopt -s nullglob - # for manifest in release-publish/*.yml; do - # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" - # done - # fi - - - name: Upload build artifacts - uses: actions/upload-artifact@v7 - with: - name: desktop-${{ matrix.platform }}-${{ matrix.arch }} - path: release-publish/* - if-no-files-found: error - - publish_cli: - name: Publish CLI to npm - needs: [preflight, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' }} - runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: read - id-token: write - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: package.json - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: package.json - registry-url: https://registry.npmjs.org - - - name: Install dependencies - run: bun install --frozen-lockfile --filter=cafe-code --filter=@cafecode/web --filter=@cafecode/scripts - - - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - - name: Build web package - run: bun --filter=@cafecode/web run build - - - name: Build CLI package - run: bun --filter=cafe-code run build - - - name: Publish CLI package - run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose - - release: - name: Publish GitHub Release - needs: [preflight, build, publish_cli] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - id: app_token - name: Mint release app token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: package.json - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - - name: Install dependencies - run: bun install --frozen-lockfile --filter=@cafecode/scripts - - - name: Download all desktop artifacts - uses: actions/download-artifact@v8 - with: - pattern: desktop-* - merge-multiple: true - path: release-assets - - - name: Merge macOS updater manifests - run: | - shopt -s nullglob - for x64_manifest in release-assets/*-mac-x64.yml; do - arm64_manifest="${x64_manifest%-x64.yml}.yml" - if [[ -f "$arm64_manifest" ]]; then - node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" - rm -f "$x64_manifest" - fi - done - - # - name: Merge Windows updater manifests - # run: | - # shopt -s nullglob - # found_windows_manifest=false - # for x64_manifest in release-assets/*-win-x64.yml; do - # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then - # continue - # fi - - # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" - # output_manifest="${x64_manifest/-win-x64.yml/.yml}" - # if [[ ! -f "$arm64_manifest" ]]; then - # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 - # exit 1 - # fi - - # found_windows_manifest=true - # node scripts/merge-update-manifests.ts --platform win \ - # "$arm64_manifest" \ - # "$x64_manifest" \ - # "$output_manifest" - # rm -f "$arm64_manifest" "$x64_manifest" - # done - - # if [[ "$found_windows_manifest" != true ]]; then - # echo "No Windows updater manifests found to merge." >&2 - # exit 1 - # fi - - - name: Publish release - if: needs.preflight.outputs.previous_tag != '' - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ needs.preflight.outputs.tag }} - target_commitish: ${{ needs.preflight.outputs.ref }} - name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: true - previous_tag: ${{ needs.preflight.outputs.previous_tag }} - prerelease: ${{ needs.preflight.outputs.is_prerelease }} - make_latest: ${{ needs.preflight.outputs.make_latest }} - files: | - release-assets/*.dmg - release-assets/*.zip - release-assets/*.AppImage - release-assets/*.exe - release-assets/*.blockmap - release-assets/*.yml - fail_on_unmatched_files: true - token: ${{ steps.app_token.outputs.token }} - - - name: Publish first release - if: needs.preflight.outputs.previous_tag == '' - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ needs.preflight.outputs.tag }} - target_commitish: ${{ needs.preflight.outputs.ref }} - name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: true - prerelease: ${{ needs.preflight.outputs.is_prerelease }} - make_latest: ${{ needs.preflight.outputs.make_latest }} - files: | - release-assets/*.dmg - release-assets/*.zip - release-assets/*.AppImage - release-assets/*.exe - release-assets/*.blockmap - release-assets/*.yml - fail_on_unmatched_files: true - token: ${{ steps.app_token.outputs.token }} - - finalize: - name: Finalize release - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} - needs: [preflight, release] - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - id: app_token - name: Mint release app token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - - name: Checkout - uses: actions/checkout@v6 - with: - ref: main - fetch-depth: 0 - token: ${{ steps.app_token.outputs.token }} - persist-credentials: true - - - id: app_bot - name: Resolve GitHub App bot identity - env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} - APP_SLUG: ${{ steps.app_token.outputs.app-slug }} - run: | - user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" - echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" - echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: package.json - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - - name: Install dependencies - run: bun install --frozen-lockfile --filter=@cafecode/scripts - - - id: update_versions - name: Update version strings - env: - RELEASE_VERSION: ${{ needs.preflight.outputs.version }} - run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output - - - name: Format package.json files - if: steps.update_versions.outputs.changed == 'true' - run: bunx oxfmt@0.40.0 apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json - - - name: Refresh lockfile - if: steps.update_versions.outputs.changed == 'true' - run: bun install --lockfile-only --ignore-scripts - - - name: Commit and push version bump - if: steps.update_versions.outputs.changed == 'true' - shell: bash - env: - RELEASE_TAG: ${{ needs.preflight.outputs.tag }} - run: | - if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock; then - echo "No version changes to commit." - exit 0 - fi - - git config user.name "${{ steps.app_bot.outputs.name }}" - git config user.email "${{ steps.app_bot.outputs.email }}" - - git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock - git commit -m "chore(release): prepare $RELEASE_TAG" - git push origin HEAD:main - - announce_discord: - name: Announce release on Discord - if: | - always() && !cancelled() && - needs.preflight.result == 'success' && - needs.release.result == 'success' && - (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') - needs: [preflight, release, finalize] - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: package.json - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - - name: Install dependencies - run: bun install --frozen-lockfile --filter=@cafecode/scripts - - - name: Announce prerelease on Discord - if: needs.preflight.outputs.is_prerelease == 'true' - continue-on-error: true - env: - DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }} - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} - run: | - node scripts/notify-discord-release.ts prerelease \ - --role-id "$DISCORD_MENTION_ROLE_ID" \ - --release-name "${{ needs.preflight.outputs.release_name }}" \ - --release-version "${{ needs.preflight.outputs.version }}" \ - --tag "${{ needs.preflight.outputs.tag }}" \ - --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" - - - name: Announce latest release on Discord - if: needs.preflight.outputs.make_latest == 'true' - continue-on-error: true - env: - DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }} - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} - run: | - node scripts/notify-discord-release.ts latest \ - --role-id "$DISCORD_MENTION_ROLE_ID" \ - --release-name "${{ needs.preflight.outputs.release_name }}" \ - --release-version "${{ needs.preflight.outputs.version }}" \ - --tag "${{ needs.preflight.outputs.tag }}" \ - --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" diff --git a/.gitignore b/.gitignore index 5e941c7b..e68c686a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,7 @@ __screenshots__/ squashfs-root/ .vercel .gstack/ +.plans/ +.selene/ dist-electron/ .electron-runtime/ diff --git a/.plans/01-shared-model-normalization.md b/.plans/01-shared-model-normalization.md deleted file mode 100644 index d38c4164..00000000 --- a/.plans/01-shared-model-normalization.md +++ /dev/null @@ -1,49 +0,0 @@ -# Plan: Centralize Model Normalization in Contracts - -## Summary - -Move model alias/default normalization into `packages/contracts` so desktop and renderer use one shared source of truth. - -## Motivation - -- Removes duplicated logic between: - - `apps/desktop/src/codexAppServerManager.ts` - - `apps/renderer/src/model-logic.ts` -- Prevents behavior drift when model aliases/defaults are updated. - -## Scope - -- Add shared model utilities to contracts. -- Update desktop and renderer to consume shared utilities. -- Keep renderer-specific display options in renderer. - -## Proposed Changes - -1. Add `packages/contracts/src/model.ts` with: - - Canonical model list - - Alias map - - `normalizeModelSlug` - - `resolveModelSlug` - - `DEFAULT_MODEL` -2. Export model utilities from `packages/contracts/src/index.ts`. -3. Update `apps/desktop/src/codexAppServerManager.ts` to replace local alias map/helper. -4. Update `apps/renderer/src/model-logic.ts` to wrap or re-export shared functions. -5. Update tests: - - Move/duplicate normalization tests to contracts. - - Keep renderer tests focused on renderer-only behavior. - -## Risks - -- Desktop/renderer may currently rely on slightly different fallback behavior. -- Import graph must avoid bundling issues for Electron main/preload. - -## Validation - -- `bun run test` -- `bun run typecheck` -- Manual check that model selection and session start still send expected model slug. - -## Done Criteria - -- No duplicated alias/default map in desktop and renderer. -- Shared model utilities are contract-tested. diff --git a/.plans/02-typed-ipc-boundaries.md b/.plans/02-typed-ipc-boundaries.md deleted file mode 100644 index fac5b1fc..00000000 --- a/.plans/02-typed-ipc-boundaries.md +++ /dev/null @@ -1,44 +0,0 @@ -# Plan: Strengthen Typed IPC Boundaries in Main Process - -## Summary - -Replace loose payload casting in IPC handlers with strict schema parsing and typed helper wrappers. - -## Motivation - -- `apps/desktop/src/main.ts` currently uses casts like `payload as Parameters<...>`. -- Casts can hide contract breakages until runtime. - -## Scope - -- Desktop main process IPC registration. -- Optional shared helper for handler registration. - -## Proposed Changes - -1. Add IPC helper utility (e.g. `apps/desktop/src/ipcHelpers.ts`) to: - - Parse payload(s) with Zod schemas - - Standardize typed handler signatures -2. Refactor provider IPC handlers in `apps/desktop/src/main.ts` to use: - - `providerSessionStartInputSchema.parse` - - `providerSendTurnInputSchema.parse` - - `providerInterruptTurnInputSchema.parse` - - `providerStopSessionInputSchema.parse` -3. Apply same pattern to agent/terminal handlers where possible. -4. Add tests for handler parsing failure paths (invalid payloads). - -## Risks - -- Refactor can subtly change IPC error shape/messages. -- Helper abstraction should stay simple and not obscure control flow. - -## Validation - -- `bun run test` -- `bun run typecheck` -- Manual invalid payload check from renderer/devtools to confirm fast failure. - -## Done Criteria - -- No provider handler uses `payload as Parameters<...>`. -- All IPC entrypoints parse unknown payloads at boundary. diff --git a/.plans/03-split-codex-app-server-manager.md b/.plans/03-split-codex-app-server-manager.md deleted file mode 100644 index 4f7fadb4..00000000 --- a/.plans/03-split-codex-app-server-manager.md +++ /dev/null @@ -1,48 +0,0 @@ -# Plan: Decompose CodexAppServerManager - -## Summary - -Split `CodexAppServerManager` into smaller modules with clear responsibilities. - -## Motivation - -- `apps/desktop/src/codexAppServerManager.ts` is large and mixes: - - Process lifecycle - - JSON-RPC parsing/routing - - Session state transitions - - Event emission -- This increases regression risk and slows changes. - -## Scope - -- Desktop provider internals only. -- Keep external behavior/API stable. - -## Proposed Changes - -1. Extract modules: - - `codex/processLifecycle.ts` - - `codex/jsonrpcRouter.ts` - - `codex/sessionState.ts` - - `codex/parsing.ts` -2. Keep `CodexAppServerManager` as thin orchestrator/facade. -3. Move pure helpers (`classifyCodexStderrLine`, route parsing) into unit-testable files. -4. Add targeted unit tests for: - - Message classification - - Request/notification/response routing - - Session state transitions - -## Risks - -- Reordering event handling can change behavior. -- Must preserve pending request timeout/cancellation semantics. - -## Validation - -- Existing tests pass. -- Add module-level tests for parsing and transition logic. - -## Done Criteria - -- Main manager file materially smaller and orchestration-focused. -- Core protocol/state logic covered by focused tests. diff --git a/.plans/04-split-chatview-component.md b/.plans/04-split-chatview-component.md deleted file mode 100644 index abf30c04..00000000 --- a/.plans/04-split-chatview-component.md +++ /dev/null @@ -1,47 +0,0 @@ -# Plan: Split ChatView into Smaller UI/Logic Units - -## Summary - -Refactor `ChatView.tsx` into composable pieces with isolated responsibilities. - -## Motivation - -- `apps/renderer/src/components/ChatView.tsx` is large and handles: - - Session orchestration - - Send/interrupt actions - - Timeline rendering - - Header/status UI - - Composer UI -- Hard to test and maintain as one component. - -## Scope - -- Renderer component boundaries and hooks. -- Keep visual behavior unchanged. - -## Proposed Changes - -1. Create hook: `apps/renderer/src/hooks/useChatSession.ts` - - `ensureSession` - - `sendTurn` - - `interruptTurn` -2. Split presentational components: - - `components/chat/ThreadHeader.tsx` - - `components/chat/MessageTimeline.tsx` - - `components/chat/ComposerBar.tsx` -3. Keep `ChatView.tsx` as container wiring store + hook + child components. -4. Add focused tests for hook behavior (error handling, session reuse). - -## Risks - -- Refactor can break subtle UI interactions (auto-scroll, menu close, keyboard send). - -## Validation - -- `bun run test` -- Manual smoke: send, stream, interrupt, model switch. - -## Done Criteria - -- `ChatView.tsx` significantly reduced and easier to scan. -- Session logic isolated from rendering. diff --git a/.plans/05-zod-persisted-state-validation.md b/.plans/05-zod-persisted-state-validation.md deleted file mode 100644 index 869da867..00000000 --- a/.plans/05-zod-persisted-state-validation.md +++ /dev/null @@ -1,41 +0,0 @@ -# Plan: Move Renderer Persisted-State Validation to Zod - -## Summary - -Use explicit Zod schemas for localStorage state parsing and migration. - -## Motivation - -- `apps/renderer/src/store.ts` has large manual sanitize functions. -- Manual type guards are verbose and easier to get wrong during schema evolution. - -## Scope - -- Renderer state hydration/persistence path. -- No backend/protocol changes. - -## Proposed Changes - -1. Add schema module: `apps/renderer/src/persistenceSchema.ts` - - Persisted payload versions (`v1`, `v2`) - - Thread/message/project schemas -2. Replace `sanitizeProjects/sanitizeThreads/sanitizeMessages` with schema parsing + transforms. -3. Keep migration logic explicit (legacy model migration and key migration). -4. Add tests for: - - Invalid payload fallback to initial state - - Legacy payload migration - - Unknown thread/project references filtered - -## Risks - -- Overly strict schemas could drop valid historical data unexpectedly. - -## Validation - -- Unit tests for migration/hydration. -- Manual reload test with existing localStorage data. - -## Done Criteria - -- Store hydration logic is schema-driven. -- Migration behavior is tested and documented. diff --git a/.plans/06-provider-logstream-lifecycle.md b/.plans/06-provider-logstream-lifecycle.md deleted file mode 100644 index 0a92de36..00000000 --- a/.plans/06-provider-logstream-lifecycle.md +++ /dev/null @@ -1,38 +0,0 @@ -# Plan: Add Provider Log Stream Lifecycle Management - -## Summary - -Ensure `ProviderManager` logging stream is initialized, rotated/structured, and closed safely. - -## Motivation - -- `apps/desktop/src/providerManager.ts` opens a write stream in constructor. -- Stream lifecycle is not explicit on shutdown. - -## Scope - -- Desktop provider logging behavior. -- App shutdown integration. - -## Proposed Changes - -1. Add explicit `dispose()` on `ProviderManager`: - - Remove event listeners - - End/close log stream -2. Call `providerManager.dispose()` from app shutdown path in `apps/desktop/src/main.ts`. -3. Optional: change log format to JSON lines with stable fields. -4. Optional: per-session log files under `.logs/providers/`. - -## Risks - -- Improper close sequencing may lose final log lines. - -## Validation - -- Manual run/quit cycle to ensure no open handle warnings. -- Confirm logs flush on quit and file descriptors are not leaked. - -## Done Criteria - -- ProviderManager owns complete log stream lifecycle. -- Shutdown path explicitly disposes provider resources. diff --git a/.plans/07-ci-quality-gates.md b/.plans/07-ci-quality-gates.md deleted file mode 100644 index ff27a9db..00000000 --- a/.plans/07-ci-quality-gates.md +++ /dev/null @@ -1,41 +0,0 @@ -# Plan: Add CI Workflow for Core Quality Gates - -## Summary - -Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-test) on pushes and PRs. - -## Motivation - -- Repository currently has no CI workflow files. -- Quality checks are only local/manual. - -## Scope - -- `.github/workflows/ci.yml` -- Bun + Turbo setup in CI. - -## Proposed Changes - -1. Add `ci.yml` with jobs: - - Setup Bun and Node environment - - Install deps - - `bun run lint` - - `bun run typecheck` - - `bun run test` -2. Add separate optional job for `bun run smoke-test` (desktop/Electron). -3. Configure caching for Bun/Turbo as appropriate. - -## Risks - -- Smoke test may be flaky in headless CI environments. -- CI runtime can grow if caching is misconfigured. - -## Validation - -- Verify workflow runs on a branch PR. -- Ensure failures surface clearly by job name. - -## Done Criteria - -- CI blocks regressions in lint/typecheck/test. -- Workflow docs added to README. diff --git a/.plans/08-precommit-format-and-lint.md b/.plans/08-precommit-format-and-lint.md deleted file mode 100644 index a919ac07..00000000 --- a/.plans/08-precommit-format-and-lint.md +++ /dev/null @@ -1,39 +0,0 @@ -# Plan: Add Pre-Commit Formatting/Lint Hooks - -## Summary - -Introduce pre-commit automation so formatting and basic lint checks happen before commits. - -## Motivation - -- Current lint failures include formatting-only issues. -- Shift-left feedback reduces noisy CI failures and cleanup churn. - -## Scope - -- Root tooling config and package scripts. -- No runtime code changes. - -## Proposed Changes - -1. Add hook tooling (e.g. Husky + lint-staged or Lefthook). -2. Configure staged-file tasks: - - `biome format --write` - - `biome check` -3. Add setup docs in README. -4. Keep checks fast to avoid developer friction. - -## Risks - -- Slow hooks can frustrate contributors and be bypassed. -- Need to ensure compatibility with Bun workspace setup. - -## Validation - -- Create sample staged changes and verify hook behavior. -- Confirm formatting fixes are applied automatically. - -## Done Criteria - -- Pre-commit hook installed and documented. -- Formatting-only lint failures drop significantly. diff --git a/.plans/09-event-state-test-expansion.md b/.plans/09-event-state-test-expansion.md deleted file mode 100644 index 35db64bc..00000000 --- a/.plans/09-event-state-test-expansion.md +++ /dev/null @@ -1,42 +0,0 @@ -# Plan: Expand Event/State Transition Test Coverage - -## Summary - -Add focused tests for renderer event handling and session evolution logic. - -## Motivation - -- Core behavior is event-driven and stateful. -- Existing renderer tests cover only a subset of timeline/model behavior. - -## Scope - -- `apps/renderer/src/session-logic.test.ts` -- Optional reducer tests for `apps/renderer/src/store.ts`. - -## Proposed Changes - -1. Add tests for `evolveSession`: - - `thread/started` - - `turn/started` - - `turn/completed` success/failure - - error/session closed events -2. Add tests for `applyEventToMessages`: - - start/delta/completed flow - - out-of-order event cases - - turn completion clearing streaming flags -3. Add reducer integration tests for `APPLY_EVENT`. - -## Risks - -- Tests may be brittle if event payload fixtures are too coupled to implementation details. - -## Validation - -- `bun run test` -- Ensure new tests remain deterministic and fast. - -## Done Criteria - -- High-risk event transitions are covered by unit tests. -- Regressions in stream assembly/session status are caught quickly. diff --git a/.plans/10-unify-process-session-abstraction.md b/.plans/10-unify-process-session-abstraction.md deleted file mode 100644 index 72f5d618..00000000 --- a/.plans/10-unify-process-session-abstraction.md +++ /dev/null @@ -1,42 +0,0 @@ -# Plan: Unify Process and PTY Session Abstractions in ProcessManager - -## Summary - -Refactor `ProcessManager` to use a single runtime-session interface for child-process and PTY modes. - -## Motivation - -- `apps/desktop/src/processManager.ts` maintains parallel maps and branch-heavy logic. -- New execution backends/providers will multiply complexity. - -## Scope - -- Desktop process execution internals. -- Preserve public `ProcessManager` API. - -## Proposed Changes - -1. Introduce internal interface (e.g. `RuntimeSession`): - - `write(data)` - - `kill()` - - lifecycle/output event hooks -2. Implement: - - `ChildProcessSession` - - `PtySession` -3. Replace dual maps with one `Map`. -4. Keep output/exit event contract unchanged. -5. Add tests for both implementations. - -## Risks - -- PTY behavior differs by platform; abstraction must not hide required differences. - -## Validation - -- Existing `processManager.test.ts` passes. -- Add PTY-path tests where feasible. - -## Done Criteria - -- Manager no longer branches per backend in `write/kill/killAll`. -- Session backends are independently testable. diff --git a/.plans/11-effect.md b/.plans/11-effect.md deleted file mode 100644 index 66521c20..00000000 --- a/.plans/11-effect.md +++ /dev/null @@ -1,40 +0,0 @@ -PR 1: Service contracts + error taxonomy -Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs. -Add typed Schema.TaggedError hierarchies for all 3 services (cause: Schema.optional(Schema.Defect) on each). -No behavior change yet, just interfaces and compile-time wiring points. -PR 2: CheckpointStore Effect adapter -Wrap current filesystemCheckpointStore behind CheckpointStoreLive (adapter). -Map all thrown/Promise errors to tagged errors. -Add service tests proving parity for isGitRepository, capture, restore, diff, prune. -PR 3: CodexService Effect adapter -Wrap current CodexAppServerManager behind CodexServiceLive (adapter). -Convert public API to Effect return types with typed errors. -Preserve existing EventEmitter internally for now, but expose Effect-friendly subscribe API. -PR 4: ProviderService Effect adapter -Wrap current ProviderManager behind ProviderServiceLive (adapter). -Provider methods become Effect methods with typed errors. -Route emitted provider events through an Effect PubSub surface. -PR 5: wsServer migration to Effect services -Stop instantiating provider/codex classes directly in wsServer. -Resolve ProviderService (and related services) from one runtime/layer graph. -Keep WS contract behavior identical. -PR 6: Native CheckpointStore implementation -Refactor checkpoint internals from Promise/throws to native Effect. -Replace ad-hoc locking with Effect concurrency primitive (keyed lock/semaphore/queue). -Keep adapter tests plus new failure-path tests. -PR 7: Codex transport/RPC core as native Effect -Split codex into scoped process layer + RPC request/response layer + session registry. -Replace timeout/pending maps with Deferred + Effect timeout/finalizer semantics. -Keep protocol behavior and ordering guarantees. -PR 8: Codex protocol decoding hardening -Replace ad-hoc unknown parsing with runtime schema decoding for inbound/outbound protocol shapes. -Map decode failures to typed tagged errors (with root cause). -Add regression tests for malformed/partial protocol messages. -PR 9: Native ProviderService orchestration -Rebuild provider logic in Effect using CodexService + CheckpointStore dependencies. -Move event fanout, checkpoint capture/revert orchestration, thread-log routing to Effect state/services. -Remove throw-based flow entirely from provider path. -PR 10: Cleanup + deprecation removal -Remove legacy class implementations/adapters once parity is proven. -Finalize layer composition and startup graph docs. -Add architecture notes for service boundaries and error model. diff --git a/.plans/12-effect-new.md b/.plans/12-effect-new.md deleted file mode 100644 index 3d87049f..00000000 --- a/.plans/12-effect-new.md +++ /dev/null @@ -1,67 +0,0 @@ -# Effect Migration Plan (From Current State) - -Current status summary: - -- Service contracts, typed errors, and most checkpoint/persistence services exist. -- `ProviderServiceLive` is already native orchestration (not a thin adapter). -- Production server path still uses legacy `ProviderManager`/`FilesystemCheckpointStore`. -- Checkpoint flow now avoids snapshot re-sync and is write-time driven. - -## PR 1: Wire Provider/Checkpoint Effect Stack Into `wsServer` - -- Build one runtime layer graph for provider + checkpoint + persistence + orchestration. -- Resolve `ProviderService` from runtime in `wsServer`. -- Replace `ProviderManager` method calls in WS handlers with `ProviderService` calls. -- Forward provider events by subscribing to `ProviderService.subscribeToEvents`. -- Keep WS method/push payloads identical. - -## PR 2: Runtime Composition + Startup Ownership - -- Create/centralize `AppLive` composition for server startup. -- Ensure outer runtime provides Node/platform services once. -- Ensure migrations run at startup via scoped/layer startup path. -- Remove ad-hoc service initialization in request-time paths. - -## PR 3: Session Lifecycle Hygiene + Checkpoint Invariants - -- Add explicit checkpoint session cleanup on `stopSession` / `stopAll`. -- Remove per-session lock/cwd map leaks. -- Keep strict invariant model: - - root checkpoint created at session initialization before agent modifications - - each completed turn captures filesystem checkpoint and persists metadata - - no after-the-fact metadata rebuild/sync -- Add tests for lifecycle cleanup and invariant-failure surfaces. - -## PR 4: Provider Event Stream Hardening (Without Extra Service Fragmentation) - -- Keep `ProviderService` as the public event surface. -- Internally move callback fanout to Effect concurrency primitives (`Queue`/`PubSub`) for ordering/backpressure control. -- Keep API as `subscribeToEvents` unless we explicitly choose stream API later. -- Add tests for ordering and subscriber isolation under load. - -## PR 5: Codex Runtime Split (Scoped Effect Core) - -- Extract `CodexAppServerManager` responsibilities into Effect-native layers: - - scoped process lifecycle - - RPC request/response + pending map via `Deferred` - - session registry/state -- Keep `CodexAdapter` contract stable while swapping internals. -- Preserve protocol behavior and timeout semantics. - -## PR 6: Codex Protocol Decode Hardening - -- Replace ad-hoc unknown parsing with runtime schema decode. -- Map decode failures to typed tagged errors with `cause` retained. -- Add regression tests for malformed/partial protocol frames. - -## PR 7: Remove Legacy Provider Stack - -- Remove `ProviderManager` + legacy checkpoint integration from runtime path. -- Remove `FilesystemCheckpointStore` from active server flow (keep only if explicitly needed for compatibility tooling). -- Update tests to assert only Effect service path is used. - -## PR 8: Final Cleanup + Docs - -- Update architecture docs with final layer graph and service boundaries. -- Document error model and recovery semantics. -- Trim dead compatibility code and stale plan references. diff --git a/.plans/13-provider-service-integration-tests.md b/.plans/13-provider-service-integration-tests.md deleted file mode 100644 index f3fe4edf..00000000 --- a/.plans/13-provider-service-integration-tests.md +++ /dev/null @@ -1,123 +0,0 @@ -# ProviderService Integration Test Plan - -Goal: - -- Validate end-to-end `ProviderService` behavior with real layers: - - `ProviderServiceLive` - - `CheckpointServiceLive` - - `CheckpointStoreLive` - - `CheckpointRepositoryLive` (sqlite in-memory) - - `ProviderSessionDirectoryLive` -- Only fake the adapter event source (deterministic Codex-like stream). -- Avoid mocking checkpointing/persistence orchestration logic. - -## Test Harness - -Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Layers/TestProviderAdapter.integration.ts`: - -- Service contract: `ProviderAdapterShape`. -- Internal state: - - session registry (session + cwd + threadId) - - thread snapshot store (`threadId`, `turns`) - - event subscribers -- Behavior: - - `startSession`: creates session with threadId. - - `sendTurn`: appends a deterministic turn snapshot and emits ordered events: - - `turn/started` - - `item/started` / `item/completed` (tool + approval variants depending on scenario) - - `item/agentMessage/delta` chunks - - `turn/completed` - - optional "mutator" callback per turn to change workspace files before completion. - - `readThread`, `rollbackThread`, `stopSession`, `stopAll`. - -Use real git-backed temporary workspaces in integration tests: - -- initialize repo with baseline commit -- run provider turn in workspace -- assert checkpoint diffs against real git refs - -## Core Integration Specs - -1. `startSession` initializes checkpoint root exactly once - -- Arrange: - - start provider session in git repo. -- Assert: - - `provider_checkpoints` contains root row (turn 0). - - checkpoint ref exists in git. - - second `startSession` for new session creates a new independent root. - -2. Turn without filesystem change - -- Arrange: - - emit normal turn events, no file mutation. -- Assert: - - provider subscribers receive: - - `turn/started` - - `turn/completed` - - synthetic `checkpoint/captured` - - `listCheckpoints` returns root + turn 1. - - `getCheckpointDiff(0 -> 1)` returns empty/no-op diff. - -3. Turn with filesystem change - -- Arrange: - - mutate `README.md` during turn. -- Assert: - - `listCheckpoints` returns root + turn 1. - - `getCheckpointDiff(0 -> 1)` contains file path and hunk. - - persisted checkpoint metadata includes non-empty `checkpointRef`. - -4. Multi-turn sequencing and checkpoint monotonicity - -- Arrange: - - turn 1: no file change - - turn 2: file change - - turn 3: file change -- Assert: - - turn counts are monotonic and contiguous in DB (0,1,2,3). - - latest checkpoint is marked current. - - diffs for adjacent turns map to expected filesystem deltas. - -5. Revert to checkpoint - -- Arrange: - - execute 3 turns with at least one file-changing turn. - - call `revertToCheckpoint(turnCount=1)`. -- Assert: - - workspace content matches turn 1 state. - - adapter `rollbackThread` called with `numTurns=2`. - - DB rows for turns >1 are removed. - - later refs are deleted from git. - -6. Capture failure surface - -- Arrange: - - adapter emits `turn/completed`, but file mutation leaves invalid repo state or store capture fails. -- Assert: - - `ProviderService` emits `checkpoint/captureError`. - - no partial metadata/ref divergence is left behind. - -## WebSocket Coverage (Thin Integration) - -Add one ws server integration spec: - -- Subscribe to `providers.event`. -- Run a deterministic provider turn through ws methods. -- Assert push stream includes: - - `turn/started`, tool events, `turn/completed`, `checkpoint/captured`. -- Assert orchestration projection still updates assistant message and turn diff summary. - -## Proposed PR Split - -PR A: - -- Test adapter harness + shared integration fixtures (repo setup, runtime/layer setup). - -PR B: - -- Core ProviderService integration specs (cases 1-4). - -PR C: - -- Revert + failure-path specs (cases 5-6) + ws thin integration spec. diff --git a/.plans/14-server-authoritative-event-sourcing-cleanup.md b/.plans/14-server-authoritative-event-sourcing-cleanup.md deleted file mode 100644 index e5c50232..00000000 --- a/.plans/14-server-authoritative-event-sourcing-cleanup.md +++ /dev/null @@ -1,227 +0,0 @@ -# Server-Authoritative Event-Sourcing Cleanup Plan - -Goal: - -- Move to a cleaner service architecture with: - - durable, server-authoritative event sourcing - - strict command routing/validation - - pluggable provider adapters - - explicit separation between transport, domain orchestration, provider runtime, and persistence - -## Target Service Graph (ASCII) - -```text - +---------------------------+ - | wsServer | - | transport | - +---------------------------+ - | orchestration.dispatchCommand - v - +-------------------------------------------+ - | OrchestrationCommandRouter | - +-------------------------------------------+ - | - v - +-------------------------------------------+ - | OrchestrationCommandHandlers | - +-------------------------------------------+ - | - v - +-------------------------------------------+ - | OrchestrationEventStore | - +-------------------------------------------+ - | - v - +-------------------------------------------+ - | OrchestrationProjectionService | - +-------------------------------------------+ - | snapshot/replay - +---------------------------> wsServer - - -wsServer -- providers.* RPC --> +---------------------------+ - | ProviderService | - +---------------------------+ - | | - v v - +-------------------+ +-------------------------+ - | ProviderSession | | ProviderAdapterRegistry | - | Registry (durable)| +-------------------------+ - +-------------------+ | - ^ v - | +-------------------------+ - | | ProviderAdapter(s) | - | +-------------------------+ - | | - | runtime events v - | +---------------------------+ - +----------| ProviderRuntimeIngestion | - +---------------------------+ - | | | - v v v - Router Session Checkpoint - Registry Service - - +-------------------------------------------+ - | CheckpointService | - +-------------------------------------------+ - | | | - v v v - +--------------------+ +-------------+ +-------------------+ - | CheckpointCatalog | | Checkpoint | | ProviderAdapter(s)| - | (durable) | | Store (git) | | (read/rollback) | - +--------------------+ +-------------+ +-------------------+ - | - v - +------+ - |SQLite| - +------+ - -OrchestrationEventStore ------> SQLite -OrchestrationProjectionService -> SQLite -ProviderSessionRegistry ------> SQLite -CheckpointCatalog ------> SQLite -``` - -## Commit Series - -### Commit 1: Split public vs system orchestration command contracts - -- Create separate schemas/types: - - `ClientOrchestrationCommandSchema` - - `SystemOrchestrationCommandSchema` - - `OrchestrationCommandSchema = union(client, system)` -- Ensure client transport can only submit client commands. -- Keep system commands for server-internal workflows only. -- Expected files: - - `packages/contracts/src/orchestration.ts` - - `apps/server/src/wsServer.ts` - - orchestration/service tests -- Tests: - - reject system-only command via WS dispatch path - - preserve internal dispatch functionality for system commands - -### Commit 2: Introduce `OrchestrationCommandRouter` + handler boundary - -- Add dedicated router service to validate, authorize, and route commands. -- Move command-to-event mapping out of `orchestration/Layer.ts` into handlers. -- Add aggregate-level invariant checks before append (thread exists, project exists, etc.). -- Expected files: - - `apps/server/src/orchestration/Services/CommandRouter.ts` (new) - - `apps/server/src/orchestration/Layers/CommandRouter.ts` (new) - - `apps/server/src/orchestration/Layer.ts` - - `apps/server/src/orchestration/reducer.ts` (only if needed for event payload changes) -- Tests: - - router validation and invariant failures - - handler happy-path tests per command type - -### Commit 3: Harden event store for idempotency + optimistic append metadata - -- Add DB-level idempotency guard for `command_id` (`UNIQUE` where non-null). -- Extend append API to support idempotent replays and deterministic return of prior event on duplicate `commandId`. -- Add optional aggregate version metadata for future optimistic concurrency. -- Expected files: - - `apps/server/src/persistence/Migrations/00x_*.ts` (new migration) - - `apps/server/src/persistence/Services/OrchestrationEvents.ts` - - `apps/server/src/persistence/Layers/OrchestrationEvents.ts` -- Tests: - - duplicate command ID append returns same event/sequence (or explicit idempotent behavior) - - concurrent append behavior stays ordered and deterministic - -### Commit 4: Extract provider-runtime -> orchestration bridge from `wsServer` - -- Create `ProviderRuntimeIngestionService` that: - - subscribes to `ProviderService.streamEvents` - - translates runtime events into orchestration commands - - dispatches through router/engine -- Remove provider-to-orchestration state mutation logic from `wsServer`. -- Expected files: - - `apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts` (new) - - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` (new) - - `apps/server/src/wsServer.ts` -- Tests: - - ingestion service mapping tests (turn started/completed, message delta/completed, runtime error) - - ws integration confirms same external push behavior - -### Commit 5: Make session directory durable (`ProviderSessionRegistry`) - -- Replace in-memory-only `ProviderSessionDirectoryLive` with persistence-backed registry. -- Keep in-memory cache optional, but source of truth must be persistent. -- Add startup reconciliation to prune dead sessions / keep known thread mapping. -- Expected files: - - `apps/server/src/provider/Services/ProviderSessionDirectory.ts` (or new SessionRegistry service) - - `apps/server/src/provider/Layers/ProviderSessionDirectory.ts` - - `apps/server/src/persistence/Migrations/00x_*.ts` (new table/indexes) - - provider persistence tests -- Tests: - - survives server restart with correct mapping - - stale session cleanup semantics - -### Commit 6: Re-key checkpoint metadata from session to thread identity - -- Change checkpoint catalog primary identity from `provider_session_id` to durable `thread_id`. -- Keep `session_id` as nullable metadata only. -- Update checkpoint flows (`initialize`, `capture`, `list`, `diff`, `revert`) to use thread identity. -- Expected files: - - `apps/server/src/persistence/Migrations/00x_*.ts` (checkpoint schema migration) - - `apps/server/src/persistence/Services/Checkpoints.ts` - - `apps/server/src/persistence/Layers/Checkpoints.ts` - - `apps/server/src/checkpointing/Layers/CheckpointService.ts` -- Tests: - - resume/new session over same thread sees same checkpoint history - - revert/diff still work after session churn - -### Commit 7: Add durable projection persistence for orchestration read models - -- Introduce projection tables/snapshots persisted in DB to avoid full replay dependency. -- Keep event stream as source of truth; projection rebuild stays deterministic. -- `getSnapshot` reads from projection store (memory cache optional). -- Expected files: - - `apps/server/src/persistence/Migrations/00x_*.ts` (projection tables) - - `apps/server/src/orchestration/*` projection service/layer - - `apps/server/src/wsServer.ts` (snapshot/replay path wiring) -- Tests: - - cold boot snapshot load without replaying full history in process - - projection rebuild from events yields same result as previous reducer semantics - -### Commit 8: Narrow `ProviderService` responsibilities - -- Keep `ProviderService` focused on provider RPC/session lifecycle + unified runtime stream. -- Move checkpoint-capture side effects out of provider event worker into dedicated ingestion/checkpoint pipeline service. -- Preserve adapter pluggability and provider-neutral contracts. -- Expected files: - - `apps/server/src/provider/Layers/ProviderService.ts` - - new orchestration/checkpoint runtime coordinator service(s) -- Tests: - - provider service routing stays intact - - checkpoint capture still triggered by turn completion through new coordinator - -### Commit 9: Look over schemas (contracts and events) - -- Scan for unused schemas. -- Use effect/Schema everywhere -- Analyze which we need - - RPC Input/Output (both for routeRequest and command handler) - - Event payloads - - Persistence entities - -### Commit 10: Remove dead legacy path and finalize docs - -- Remove unused legacy manager/store path from active architecture: - - `providerManager.ts` - - `filesystemCheckpointStore.ts` (if no longer needed by tests/tools) -- Look over effect services for unused methods, errors, etc -- Update architecture docs with final service boundaries and boot/runtime graph. -- Expected files: - - legacy files + references - - `AGENTS.md`/docs as needed - - `.plans` docs linkage -- Tests: - - full server integration suite passes on Effect-only path - - no regressions in WS protocol behavior - -## Risk Controls - -- Keep WS method names and payload contracts stable throughout. -- Gate each commit with targeted integration tests before moving forward. -- Avoid broad event-type churn in one step; migrate schemas incrementally with clear compatibility windows. diff --git a/.plans/15-effect-server.md b/.plans/15-effect-server.md deleted file mode 100644 index 5e245bb8..00000000 --- a/.plans/15-effect-server.md +++ /dev/null @@ -1,11 +0,0 @@ -Rewrite `createServer` and `index.ts` to be Effect native. - -Maybe use `effect/unstable/Socket` for the web socket server - -- https://github.com/Effect-TS/effect-smol/blob/main/packages/effect/src/unstable/socket/SocketServer.ts -- https://github.com/Effect-TS/effect-smol/blob/main/packages/platform-node/test/NodeSocket.test.ts - -- Migrate remaining runtime code to Effect - - `gitManager` -> `src/git` - - `terminalManager` -> `src/terminal` (Manager + PTY) - - ... diff --git a/.plans/16-pr89-review-remediation-phases.md b/.plans/16-pr89-review-remediation-phases.md deleted file mode 100644 index 81ed6bd9..00000000 --- a/.plans/16-pr89-review-remediation-phases.md +++ /dev/null @@ -1,165 +0,0 @@ -# PR #89 Review Remediation Plan (Phased) - -## How To Use These Files - -- Working checklist with updateable status per item (single source of truth): `.plans/16c-pr89-remediation-checklist.md` -- This file (`16-pr89-review-remediation-phases.md`): phase strategy and grouping. - -## Scope - -- Source: GitHub review comments on PR #89 (`Add server-side orchestration engine with event sourcing`). -- Triage baseline used here: - - Total threads: 185 - - Outdated: 94 (excluded) - - Active unresolved: 85 - - Invalid/false-positive: 3 (excluded) - - Duplicate reposts: collapsed - - Unique actionable findings after filtering: 58 - - Post-rewrite validity audit: 5 additional stale items marked invalid, leaving 53 actionable (`34 valid` + `19 partially-valid`) - -## Phase 0: Canonical Triage Lock - -- Create a single tracking checklist for the 53 currently actionable findings. -- Map every duplicate thread to its canonical item. -- Mark invalid/false-positive items with explicit rationale. - -Exit criteria: - -- Every open thread is mapped to one canonical fix item or marked invalid. - -## Phase 1: Runtime Survival and Critical Event Wiring - -Related bug groups solved together: - -- Worker loop/fiber fatal error handling in orchestration reactors. -- WebSocket message error boundaries and unhandled rejection guards. -- Close invalid `providers.event` review findings as documented architecture mismatch (no code change expected). - -Primary files: - -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` -- `apps/server/src/orchestration/Layers/CheckpointReactor.ts` -- `apps/server/src/wsServer.ts` - -Exit criteria: - -- A single event-processing failure cannot permanently stop ingestion/reactor loops. -- WS message handling cannot produce unhandled promise rejections. -- Invalid provider-event-channel review findings are closed with architecture rationale. - -## Phase 2: State Consistency and Ordering - -Related bug groups solved together: - -- Fire-and-forget revert completion causing consistency windows. -- Non-atomic append/projection paths and retry behavior. -- Race-sensitive thread/event association issues. - -Primary files: - -- `apps/server/src/orchestration/Layers/CheckpointReactor.ts` -- `apps/server/src/orchestration/Layers/OrchestrationEngine.ts` -- `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` - -Exit criteria: - -- Revert flow is deterministically reflected in read model updates. -- Append/project failure mode is explicit and safe under retry. -- No cross-thread misassociation under concurrent runtime events. - -## Phase 3: Checkpointing Correctness Bundle - -Related bug groups solved together: - -- Checkpoint input normalization consistency. -- Snapshot/projector coverage mismatches. -- Checkpoint ref/workspace CWD utility duplication. -- Checkpoint diff/error handling behavior gaps. - -Primary files: - -- `apps/server/src/checkpointing/Layers/CheckpointStore.ts` -- `apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts` -- `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts` -- `apps/server/src/orchestration/Layers/CheckpointReactor.ts` -- `apps/server/src/wsServer.ts` - -Exit criteria: - -- Checkpoint capture/restore/revert paths use one normalization policy. -- Required projectors are actually represented in snapshot reads. -- Shared checkpoint/ref/CWD helpers are centralized. - -## Phase 4: Memory and Lifecycle Hygiene - -Related bug groups solved together: - -- Unbounded in-memory dedup sets/maps. -- Missing cleanup/lifecycle protections in long-lived effects/resources. - -Primary files: - -- `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` -- `apps/server/src/config.ts` - -Exit criteria: - -- Long-running server memory does not grow unbounded from dedup bookkeeping. -- Resource cleanup paths are registered for interruption/shutdown. - -## Phase 5: Transport, Parsing, and Platform Edge Cases - -Related bug groups solved together: - -- UTF-8 chunk boundary decode correctness. -- Markdown/file-link parsing edge cases. -- Shell/OS-specific PATH parsing behavior. -- Git rename parsing and small keybinding edge cases. - -Primary files: - -- `apps/server/src/wsServer.ts` -- `apps/server/src/git/Layers/CodexTextGeneration.ts` -- `apps/web/src/markdown-links.ts` -- `apps/server/src/os-jank.ts` -- `apps/server/src/git/Layers/GitCore.ts` -- `apps/server/src/keybindings.ts` - -Exit criteria: - -- Edge-case parsers are robust across valid but non-trivial inputs. -- Platform-dependent command behavior has safe fallbacks. - -## Phase 6: Build and Maintainability Cleanup - -Related bug groups solved together: - -- Build script/runtime assumption cleanup. -- Redundant error-union declarations and utility/type duplication. -- Non-functional cleanup comments/docs markers. - -Primary files: - -- `apps/server/package.json` -- `apps/server/src/checkpointing/Errors.ts` -- Shared utility locations introduced during earlier phases -- `AGENTS.md` (if cleanup is still pending) - -Exit criteria: - -- Build path is explicit and environment-safe. -- Redundant types/utilities are removed in favor of single sources of truth. - -## Phase 7: Verification and Closeout - -- Add backend tests for all behavioral fixes (integration-focused; external services may be layered/mocked, core business logic not mocked out). -- Run lint and backend tests for all touched packages. -- Resolve threads with fix references per canonical checklist item. - -Exit criteria: - -- Lint passes. -- Backend tests pass. -- All actionable review threads are resolved or explicitly justified. diff --git a/.plans/16c-pr89-remediation-checklist.md b/.plans/16c-pr89-remediation-checklist.md deleted file mode 100644 index 6512e924..00000000 --- a/.plans/16c-pr89-remediation-checklist.md +++ /dev/null @@ -1,478 +0,0 @@ -# PR #89 Remediation Checklist (Consolidated) - -_Last updated: 2026-02-26_ - -This is the working checklist for remediation execution. - -Status values: - -- `TODO`: Not started -- `IN_PROGRESS`: Currently being worked -- `BLOCKED`: Waiting on decision/dependency -- `DONE`: Implemented and verified -- `CLOSED_INVALID`: Stale/invalid review finding - -Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` - -## Active Checklist - -### Phase 1 - -- [x] `C002` A dispatch error in `processEvent` will terminate the `Effect.forever` loop, permanently halting event ingestion. Consider adding error recovery (e.g., `Effect.catchAll` with logging) around `processEvent` so failures don't kill the fiber. - - Status: `DONE` - - Verdict: `valid` - - Severity: `High` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:333` - - Threads: PRRT_kwDORLtfbc5wj4cH, PRRT_kwDORLtfbc5wnWwF, PRRT_kwDORLtfbc5wyTaP, PRRT_kwDORLtfbc5wzliw, PRRT_kwDORLtfbc5w0_g3, PRRT_kwDORLtfbc5w1HGT (+5 duplicate thread(s)) - - Audit note: Ingestion worker loop can terminate on unhandled processEvent failure. - -- [x] `C003` Consider attaching a no-op error listener before `socket.write` (e.g., `socket.on('error', () => {})`) to prevent an unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake. - - Status: `DONE` - - Verdict: `valid` - - Severity: `High` - - Area: `WebSocket robustness` - - File: `apps/server/src/wsServer.ts:75` - - Threads: PRRT_kwDORLtfbc5v-cf4 - - Audit note: Upgrade reject writes then destroys socket without defensive error listener. - -- [x] `C012` Forked revert dispatch risks read model inconsistency - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:542` - - Threads: PRRT_kwDORLtfbc5whszW, PRRT_kwDORLtfbc5wyTaS, PRRT_kwDORLtfbc5wzli0, PRRT_kwDORLtfbc5w0_g4, PRRT_kwDORLtfbc5w1HGX (+4 duplicate thread(s)) - - Audit note: Revert completion dispatch remains forked; state consistency window remains. - -- [ ] `C019` ProviderRuntimeIngestion processes events for wrong thread on race - - Status: `TODO` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:178` - - Threads: PRRT_kwDORLtfbc5wkPaL - - Audit note: SessionId-only routing can misassociate events under races/rebinds. - -- [x] `C020` On `message.completed`, the message ID is added to the set and `thread.message.assistant.complete` is dispatched. On `turn.completed`, the same set is iterated and `thread.message.assistant.complete` is dispatched again for each ID—including already-completed ones. Consider removing message IDs from the set after dispatching on `message.completed`, or filtering out already-completed IDs before the `turn.completed` loop. - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:266` - - Threads: PRRT_kwDORLtfbc5w1GPr - - Audit note: Duplicate complete dispatch exists; downstream impact often idempotent. - -- [x] `C026` Consider adding `.catch(() => {})` after `Effect.runPromise(handleMessage(ws, raw))` to prevent unhandled rejections from crashing the server if `encodeResponse` or setup logic fails. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `WebSocket robustness` - - File: `apps/server/src/wsServer.ts:545` - - Threads: PRRT_kwDORLtfbc5wj4cE - - Audit note: runPromise result still not caught; rejection can surface unhandled. - -- [x] `C027` WS message handler can cause unhandled promise rejection - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/wsServer.ts:545` - - Threads: PRRT_kwDORLtfbc5wyTaW, PRRT_kwDORLtfbc5wzli3 (+1 duplicate thread(s)) - - Audit note: Same unhandled rejection path remains in WS message handler. - -- [x] `C042` Duplicated `resolveThreadWorkspaceCwd` across three files - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:62` - - Threads: PRRT_kwDORLtfbc5wzli2 - - Audit note: Duplication exists but one instance is variant logic, so impact is moderate. - -- [x] `C043` Duplicated workspace CWD resolution logic across reactor modules - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:62` - - Threads: PRRT_kwDORLtfbc5wnWwM, PRRT_kwDORLtfbc5w1C3-, PRRT_kwDORLtfbc5w1HGZ (+2 duplicate thread(s)) - - Audit note: Workspace CWD resolution duplication still present across modules. - -- [x] `C044` Checkpoint reactor swallows diff errors silently for `turn.completed` - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:274` - - Threads: PRRT_kwDORLtfbc5wkPaO - - Audit note: Errors are swallowed to empty diff with warning; not fully silent but still lossy. - -- [x] `C045` `truncateDetail` slices to `limit - 1` then appends `"..."` (3 chars), producing strings of length `limit + 2`. Consider slicing to `limit - 3` instead.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:29` - - Threads: PRRT_kwDORLtfbc5wzp4R - - Audit note: truncateDetail still overshoots limit. - -- [x] `C046` `latestMessageIdByTurnKey` is written to but never read, and `clearAssistantMessageIdsForTurn` doesn't clear its entries—only `clearTurnStateForSession` does. Consider removing this map entirely if unused, or clearing it alongside `turnMessageIdsByTurnKey` in `clearAssistantMessageIdsForTurn`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:133` - - Threads: PRRT_kwDORLtfbc5wxvIQ - - Audit note: latestMessageIdByTurnKey still unused/unpruned in per-turn clear path. - -- [x] `C053` Consider using `socket.end(response)` instead of `socket.write(response)` + `socket.destroy()` to ensure the HTTP error response is fully flushed before closing the connection. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `WebSocket robustness` - - File: `apps/server/src/wsServer.ts:83` - - Threads: PRRT_kwDORLtfbc5v-WPD - - Audit note: Still uses write+destroy rather than end() for rejection response. - -- [ ] `C054` When array chunks contain a multi-byte UTF-8 character split across boundaries, decoding each chunk separately produces replacement characters. Consider using `Buffer.concat()` on all chunks before calling `.toString("utf8")`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `WebSocket robustness` - - File: `apps/server/src/wsServer.ts:104` - - Threads: PRRT_kwDORLtfbc5whtrR - - Audit note: Array chunk UTF-8 decode remains vulnerable to split multibyte corruption. - -- [x] `C059` Suggestion: don’t spread `params` into `body`; it can override `_tag` and mishandle non-object values. Keep `_tag` separate and nest `params` under a single key (e.g., `data`), or validate `params` is a plain object. - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `WebSocket robustness` - - File: `apps/web/src/wsTransport.ts:59` - - Threads: PRRT_kwDORLtfbc5whtrN - - Audit note: Transport \_tag override risk exists but current callsites are constrained. - -### Phase 2 - -- [x] `C001` Non-atomic event appending can corrupt state on retry. If an error occurs mid-loop (lines 96-102) after some events are persisted but before the receipt is written, the command appears to fail. A retry generates new UUIDs via `crypto.randomUUID()` in the decider, appending duplicate events. Consider wrapping the loop in a transaction or using deterministic event IDs derived from `commandId`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `High` - - Area: `Event ordering and state consistency` - - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:96` - - Threads: PRRT_kwDORLtfbc5wzp4T - - Audit note: Append/project/receipt are non-atomic; retry can duplicate events. - -- [x] `C013` If `projectionPipeline.projectEvent` fails after `eventStore.append` succeeds, the event is persisted but `readModel` isn't updated, causing desync. Consider updating the in-memory `readModel` immediately after append (before the external projection), so local state stays consistent regardless of downstream failures. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Event ordering and state consistency` - - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:99` - - Threads: PRRT_kwDORLtfbc5whtrM - - Audit note: Persisted event can outpace in-memory projection on mid-flight failure. - -- [x] `C015` The gap-filling fallback logic can retain messages from turns that are about to be deleted, causing foreign key violations. Consider removing the fallback logic entirely, or filtering `fallbackUserMessages` and `fallbackAssistantMessages` to only include messages whose `turnId` is in `retainedTurnIds`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Event ordering and state consistency` - - File: `apps/server/src/orchestration/Layers/ProjectionPipeline.ts:99` - - Threads: PRRT_kwDORLtfbc5whxJO - - Audit note: Message fallback retention issue is real, but prior FK-violation claim is overstated. - -- [x] `C016` The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes _after_ the committed sequence. Consider persisting this pending state or processing these two events atomically.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Event ordering and state consistency` - - File: `apps/server/src/orchestration/Layers/ProjectionPipeline.ts:490` - - Threads: PRRT_kwDORLtfbc5wxvH8 - - Audit note: Pending turn-start map is in-memory only and not rebuilt on bootstrap. - -### Phase 3 - -- [x] `C008` Inconsistent input normalization across CheckpointStore methods - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Checkpointing correctness` - - File: `apps/server/src/checkpointing/Layers/CheckpointStore.ts:94` - - Threads: PRRT*kwDORLtfbc5widJw, PRRT_kwDORLtfbc5wnWv*, PRRT_kwDORLtfbc5w0_g7, PRRT_kwDORLtfbc5w1C36 (+3 duplicate thread(s)) - - Audit note: Edge schema strategy is in place across contracts/consumers (trim/normalize via schemas and decode at boundaries); CheckpointStore remains an internal repository boundary. - -- [x] `C017` `REQUIRED_SNAPSHOT_PROJECTORS` includes `pending-approvals` and `thread-turns`, but `getSnapshot` doesn't query their data. If these projectors lag behind, the returned `snapshotSequence` will be lower than what the included data actually reflects, causing clients to replay already-applied events. Consider filtering `REQUIRED_SNAPSHOT_PROJECTORS` to only include projectors whose data is actually fetched in the snapshot.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Checkpointing correctness` - - File: `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:71` - - Threads: PRRT_kwDORLtfbc5wiLhQ - - Audit note: Snapshot sequence can under-report due to extra projectors, but replay impact is lower now. - -- [x] `C033` Three error classes defined but never instantiated anywhere - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Checkpointing correctness` - - File: `apps/server/src/checkpointing/Errors.ts:51` - - Threads: PRRT_kwDORLtfbc5wlYgo - - Audit note: Original claim overstated; some errors used, others appear unused. - -- [x] `C034` Redundant `CheckpointInvariantError` in `CheckpointServiceError` union type - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Checkpointing correctness` - - File: `apps/server/src/checkpointing/Errors.ts:79` - - Threads: PRRT_kwDORLtfbc5wj5fn - - Audit note: CheckpointInvariantError remains redundantly included in service union. - -- [x] `C035` Redundant error type in CheckpointServiceError union definition - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Checkpointing correctness` - - File: `apps/server/src/checkpointing/Errors.ts:79` - - Threads: PRRT_kwDORLtfbc5wlYgs, PRRT_kwDORLtfbc5wxsO6, PRRT_kwDORLtfbc5w1C4B (+2 duplicate thread(s)) - - Audit note: Same as C034. - -### Phase 4 - -- [ ] `C018` Unbounded memory growth in turn start deduplication set - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Memory/resource growth` - - File: `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:84` - - Threads: PRRT_kwDORLtfbc5whszQ, PRRT_kwDORLtfbc5wl2A8, PRRT_kwDORLtfbc5wyTaT, PRRT_kwDORLtfbc5wzliz, PRRT_kwDORLtfbc5w0_g-, PRRT_kwDORLtfbc5w1HGW (+5 duplicate thread(s)) - - Audit note: handledTurnStartKeys still grows without pruning. - -### Phase 5 - -- [ ] `C009` Git's braced rename syntax (e.g., `src/{old => new}/file.ts`) isn't handled correctly. The current slice after `=>` produces invalid paths like `new}/file.ts`. Consider expanding the braces to construct the full destination path.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/git/Layers/GitCore.ts:41` - - Threads: PRRT_kwDORLtfbc5w1CxT - - Audit note: Braced rename parsing still breaks paths like src/{old => new}/file.ts. - -- [ ] `C010` `loadCustomKeybindingsConfig` fails when the config file doesn't exist, which is expected for new users. Consider catching `ENOENT` and returning an empty array instead.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/keybindings.ts:418` - - Threads: PRRT_kwDORLtfbc5wxvIJ - - Audit note: ENOENT for missing keybindings config still not handled as empty/default. - -- [ ] `C022` Fish shell outputs `$PATH` as space-separated, not colon-separated. Consider checking if the shell is fish and using `string join : $PATH` instead, or validating the result contains colons before assigning. - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/os-jank.ts:10` - - Threads: PRRT_kwDORLtfbc5wkRZM - - Audit note: fish PATH formatting risk still exists in os-jank path recovery. - -- [ ] `C023` Using `-il` flags causes the shell to source profile scripts that may print banners or other text, polluting the captured `PATH`. Consider using `-lc` (login only, non-interactive) to reduce unwanted output. - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/os-jank.ts:10` - - Threads: PRRT_kwDORLtfbc5wj4cM - - Audit note: -ilc shell invocation can pollute captured PATH output. - -- [x] `C029` `parseFileUrlHref` already decodes the path (line 46), but `safeDecode` is called again here, corrupting filenames containing `%` sequences. Consider skipping the decode when `fileUrlTarget` is non-null. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/web/src/markdown-links.ts:105` - - Threads: PRRT_kwDORLtfbc5wnVsU - - Audit note: file URL decoding still double-decodes in one path. - -- [x] `C030` `EXTERNAL_SCHEME_PATTERN` matches `script.ts:10` as a scheme because `.ts:` looks like `scheme:`. Consider requiring `://` after the colon, or checking that what follows the colon is not just digits.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/web/src/markdown-links.ts:111` - - Threads: PRRT_kwDORLtfbc5wnVsK - - Audit note: Scheme regex still misclassifies script.ts:10 as external scheme. - -- [ ] `C038` Multi-byte UTF-8 characters split across chunks will be corrupted when decoding each chunk separately. Consider accumulating all chunks first, then decoding once, or use `TextDecoder` with `stream: true`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/git/Layers/CodexTextGeneration.ts:136` - - Threads: PRRT_kwDORLtfbc5w1GPo - - Audit note: Chunk-by-chunk UTF-8 decode can still corrupt split multibyte characters. - -- [x] `C039` The `+` key can be parsed (via trailing `+` handling) but cannot be encoded because `shortcut.key.includes("+")` returns true for the literal `+` key. Consider checking `shortcut.key === "+"` separately and encoding it as `"space"` style (e.g., a special token), or adjusting the condition to allow the single `+` character.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/keybindings.ts:352` - - Threads: PRRT_kwDORLtfbc5wxvIB - - Audit note: Parser/encoder mismatch remains, but encoder path currently low-use. - -- [x] `C040` `upsertKeybindingRule` has a race condition: concurrent calls read the same file state, then the last write overwrites earlier changes. Consider wrapping the read-modify-write sequence with `Effect.Semaphore` to serialize access.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/keybindings.ts:488` - - Threads: PRRT_kwDORLtfbc5wxvIA - - Audit note: upsertKeybindingRule read-modify-write remains race-prone. - -### Phase 6 - -- [ ] `C028` Branch sync dispatches both server and stale local update - - Status: `TODO` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Other` - - File: `apps/web/src/components/BranchToolbar.tsx:102` - - Threads: PRRT_kwDORLtfbc5v-XCu - - Audit note: Optimistic local+server dual update is intentional but can temporarily diverge. - -- [x] `C037` `Effect.callback` should return a cleanup function to close the server(s) on fiber interruption. Without it, the `Net.Server` handles keep the process alive and leak the port if the effect is cancelled.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/config.ts:41` - - Threads: PRRT_kwDORLtfbc5wj4cO - - Audit note: Callback cleanup missing, but practical exposure is low in one-shot startup path. - -- [ ] `C047` `SqlSchema.findOneOption` can produce both SQL errors and decode errors, but `mapError` wraps all as `PersistenceSqlError`. Consider distinguishing `ParseError` from SQL errors and mapping decode failures to `PersistenceDecodeError` instead.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts:75` - - Threads: PRRT_kwDORLtfbc5wiaR- - - Audit note: Decode and SQL errors still collapsed into one persistence error kind. - -- [x] `C049` `JSON.stringify(cause)` returns `undefined` for `undefined`, functions, or symbols, violating the `string` return type. Consider coercing the result to a string (e.g., `String(JSON.stringify(cause))`) or adding a fallback. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/provider/Layers/ProviderService.ts:59` - - Threads: PRRT_kwDORLtfbc5wnVsI - - Audit note: JSON.stringify(cause) may return undefined despite string expectations. - -- [ ] `C050` The read-modify-write pattern (`getBySessionId` → merge → `upsert`) is susceptible to lost updates under concurrent writes. Consider wrapping in a transaction or adding optimistic concurrency control (e.g., version field) if concurrent session updates are expected.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/provider/Layers/ProviderSessionDirectory.ts:94` - - Threads: PRRT_kwDORLtfbc5wiLhY - - Audit note: ProviderSessionDirectory upsert remains read-merge-write without concurrency control. - -- [x] `C051` Using `??` for `providerThreadId` and `adapterKey` makes it impossible to clear these fields by passing `null`, since `null ?? existing` evaluates to `existing`. Consider using explicit `undefined` checks (like `resumeCursor` does) if clearing should be supported.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/provider/Layers/ProviderSessionDirectory.ts:119` - - Threads: PRRT_kwDORLtfbc5wxvH9 - - Audit note: Null-clearing issue is real for providerThreadId; adapterKey part overstated. - -- [ ] `C052` Race condition: `processHandle` may be `null` when `data` callback fires, since it's assigned after `Bun.spawn` returns. Consider initializing `BunPtyProcess` first, then passing it to the callback to avoid losing initial output.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/terminal/Layers/BunPTY.ts:97` - - Threads: PRRT_kwDORLtfbc5w1CxE - - Audit note: Data callback may race before processHandle assignment. - -- [ ] `C056` When `onOpenChange` is provided without `open`, the internal `_open` state never updates because `setOpenProp` takes precedence. Consider calling `_setOpen` when `openProp === undefined`, regardless of whether `setOpenProp` exists. - - Status: `TODO` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/web/src/components/ui/sidebar.tsx:114` - - Threads: PRRT_kwDORLtfbc5wxvIq - - Audit note: Bug pattern exists, but current callsites mostly avoid triggering it. - -- [ ] `C057` The `resizable` object is recreated on every render, causing `SidebarRail`'s `useEffect` to repeatedly read localStorage and update the DOM. Consider memoizing the object with `useMemo`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/web/src/routes/_chat.$threadId.tsx:105` - - Threads: PRRT_kwDORLtfbc5wyWz4 - - Audit note: Resizable object recreation still retriggers effect/storage reads. - -- [ ] `C058` When `localStorage.getItem()` returns `null`, `Number(null)` evaluates to `0`, which passes `Number.isFinite(0)`. This causes the sidebar to clamp to `minWidth` on first load, overriding the `DIFF_INLINE_DEFAULT_WIDTH` CSS clamp. Consider checking for `null` or empty string before parsing, e.g. guard with `storedWidth === null || storedWidth === ''`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/web/src/routes/_chat.$threadId.tsx:122` - - Threads: PRRT_kwDORLtfbc5wnVsX - - Audit note: Number(null) -> 0 path still forces min width on initial load. - -- [ ] `C060` `defaultModel` should be `Schema.optional(Schema.NullOr(Schema.String))` to allow clearing the value. Currently there's no way to reset it to `null` since omitting means "no change" in patch semantics.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `packages/contracts/src/orchestration.ts:253` - - Threads: PRRT_kwDORLtfbc5whxJC - - Audit note: Schema still cannot express null clear for defaultModel patch. - -## Closed Invalid Items - -- [x] `C014` Engine error handler catches all errors including non-invariant ones - - Status: `CLOSED_INVALID` - - Severity: `Medium` - - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:144` - - Threads: PRRT_kwDORLtfbc5wkPaJ - - Rationale: Broad catch is intentional for worker liveness; transactional dispatch path prevents the claimed non-invariant idempotency break in current design. - -- [x] `C021` Shared mutable default metadata object causes stale eventId - - Status: `CLOSED_INVALID` - - Severity: `Medium` - - File: `apps/server/src/orchestration/decider.ts:27` - - Threads: PRRT_kwDORLtfbc5wkPaA - - Rationale: Stale-eventId claim no longer applies; eventId is regenerated per event. - -- [x] `C025` Duplicated checkpoint ref computation across two files - - Status: `CLOSED_INVALID` - - Severity: `Medium` - - File: `apps/server/src/wsServer.ts:128` - - Threads: PRRT_kwDORLtfbc5wvwag - - Rationale: No longer duplicated; checkpoint ref helper now centralized. - -- [x] `C031` Revert uses wrong turn count from positional inference - - Status: `CLOSED_INVALID` - - Severity: `Medium` - - File: `apps/web/src/session-logic.ts:127` - - Threads: PRRT_kwDORLtfbc5v9SCp - - Rationale: Revert now uses explicit checkpointTurnCount first; positional fallback is non-primary. - -- [x] `C036` Duplicate `checkpointRefForThreadTurn` function in two production files - - Status: `CLOSED_INVALID` - - Severity: `Low` - - File: `apps/server/src/checkpointing/Layers/CheckpointStore.ts:284` - - Threads: PRRT_kwDORLtfbc5wiqFX - - Rationale: No longer duplicated; single production source via Refs.ts. - -- [x] `C055` Duplicate `checkpointRefForThreadTurn` function across files - - Status: `CLOSED_INVALID` - - Severity: `Low` - - File: `apps/server/src/wsServer.ts:128` - - Threads: PRRT_kwDORLtfbc5wkPaG - - Rationale: No longer duplicated; helper is centralized. diff --git a/.plans/17-claude-agent.md b/.plans/17-claude-agent.md deleted file mode 100644 index a2d906e0..00000000 --- a/.plans/17-claude-agent.md +++ /dev/null @@ -1,441 +0,0 @@ -# Plan: Claude Code Integration (Orchestration Architecture) - -## Why this plan was rewritten - -The previous plan targeted a pre-orchestration architecture (`ProviderManager`, provider-native WS event methods, and direct provider UI wiring). The current app now routes everything through: - -1. `orchestration.dispatchCommand` (client intent) -2. `OrchestrationEngine` (decide + persist + publish domain events) -3. `ProviderCommandReactor` (domain intent -> `ProviderService`) -4. `ProviderService` (adapter routing + canonical runtime stream) -5. `ProviderRuntimeIngestion` (provider runtime -> internal orchestration commands) -6. `orchestration.domainEvent` (single push channel consumed by web) - -Claude integration must plug into this path instead of reintroducing legacy provider-specific flows. - ---- - -## Current constraints to design around (post-Stage 1) - -1. Provider runtime ingestion expects canonical `ProviderRuntimeEvent` shapes, not provider-native payloads. -2. Start input now uses typed `providerOptions` and generic `resumeCursor`; top-level provider-specific fields were removed. -3. `resumeCursor` is intentionally opaque outside adapters and must never be synthesized from `providerThreadId`. -4. `ProviderService` still requires adapter `startSession()` to return a `ProviderSession` with `threadId`. -5. Checkpoint revert currently calls `providerService.rollbackConversation()`, so Claude adapter needs a rollback strategy compatible with current reactor behavior. -6. Web currently marks Claude as unavailable (`"Claude Code (soon)"`) and model picker is Codex-only. - ---- - -## Architecture target - -Add Claude as a first-class provider adapter that emits canonical runtime events and works with existing orchestration reactors without adding new WS channels or bypass paths. - -Key decisions: - -1. Keep orchestration provider-agnostic; adapt Claude inside adapter/layer boundaries. -2. Use the existing canonical runtime stream (`ProviderRuntimeEvent`) as the only ingestion contract. -3. Keep provider session routing in `ProviderService` and `ProviderSessionDirectory`. -4. Add explicit provider selection to turn-start intent so first turn can start Claude session intentionally. - ---- - -## Phase 1: Contracts and command shape updates - -### 1.1 Provider-aware model contract - -Update `packages/contracts/src/model.ts` so model resolution can be provider-aware instead of Codex-only. - -Expected outcomes: - -1. Introduce provider-scoped model lists (Codex + Claude). -2. Add helpers that resolve model by provider. -3. Preserve backwards compatibility for existing Codex defaults. - -### 1.2 Turn-start provider intent - -Update `packages/contracts/src/orchestration.ts`: - -1. Add optional `provider: ProviderKind` to `ThreadTurnStartCommand`. -2. Carry provider through `ThreadTurnStartRequestedPayload`. -3. Keep existing command valid when provider is omitted. - -This removes the implicit “Codex unless session already exists” behavior as the only path. - -### 1.3 Provider session start input for Claude runtime knobs (completed) - -Update `packages/contracts/src/provider.ts`: - -1. Move provider-specific start fields into typed `providerOptions`: - - `providerOptions.codex` - - `providerOptions.claudeCode` -2. Keep `resumeCursor` as the single cross-provider resume input in `ProviderSessionStartInput`. -3. Deprecate/remove `resumeThreadId` from the generic start contract. -4. Treat `resumeCursor` as adapter-owned opaque state. - -### 1.4 Contract tests (completed) - -Update/add tests in `packages/contracts/src/*.test.ts` for: - -1. New command payload shape. -2. Provider-aware model resolution behavior. -3. Breaking-change expectations for removed top-level provider fields. - ---- - -## Phase 2: Claude adapter implementation - -### 2.1 Add adapter service + layer - -Create: - -1. `apps/server/src/provider/Services/ClaudeAdapter.ts` -2. `apps/server/src/provider/Layers/ClaudeAdapter.ts` - -Adapter must implement `ProviderAdapterShape`. - -### 2.1.a SDK dependency and baseline config - -Add server dependency: - -1. `@anthropic-ai/claude-agent-sdk` - -Baseline adapter options to support from day one: - -1. `cwd` -2. `model` -3. `pathToClaudeCodeExecutable` (from `providerOptions.claudeCode.binaryPath`) -4. `permissionMode` (from `providerOptions.claudeCode.permissionMode`) -5. `maxThinkingTokens` (from `providerOptions.claudeCode.maxThinkingTokens`) -6. `resume` -7. `resumeSessionAt` -8. `includePartialMessages` -9. `canUseTool` -10. `hooks` -11. `env` and `additionalDirectories` (if needed for sandbox/workspace parity) - -### 2.2 Claude runtime bridge - -Implement a Claude runtime bridge (either directly in adapter layer or via dedicated manager file) that wraps Agent SDK query lifecycle. - -Required capabilities: - -1. Long-lived session context per adapter session. -2. Multi-turn input queue. -3. Interrupt support. -4. Approval request/response bridge. -5. Resume support via opaque `resumeCursor` (parsed inside Claude adapter only). - -#### 2.2.a Agent SDK details to preserve - -The adapter should explicitly rely on these SDK capabilities: - -1. `query()` returns an async iterable message stream and control methods (`interrupt`, `setModel`, `setPermissionMode`, `setMaxThinkingTokens`, account/status helpers). -2. Multi-turn input is supported via async-iterable prompt input. -3. Tool approval decisions are provided via `canUseTool`. -4. Resume support uses `resume` and optional `resumeSessionAt`, both derived by parsing adapter-owned `resumeCursor`. -5. Hooks can be used for lifecycle signals (`Stop`, `PostToolUse`, etc.) when we need adapter-originated checkpoint/runtime events. - -#### 2.2.b Effect-native session lifecycle skeleton - -```ts -import { query } from "@anthropic-ai/claude-agent-sdk"; -import { Effect } from "effect"; - -const acquireSession = (input: ProviderSessionStartInput) => - Effect.acquireRelease( - Effect.tryPromise({ - try: async () => { - const claudeOptions = input.providerOptions?.claudeCode; - const resumeState = readClaudeResumeState(input.resumeCursor); - const abortController = new AbortController(); - const result = query({ - prompt: makePromptAsyncIterable(), - options: { - cwd: input.cwd, - model: input.model, - permissionMode: claudeOptions?.permissionMode, - maxThinkingTokens: claudeOptions?.maxThinkingTokens, - pathToClaudeCodeExecutable: claudeOptions?.binaryPath, - resume: resumeState?.threadId, - resumeSessionAt: resumeState?.sessionAt, - signal: abortController.signal, - includePartialMessages: true, - canUseTool: makeCanUseTool(), - hooks: makeClaudeHooks(), - }, - }); - return { abortController, result }; - }, - catch: (cause) => - new ProviderAdapterProcessError({ - provider: "claudeCode", - sessionId: "pending", - detail: "Failed to start Claude runtime session.", - cause, - }), - }), - ({ abortController }) => Effect.sync(() => abortController.abort()), - ); -``` - -#### 2.2.c AsyncIterable -> Effect Stream integration - -Preferred when available in the pinned Effect version: - -```ts -const sdkMessageStream = Stream.fromAsyncIterable( - session.result, - (cause) => - new ProviderAdapterProcessError({ - provider: "claudeCode", - sessionId, - detail: "Claude runtime stream failed.", - cause, - }), -); -``` - -Portable fallback (already aligned with current server patterns): - -```ts -const sdkMessageStream = Stream.async((emit) => { - let cancelled = false; - void (async () => { - try { - for await (const message of session.result) { - if (cancelled) break; - emit.single(message); - } - emit.end(); - } catch (cause) { - emit.fail( - new ProviderAdapterProcessError({ - provider: "claudeCode", - sessionId, - detail: "Claude runtime stream failed.", - cause, - }), - ); - } - })(); - return Effect.sync(() => { - cancelled = true; - }); -}); -``` - -### 2.3 Canonical event mapping - -Claude adapter must translate Agent SDK output into canonical `ProviderRuntimeEvent`. - -Initial mapping target: - -1. assistant text deltas -> `content.delta` -2. final assistant text -> `item.completed` and/or `turn.completed` -3. approval requests -> `request.opened` -4. approval results -> `request.resolved` -5. system lifecycle -> `session.*`, `thread.*`, `turn.*` -6. errors -> `runtime.error` -7. plan/proposed-plan content when derivable - -Implementation note: - -1. Keep raw Claude message on `raw` for debugging. -2. Prefer canonical item/request kinds over provider-native enums. -3. If Claude emits extra event kinds we do not model yet, map them to `tool.summary`, `runtime.warning`, or `unknown`-compatible payloads instead of dropping silently. - -### 2.4 Resume cursor strategy - -Define Claude-owned opaque resume state, e.g.: - -```ts -interface ClaudeResumeCursor { - readonly version: 1; - readonly threadId?: string; - readonly sessionAt?: string; -} -``` - -Rules: - -1. Serialize only adapter-owned state into `resumeCursor`. -2. Parse/validate only inside Claude adapter. -3. Store updated cursor when Claude runtime yields enough data to resume safely. -4. Never overload orchestration thread id as Claude thread id. - -### 2.5 Interrupt and stop semantics - -Map orchestration stop/interrupt expectations onto SDK controls: - -1. `interruptTurn()` -> active query interrupt. -2. `stopSession()` -> close session resources and prevent future sends. -3. `rollbackThread()` -> see Phase 4. - ---- - -## Phase 3: Provider service and composition - -### 3.1 Register Claude adapter - -Update provider registry layer to include Claude: - -1. add `claudeCode` -> `ClaudeAdapter` -2. ensure `ProviderService.listProviderStatuses()` reports Claude availability - -### 3.2 Persist provider binding - -Current `ProviderSessionDirectory` already stores provider/thread binding and opaque `resumeCursor`. - -Required validation: - -1. Claude bindings survive restart. -2. resume cursor remains opaque and round-trips untouched. -3. stopAll + restart can recover Claude sessions when possible. - -### 3.3 Provider start routing - -Update `ProviderCommandReactor` / orchestration flow: - -1. If a thread turn start requests `provider: "claudeCode"`, start Claude if no active session exists. -2. If a thread already has Claude session binding, reuse it. -3. If provider switches between Codex and Claude, explicitly stop/rebind before next send. - ---- - -## Phase 4: Checkpoint and revert strategy - -Claude does not necessarily expose the same conversation rewind primitive as Codex app-server. Current architecture expects `providerService.rollbackConversation()`. - -Pick one explicit strategy: - -### Option A: provider-native rewind - -If SDK/runtime supports safe rewind: - -1. implement in Claude adapter -2. keep `CheckpointReactor` unchanged - -### Option B: session restart + state truncation shim - -If no native rewind exists: - -1. Claude adapter returns successful rollback by: - - stopping current Claude session - - clearing/rewriting stored Claude resume cursor to last safe resumable point - - forcing next turn to recreate session from persisted orchestration state -2. Document that rollback is “conversation reset to checkpoint boundary”, not provider-native turn deletion. - -Whichever option is chosen: - -1. behavior must be deterministic -2. checkpoint revert tests must pass under orchestration expectations -3. user-visible activity log should explain failures clearly when provider rollback is impossible - ---- - -## Phase 5: Web integration - -### 5.1 Provider picker and model picker - -Update web state/UI: - -1. allow choosing Claude as thread provider before first turn -2. show Claude model list from provider-aware model helpers -3. preserve existing Codex default behavior when provider omitted - -Likely touch points: - -1. `apps/web/src/store.ts` -2. `apps/web/src/components/ChatView.tsx` -3. `apps/web/src/types.ts` -4. `packages/shared/src/model.ts` - -### 5.2 Settings for Claude executable/options - -Add app settings if needed for: - -1. Claude binary path -2. default permission mode -3. default max thinking tokens - -Do not hardcode provider-specific config into generic session state if it belongs in app settings or typed `providerOptions`. - -### 5.3 Session rendering - -No new WS channel should be needed. Claude should appear through existing: - -1. thread messages -2. activities/worklog -3. approvals -4. session state -5. checkpoints/diffs - ---- - -## Phase 6: Testing strategy - -### 6.1 Contract tests - -Cover: - -1. provider-aware model schemas -2. provider field on turn-start command -3. provider-specific start options schema - -### 6.2 Adapter layer tests - -Add `ClaudeAdapter.test.ts` covering: - -1. session start -2. event mapping -3. approval bridge -4. resume cursor parse/serialize -5. interrupt behavior -6. rollback behavior or explicit unsupported error path - -Use SDK-facing layer tests/mocks only at the boundary. Do not mock orchestration business logic in higher-level tests. - -### 6.3 Provider service integration tests - -Extend provider integration coverage so Claude is exercised through `ProviderService`: - -1. start Claude session -2. send turn -3. receive canonical runtime events -4. restart/recover using persisted binding - -### 6.4 Orchestration integration tests - -Add/extend integration tests around: - -1. first-turn provider selection -2. Claude approval requests routed through orchestration -3. Claude runtime ingestion -> messages/activities/session updates -4. checkpoint revert behavior under Claude -5. stopAll/restart recovery - -These should validate real orchestration flows, not just adapter behavior. - ---- - -## Phase 7: Rollout order - -Recommended implementation order: - -1. contracts/provider-aware models -2. provider field on turn-start -3. Claude adapter skeleton + start/send/stream -4. canonical event mapping -5. provider registry/service wiring -6. orchestration recovery + checkpoint strategy -7. web provider/model picker -8. full integration tests - ---- - -## Non-goals - -1. Reintroducing provider-specific WS methods/channels. -2. Storing provider-native thread ids as orchestration ids. -3. Bypassing orchestration engine for Claude-specific UI flows. -4. Encoding Claude resume semantics outside adapter-owned `resumeCursor`. diff --git a/.plans/17-provider-neutral-runtime-determinism.md b/.plans/17-provider-neutral-runtime-determinism.md deleted file mode 100644 index d70ec105..00000000 --- a/.plans/17-provider-neutral-runtime-determinism.md +++ /dev/null @@ -1,109 +0,0 @@ -# Plan: Provider-Neutral Runtime Determinism and Flake Elimination - -## Summary -Replace timing-sensitive websocket and orchestration behavior with explicit typed runtime boundaries, ordered push delivery, and server-owned completion receipts. The cutover is broad and single-shot: no compatibility shim, no mixed old/new transport. The design must reduce flakes without baking Codex-specific lifecycle semantics into generic runtime code. - -## Implementation Status - -All 7 sections are implemented. CI passes (format, lint, typecheck, test, browser test, build). One deferred item remains: the shared `WsTestClient` helper from section 7 — tests use direct transport subscription and receipt-based waits instead. - -### New files - -| File | Purpose | -|------|---------| -| `packages/shared/src/DrainableWorker.ts` | Queue-based Effect worker with deterministic `drain` signal | -| `packages/shared/src/schemaJson.ts` | Two-phase JSON→Schema decode helpers (`decodeJsonResult`, `formatSchemaError`) | -| `apps/server/src/wsServer/pushBus.ts` | `ServerPushBus` — ordered typed push pipeline with auto-incrementing sequence | -| `apps/server/src/wsServer/readiness.ts` | `ServerReadiness` — Deferred-based barriers for startup sequencing | -| `apps/server/src/orchestration/Services/RuntimeReceiptBus.ts` | Receipt schema union: checkpoint captured, diff finalized, turn quiesced | -| `apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts` | PubSub-backed receipt bus implementation | -| `apps/server/src/watchFileWithStatPolling.ts` | Stat-polling file watcher for containers where `fs.watch` is unreliable | -| `apps/server/vitest.config.ts` | Server-specific test config (timeout bumps) | -| `apps/server/src/wsServer/pushBus.test.ts` | Push bus serialization and welcome-gating tests | -| `packages/shared/src/DrainableWorker.test.ts` | Drainable worker enqueue/drain lifecycle tests | - -### Key modifications - -| File | Change | -|------|--------| -| `packages/contracts/src/ws.ts` | Channel-indexed `WsPushPayloadByChannel` map, `WsPush` union schema, `WsPushSequence` | -| `apps/server/src/wsServer.ts` | Integrated `ServerPushBus` and `ServerReadiness`; welcome gated on readiness | -| `apps/server/src/keybindings.ts` | Explicit runtime with `start`/`ready`/`snapshot`; dual `fs.watch` + stat-polling watcher | -| `apps/web/src/wsTransport.ts` | Connection state machine (`connecting`→`open`→`reconnecting`→`closed`→`disposed`); two-phase decode at boundary; cached latest push by channel | -| `apps/web/src/wsNativeApi.ts` | Removed decode logic; delegates to pre-validated transport messages | -| `apps/server/src/orchestration/Layers/CheckpointReactor.ts` | Uses `DrainableWorker`; publishes completion receipts | -| `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` | Uses `DrainableWorker` for command processing | -| `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` | Uses `DrainableWorker` for event ingestion | -| `apps/server/integration/OrchestrationEngineHarness.integration.ts` | Receipt-based waits replace polling loops | - -## Key Changes -### 1. Strengthen the generic boundaries, not the Codex boundary — DONE -- `ProviderRuntimeEvent` remains the canonical provider event contract; `ProviderService` remains the only cross-provider facade. -- Raw Codex payloads and event ordering stay isolated in `CodexAdapter.ts` and `codexAppServerManager.ts`. -- `ProviderKind` was not expanded. The runtime stays provider-neutral by contract. - -### 2. Replace loose websocket envelopes with channel-indexed typed pushes — DONE -- `packages/contracts/src/ws.ts` now derives push messages from a `WsPushPayloadByChannel` channel-to-schema map. `WsPush` is a union schema replacing `channel: string` + `data: unknown`. -- Every server push carries `sequence: number`, auto-incremented in `ServerPushBus`. -- `packages/shared/src/schemaJson.ts` provides structured decode diagnostics via `formatSchemaError`. -- `packages/contracts/src/ws.test.ts` covers typed push envelope validation and channel/payload mismatch rejection. - -### 3. Introduce explicit server readiness and a single push pipeline — DONE -- `apps/server/src/wsServer/pushBus.ts`: `ServerPushBus` with `publishAll` (broadcast) and `publishClient` (targeted) methods, backed by one ordered path. All pushes flow through it. -- `apps/server/src/wsServer/readiness.ts`: `ServerReadiness` with Deferred-based barriers for HTTP listening, push bus, keybindings, terminal subscriptions, and orchestration subscriptions. -- `server.welcome` is emitted only after connection-scoped and server-scoped readiness is complete. -- `wsServer.ts` no longer publishes directly from ad hoc background streams. - -### 4. Turn background watchers into explicit runtimes — DONE -- `apps/server/src/keybindings.ts` refactored as explicit `KeybindingsShape` service with `start`, `ready`, `snapshot` semantics. -- Initial config load, cache warmup, and dual watcher attachment (`fs.watch` + `watchFileWithStatPolling`) complete before `ready` resolves. -- `watchFileWithStatPolling.ts` is the thin adapter for environments where `fs.watch` is unreliable. - -### 5. Replace polling-based orchestration waiting with receipts — DONE -- `RuntimeReceiptBus` service defines three receipt types: `CheckpointBaselineCapturedReceipt`, `CheckpointDiffFinalizedReceipt` (with `status: "ready"|"missing"|"error"`), and `TurnProcessingQuiescedReceipt`. -- `CheckpointReactor`, `ProviderCommandReactor`, and `ProviderRuntimeIngestion` use `DrainableWorker` and publish receipts on completion. -- Integration harness and checkpoint tests await receipts instead of polling snapshots and git refs. - -### 6. Centralize client transport state and decoding — DONE -- `apps/web/src/wsTransport.ts` implements an explicit connection state machine: `connecting`, `open`, `reconnecting`, `closed`, `disposed`. -- Two-phase decode (JSON parse → Schema validate) happens at the transport boundary. `wsNativeApi.ts` receives pre-validated messages. -- Cached latest welcome/config modeled as explicit `latestPushByChannel` state. - -### 7. Replace ad hoc test helpers with semantic test clients — MOSTLY DONE -- `DrainableWorker` replaces timing-sensitive `Effect.sleep` with deterministic `drain()` across reactor tests. -- Orchestration harness waits on receipts/barriers instead of `waitForThread`, `waitForGitRef`, and retry loops. -- Behavioral assertions moved to deterministic unit-style harnesses; narrow integration tests kept for real filesystem/socket behavior. -- **Deferred:** Shared `WsTestClient` helper (connect, awaitSemanticWelcome, awaitTypedPush, trackSequence, matchRpcResponseById). Tests use direct transport subscription instead. - -## Provider-Coupling Guardrails -- No generic runtime API may depend on Codex-native event names, thread IDs, or request payload shapes. -- No readiness barrier may be defined as "Codex has emitted X." Readiness is owned by the server runtime, not by provider event order. -- No websocket channel payload may contain raw provider-native payloads unless the channel is explicitly debug/internal. -- Any provider-specific divergence must be exposed through provider capabilities from `ProviderService.getCapabilities()`, not `if provider === "codex"` branches in shared runtime code. -- Generic tests must use canonical `ProviderRuntimeEvent` fixtures. Codex-specific ordering and translation tests stay in adapter/app-server suites only. -- Keep UI/provider-specific knobs such as Codex-only options scoped to provider UX code. Do not pull them into generic transport or orchestration state. - -## Test Plan -- Contracts: - - schema tests for typed push envelopes and structured decode diagnostics - - ordering tests for `sequence` -- Server: - - readiness tests proving `server.welcome` cannot precede runtime readiness - - push bus tests proving terminal/config/orchestration pushes are serialized and typed - - keybindings runtime tests with fake watch source plus one real watcher integration test -- Orchestration: - - receipt tests proving checkpoint refs and projections are complete before completion signals resolve - - replacement of polling-based checkpoint/integration waits with receipt-based waits -- Web: - - transport tests for invalid JSON, invalid envelope, invalid payload, reconnect queue flushing, cached semantic state -- Validation gate: - - `bun run lint` - - `bun run typecheck` - - `mise exec -- bun run test` - - repeated full-suite run after cutover to confirm flake removal - -## Assumptions and Defaults -- This remains a single-provider product during the cutover, but the runtime contracts must stay provider-neutral. -- No backward-compatibility layer is required for old websocket push envelopes. -- The goal is deterministic runtime behavior first; reducing retries and sleeps in tests is a consequence, not the primary mechanism. -- If a completion signal cannot be expressed provider-neutrally, it does not belong in the shared runtime layer and must stay adapter-local. diff --git a/.plans/18-server-auth-model.md b/.plans/18-server-auth-model.md deleted file mode 100644 index 9f8ba8a0..00000000 --- a/.plans/18-server-auth-model.md +++ /dev/null @@ -1,823 +0,0 @@ -# Server Auth Model Plan - -## Purpose - -Define the long-term server auth architecture for T3 Code before first-class remote environments ship. - -This plan is deliberately broader than the current WebSocket token check and narrower than a complete remote collaboration system. The goal is to make the server secure by default, keep local desktop UX frictionless, and leave clean integration points for future remote access methods. - -This document is written in terms of Effect-native services and layers because auth needs to be a core runtime concern, not route-local glue code. - -## Primary goals - -- Make auth server-wide, not WebSocket-only. -- Make insecure exposure hard to do accidentally. -- Preserve zero-login local desktop UX for desktop-managed environments. -- Support browser-native pairing and session auth. -- Leave room for native/mobile credentials later without rewriting the server boundary. -- Keep auth separate from transport and launch method. - -## Non-goals - -- Full multi-user authorization and RBAC. -- OAuth / SSO / enterprise identity. -- Passkeys or biometric UX in v1. -- Syncing auth state across environments. -- Designing the full remote environment product in this document. - -## Core decisions - -### 1. Auth is a server concern - -Every privileged surface of the T3 server must go through the same auth policy engine: - -- HTTP routes -- WebSocket upgrades -- RPC methods reached through WebSocket - -The current split where [`/ws`](../apps/server/src/ws.ts) checks `authToken` but routes in [`http.ts`](../apps/server/src/http.ts) do not is not sufficient for a remote-capable product. - -### 2. Pairing and session are different things - -The system should distinguish: - -- bootstrap credentials -- session credentials - -Bootstrap credentials are short-lived and high-trust. They allow a client to become authenticated. - -Session credentials are the durable credentials used after pairing. - -Bootstrap should never become the long-lived request credential. - -### 3. Auth and transport are separate - -Auth must not be defined by how the client reached the server. - -Examples: - -- local desktop-managed server -- LAN `ws://` -- public `wss://` -- tunneled `wss://` -- SSH-forwarded `ws://127.0.0.1:` - -All of these should feed into the same auth model. - -### 4. Exposure level changes defaults - -The more exposed an environment is, the narrower the safe default should be. - -Safe default expectations: - -- local desktop-managed: auto-pair allowed -- loopback browser access: explicit bootstrap allowed -- non-loopback bind: auth required -- tunnel/public endpoint: auth required, explicit enablement required - -### 5. Browser and native clients may use different session credentials - -The auth model should support more than one session credential type even if only one ships first. - -Examples: - -- browser session cookie -- native bearer/device token - -This should be represented in the model now, even if browser cookies are the first implementation. - -## Target auth domain - -### Route classes - -Every route or transport entrypoint should be classified as one of: - -1. `public` -2. `bootstrap` -3. `authenticated` - -#### `public` - -Unauthenticated by definition. - -Should be extremely small. Examples: - -- static shell needed to render the pairing/login UI -- favicon/assets required for the pairing screen -- a minimal server health/version endpoint if needed - -#### `bootstrap` - -Used only to exchange a bootstrap credential for a session. - -Examples: - -- Initial bootstrap envelope over file descriptor at startup -- `POST /api/auth/bootstrap` -- `GET /api/auth/session` if unauthenticated checks are part of bootstrap UX - -#### `authenticated` - -Everything that reveals machine state or mutates it. - -Examples: - -- WebSocket upgrade -- orchestration snapshot and events -- terminal open/write/close -- project search and file writes -- git routes -- attachments -- project favicon lookup -- server settings - -The default stance should be: if it touches the machine, it is authenticated. - -## Credential model - -### Bootstrap credentials - -Initial credential types to model: - -- `desktop-bootstrap` -- `one-time-token` - -Possible future credential types: - -- `device-code` -- `passkey-assertion` -- `external-identity` - -#### `desktop-bootstrap` - -Used when the desktop shell manages the server and should be the only default pairing method for desktop-local environments. - -Properties: - -- launcher-provided -- short-lived -- one-time or bounded-use -- never shown to the user as a reusable password - -#### `one-time-token` - -Used for explicit browser/mobile pairing flows. - -Properties: - -- short TTL -- one-time use -- safe to embed in a pairing URL fragment -- exchanged for a session credential - -### Session credentials - -Initial credential types to model: - -- `browser-session-cookie` -- `bearer-session-token` - -#### `browser-session-cookie` - -Primary browser credential. - -Properties: - -- signed -- `HttpOnly` -- bounded lifetime -- revocable by server key rotation or session invalidation - -#### `bearer-session-token` - -Reserved for native/mobile or non-browser clients. - -Properties: - -- opaque token, not a bootstrap secret -- long enough lifetime to survive reconnects -- stored in secure client storage when available - -## Auth policy model - -Auth behavior should be driven by an explicit environment auth policy, not route-local heuristics. - -### Policy examples - -#### `DesktopManagedLocalPolicy` - -Default for desktop-managed local server. - -Allowed bootstrap methods: - -- `desktop-bootstrap` - -Allowed session methods: - -- `browser-session-cookie` - -Disabled by default: - -- `one-time-token` -- `bearer-session-token` -- password login -- public pairing - -#### `LoopbackBrowserPolicy` - -Used for browser access on localhost without desktop-managed bootstrap. - -Allowed bootstrap methods: - -- `one-time-token` - -Allowed session methods: - -- `browser-session-cookie` - -#### `RemoteReachablePolicy` - -Used when binding non-loopback or using an explicit remote/tunnel workflow. - -Allowed bootstrap methods: - -- `one-time-token` -- possibly `desktop-bootstrap` when a desktop shell is brokering access - -Allowed session methods: - -- `browser-session-cookie` -- `bearer-session-token` - -#### `UnsafeNoAuthPolicy` - -Should exist only as an explicit escape hatch. - -Requirements: - -- explicit opt-in flag -- loud startup warnings -- never defaulted automatically - -## Effect-native service model - -### `ServerAuth` - -The main auth facade used by HTTP routes and WebSocket upgrade handling. - -Responsibilities: - -- classify requests -- authenticate requests -- authorize bootstrap attempts -- create sessions from bootstrap credentials -- enforce policy by environment mode - -Sketch: - -```ts -export interface ServerAuthShape { - readonly getCapabilities: Effect.Effect; - readonly authenticateHttpRequest: ( - request: HttpServerRequest.HttpServerRequest, - routeClass: RouteAuthClass, - ) => Effect.Effect; - readonly authenticateWebSocketUpgrade: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; - readonly exchangeBootstrapCredential: ( - input: BootstrapExchangeInput, - ) => Effect.Effect; -} - -export class ServerAuth extends ServiceMap.Service()( - "t3/ServerAuth", -) {} -``` - -### `BootstrapCredentialService` - -Owns issuance, storage, validation, and consumption of bootstrap credentials. - -Responsibilities: - -- issue desktop bootstrap grants -- issue one-time pairing tokens -- validate TTL and single-use semantics -- consume bootstrap grants atomically - -Sketch: - -```ts -export interface BootstrapCredentialServiceShape { - readonly issueDesktopBootstrap: ( - input: IssueDesktopBootstrapInput, - ) => Effect.Effect; - readonly issueOneTimeToken: ( - input: IssueOneTimeTokenInput, - ) => Effect.Effect; - readonly consume: ( - presented: PresentedBootstrapCredential, - ) => Effect.Effect; -} -``` - -### `SessionCredentialService` - -Owns creation and validation of authenticated sessions. - -Responsibilities: - -- mint cookie sessions -- mint bearer sessions -- validate active session credentials -- revoke sessions if needed later - -Sketch: - -```ts -export interface SessionCredentialServiceShape { - readonly createBrowserSession: ( - input: CreateSessionFromBootstrapInput, - ) => Effect.Effect; - readonly createBearerSession: ( - input: CreateSessionFromBootstrapInput, - ) => Effect.Effect; - readonly authenticateCookie: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; - readonly authenticateBearer: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; -} -``` - -### `ServerAuthPolicy` - -Pure policy/config service that decides which credential types are allowed. - -Responsibilities: - -- map runtime mode and bind/exposure settings to allowed auth methods -- answer whether a route can be public -- answer whether remote exposure requires auth - -This should stay mostly pure and cheap to test. - -### `ServerSecretStore` - -Owns long-lived server signing keys and secrets. - -Responsibilities: - -- get or create signing key -- rotate signing key -- abstract secure OS-backed storage vs filesystem fallback - -Important: - -- prefer platform secure storage when available -- support hardened filesystem fallback for headless/server-only environments - -### `BrowserSessionCookieCodec` - -Focused utility service for cookie encode/decode/signing behavior. - -This should not own policy. It should only own the cookie format. - -### `AuthRouteGuards` - -Thin helper layer used by routes to enforce auth consistently. - -Responsibilities: - -- require auth for HTTP route handlers -- classify route auth mode -- convert auth failures into `401` / `403` - -This prevents every route from re-implementing the same pattern. - -Integrates with `HttpRouter.middleware` to enforce auth consistently. - -## Suggested layer graph - -```text -ServerSecretStore - ├─> BootstrapCredentialService - ├─> BrowserSessionCookieCodec - └─> SessionCredentialService - -ServerAuthPolicy - ├─> BootstrapCredentialService - ├─> SessionCredentialService - └─> ServerAuth - -ServerAuth - └─> AuthRouteGuards -``` - -Layer naming should follow existing repo style: - -- `ServerSecretStoreLive` -- `BootstrapCredentialServiceLive` -- `SessionCredentialServiceLive` -- `ServerAuthPolicyLive` -- `ServerAuthLive` -- `AuthRouteGuardsLive` - -## High-level implementation examples - -### Example: WebSocket upgrade auth - -Current state: - -- `authToken` query param is checked in [`ws.ts`](../apps/server/src/ws.ts) - -Target shape: - -```ts -const websocketUpgradeAuth = HttpMiddleware.make((httpApp) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const serverAuth = yield* ServerAuth; - yield* serverAuth.authenticateWebSocketUpgrade(request); - return yield* httpApp; - }), -); -``` - -Then the `/ws` route becomes: - -```ts -export const websocketRpcRouteLayer = HttpRouter.add( - "GET", - "/ws", - rpcWebSocketHttpEffect.pipe( - websocketUpgradeAuth, - Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), - ), -); -``` - -This keeps the route itself declarative and makes auth compose like normal HTTP middleware. - -### Example: authenticated HTTP route - -For routes like attachments or project favicon: - -```ts -const authenticatedRoute = (routeClass: RouteAuthClass) => - HttpMiddleware.make((httpApp) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const serverAuth = yield* ServerAuth; - yield* serverAuth.authenticateHttpRequest(request, routeClass); - return yield* httpApp; - }), - ); -``` - -Then: - -```ts -export const attachmentsRouteLayer = HttpRouter.add( - "GET", - `${ATTACHMENTS_ROUTE_PREFIX}/*`, - serveAttachment.pipe( - authenticatedRoute(RouteAuthClass.Authenticated), - Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), - ), -); -``` - -### Example: desktop bootstrap exchange - -The desktop shell launches the local server and gets a short-lived bootstrap grant through a trusted side channel. - -That grant is then exchanged for a browser cookie session when the renderer loads. - -Sketch: - -```ts -const pairDesktopRenderer = Effect.gen(function* () { - const bootstrapService = yield* BootstrapCredentialService; - const credential = yield* bootstrapService.issueDesktopBootstrap({ - audience: "desktop-renderer", - ttlMs: 30_000, - }); - return credential; -}); -``` - -The renderer then calls a bootstrap endpoint and receives a cookie session. The bootstrap credential is consumed and becomes invalid. - -### Example: one-time pairing URL - -For browser-driven pairing: - -```ts -const createPairingToken = Effect.gen(function* () { - const bootstrapService = yield* BootstrapCredentialService; - return yield* bootstrapService.issueOneTimeToken({ - ttlMs: 5 * 60_000, - audience: "browser", - }); -}); -``` - -The server can emit a pairing URL where the token lives in the URL fragment so it is not automatically sent to the server before the client explicitly exchanges it. - -## Sequence diagrams - -These flows are meant to anchor the auth model in concrete user journeys. - -The important invariant across all of them is: - -- access method is not the auth method -- launch method is not the auth method -- bootstrap credential is not the session credential - -### Normal desktop user - -This is the default desktop-managed local flow. - -The desktop shell is trusted to bootstrap the local renderer, but the renderer should still exchange that one-time bootstrap grant for a normal browser session cookie. - -```text -Participants: - DesktopMain = Electron main - SecretStore = secure local secret backend - T3Server = local backend child process - Frontend = desktop renderer - -DesktopMain -> SecretStore : getOrCreate("server-signing-key") -SecretStore --> DesktopMain : signing key available - -DesktopMain -> T3Server : spawn server (--bootstrap-fd ...) -DesktopMain -> T3Server : send desktop bootstrap envelope -note over T3Server : policy = DesktopManagedLocalPolicy -note over T3Server : allowed pairing = desktop-bootstrap only - -Frontend -> DesktopMain : request local bootstrap grant -DesktopMain --> Frontend : short-lived desktop bootstrap grant - -Frontend -> T3Server : POST /api/auth/bootstrap -T3Server -> T3Server : validate desktop bootstrap grant -T3Server -> T3Server : create browser session -T3Server --> Frontend : Set-Cookie: session=... - -Frontend -> T3Server : GET /ws + authenticated cookie -T3Server -> T3Server : validate cookie session -T3Server --> Frontend : websocket accepted -``` - -### `npx t3` user - -This is the standalone local server flow. - -There is no trusted desktop shell here, so pairing should be explicit. - -```text -Participants: - UserShell = npx t3 launcher - T3Server = standalone local server - Browser = browser tab - -UserShell -> T3Server : start server -T3Server -> T3Server : getOrCreate("server-signing-key") -note over T3Server : policy = LoopbackBrowserPolicy - -UserShell -> T3Server : issue one-time pairing token -T3Server --> UserShell : pairing URL or pairing token - -UserShell --> Browser : open /pair?token=... - -Browser -> T3Server : GET /pair?token=... -T3Server -> T3Server : validate one-time token -T3Server -> T3Server : create browser session -T3Server --> Browser : Set-Cookie: session=... -T3Server --> Browser : redirect to app - -Browser -> T3Server : GET /ws + authenticated cookie -T3Server --> Browser : websocket accepted -``` - -### Phone user with tunneled host - -This is the explicit remote access flow for a browser on another device. - -The tunnel only provides reachability. It must not imply trust. - -Recommended UX: - -- desktop shows a QR code -- desktop also shows a copyable pairing URL -- if the phone opens the host URL without a valid token, the server should render a login or pairing screen rather than granting access - -```text -Participants: - DesktopUser = user at the host machine - DesktopMain = desktop app - Tunnel = tunnel provider - T3Server = T3 server - PhoneBrowser = mobile browser - -DesktopUser -> DesktopMain : enable remote access via tunnel -DesktopMain -> T3Server : switch policy to RemoteReachablePolicy -DesktopMain -> Tunnel : publish local T3 endpoint -Tunnel --> DesktopMain : public https/wss URL - -DesktopMain -> T3Server : issue one-time pairing token -T3Server --> DesktopMain : pairing token -DesktopMain -> DesktopUser : show QR code / shareable URL - -DesktopUser -> PhoneBrowser : scan QR / open URL -PhoneBrowser -> Tunnel : GET https://public-host/pair?token=... -Tunnel -> T3Server : forward request -T3Server -> T3Server : validate one-time token -T3Server -> T3Server : create mobile browser session -T3Server --> PhoneBrowser : Set-Cookie: session=... -T3Server --> PhoneBrowser : redirect to app - -PhoneBrowser -> Tunnel : GET /ws + authenticated cookie -Tunnel -> T3Server : forward websocket upgrade -T3Server --> PhoneBrowser : websocket accepted -``` - -### Phone user with private network - -This is operationally similar to the tunnel flow, but the access endpoint is on a private network such as Tailscale. - -The auth flow should stay the same. - -```text -Participants: - DesktopUser = user at the host machine - T3Server = T3 server - PrivateNet = tailscale / private LAN - PhoneBrowser = mobile browser - -DesktopUser -> T3Server : enable private-network access -T3Server -> T3Server : switch policy to RemoteReachablePolicy -DesktopUser -> T3Server : issue one-time pairing token -T3Server --> DesktopUser : pairing URL / QR - -DesktopUser -> PhoneBrowser : open private-network URL -PhoneBrowser -> PrivateNet : GET http(s)://private-host/pair?token=... -PrivateNet -> T3Server : route request -T3Server -> T3Server : validate one-time token -T3Server -> T3Server : create mobile browser session -T3Server --> PhoneBrowser : Set-Cookie: session=... -T3Server --> PhoneBrowser : redirect to app - -PhoneBrowser -> PrivateNet : GET /ws + authenticated cookie -PrivateNet -> T3Server : websocket upgrade -T3Server --> PhoneBrowser : websocket accepted -``` - -### Desktop user adding new SSH hosts - -SSH should be treated as launch and reachability plumbing, not as the long-term auth model. - -The desktop app uses SSH to start or reach the remote server, then the renderer pairs against that server using the same bootstrap/session split as every other environment. - -```text -Participants: - DesktopUser = local desktop user - DesktopMain = desktop app - SSH = ssh transport/session - RemoteHost = remote machine - RemoteT3 = remote T3 server - Frontend = desktop renderer - -DesktopUser -> DesktopMain : add SSH host -DesktopMain -> SSH : connect to remote host -SSH -> RemoteHost : probe environment / verify t3 availability -DesktopMain -> SSH : run remote launch command -SSH -> RemoteHost : t3 remote launch --json -RemoteHost -> RemoteT3 : start or reuse server -RemoteT3 --> RemoteHost : port + environment metadata -RemoteHost --> SSH : launch result JSON -SSH --> DesktopMain : remote server details - -DesktopMain -> SSH : establish local port forward -SSH --> DesktopMain : localhost:FORWARDED_PORT ready - -note over RemoteT3 : policy = RemoteReachablePolicy -note over DesktopMain,RemoteT3 : desktop may use a trusted bootstrap flow here - -Frontend -> DesktopMain : request bootstrap for selected environment -DesktopMain --> Frontend : short-lived bootstrap grant - -Frontend -> RemoteT3 : POST /api/auth/bootstrap via forwarded port -RemoteT3 -> RemoteT3 : validate bootstrap grant -RemoteT3 -> RemoteT3 : create browser session -RemoteT3 --> Frontend : Set-Cookie: session=... - -Frontend -> RemoteT3 : GET /ws + authenticated cookie -RemoteT3 --> Frontend : websocket accepted -``` - -## Storage decisions - -### Server secrets - -Use a `ServerSecretStore` abstraction. - -Preferred order (use a layer for each, resolve on startup): - -1. OS secure storage if available -2. hardened filesystem fallback if not - -The filesystem fallback should store only opaque signing material with strict file permissions. It should not store user passwords or reusable third-party credentials. - -### Client credentials - -Client-side credential persistence should prefer secure storage when available: - -- desktop: OS keychain / secure store -- mobile: platform secure storage -- browser: cookie session for browser auth - -This concern should stay in the client shell/runtime layer, not the server auth layer. - -## What to build now - -These are the parts worth building before remote environments ship: - -1. `ServerAuth` service boundary. -2. route classification and route guards. -3. `ServerSecretStore` abstraction. -4. bootstrap vs session credential split. -5. browser session cookie codec as one session method. -6. explicit auth capabilities/config surfaced in contracts. - -Even if only one pairing flow is used initially, these seams will keep future remote and mobile work contained. - -## What to add as part of first remote-capable auth - -1. Browser pairing flow using one-time bootstrap token and cookie session. -2. Desktop-managed auto-bootstrap for the local desktop-managed environment. -3. Auth-required defaults for any non-loopback or explicitly published server. -4. Explicit environment auth policy selection instead of scattered `if (host !== localhost)` checks. - -## What to defer - -- passkeys / WebAuthn -- iCloud Keychain / Face ID-specific UX -- multi-user permissions -- collaboration roles -- OAuth / SSO -- polished session management UI -- complex device approval flows - -These can all sit on top of the same bootstrap/session/service split. - -## Relationship to future remote environments - -Remote access is one reason this auth model matters, but the auth model should not be remote-shaped. - -Keep the design focused on: - -- one T3 server -- one auth policy -- multiple credential types -- multiple future access methods - -That keeps the server auth model stable even as access methods expand later. - -## Recommended implementation order - -### Phase 1 - -- Introduce route auth classes. -- Add `ServerAuth` and `AuthRouteGuards`. -- Move existing `authToken` check behind `ServerAuth`. -- Require auth for all privileged HTTP routes as well as WebSocket. - -### Phase 2 - -- Add `ServerSecretStore` service with platform-specific layer implementations. - - `layerOSXKeychain`, `layer -- Add bootstrap/session split. -- Add browser session cookie support. -- Add one-time bootstrap exchange endpoint. - -### Phase 3 - -- Add desktop bootstrap flow on top of the same services. -- Make desktop-managed local environments default to bootstrap-only pairing. -- Surface auth capabilities in shared contracts and renderer bootstrap. - -### Phase 4 - -- Add non-browser bearer session support if mobile/native needs it. -- Add richer policy modes for remote-reachable environments. - -## Acceptance criteria - -- No privileged HTTP or WebSocket path bypasses auth policy. -- Local desktop-managed flows still avoid a visible login screen. -- Non-loopback or published environments require explicit authenticated pairing by default. -- Bootstrap and session credentials are distinct in code and in behavior. -- Auth logic is centralized in Effect services/layers rather than route-local branching. diff --git a/.plans/19-remote-endpoints-hosted-static.md b/.plans/19-remote-endpoints-hosted-static.md deleted file mode 100644 index ada2f681..00000000 --- a/.plans/19-remote-endpoints-hosted-static.md +++ /dev/null @@ -1,349 +0,0 @@ -# Remote Endpoints and Hosted Static App Plan - -## Purpose - -Make remote access feel first-class while keeping the free DIY path open. - -The immediate product goal is: - -- users can expose a backend through LAN, their own Tailscale, MagicDNS, a manual HTTPS endpoint, or later T3 Tunnel -- users can generate a hosted pairing link for `app.t3.codes` -- the hosted app can pair, persist, reconnect, and operate against saved environments without requiring a backend at the hosted app origin -- all transports reuse the same backend auth, WebSocket runtime, saved environment registry, and pairing UX - -This plan intentionally leaves the paid T3 cloud tunnel fabric out of scope. It defines the OSS foundation that T3 Tunnel should later plug into. - -## Current State - -Already present or in progress: - -- Server auth distinguishes bootstrap credentials from session credentials. -- One-time pairing credentials can be exchanged for browser sessions or bearer sessions. -- Saved remote environments store `httpBaseUrl`, `wsBaseUrl`, and a bearer token. -- Remote environment WebSocket connections use a short-lived WebSocket token. -- Pairing URLs can carry tokens in the URL fragment. -- Hosted `/pair?host=...#token=...` can add a saved environment. -- Hosted static startup can avoid assuming the page origin is the backend. - -Main gaps: - -- Reachability is represented ad hoc as `endpointUrl`, manual host input, or saved environment URLs. -- Desktop exposure, hosted pairing, manual remote environments, and future tunnels do not share one endpoint model. -- Tailscale/MagicDNS endpoints are not detected or surfaced. -- Hosted-static empty/offline states are still thin. -- Browser compatibility is not explicitly modeled, especially HTTPS hosted app to HTTP backend mixed-content failure. - -## Core Decision: Add `AdvertisedEndpoint` - -Add a new first-class contract instead of extending the environment descriptor. - -### Why not extend `ExecutionEnvironmentDescriptor` - -`ExecutionEnvironmentDescriptor` answers: "What environment is this?" - -Examples: - -- environment id -- label -- platform -- server version -- capabilities - -`AdvertisedEndpoint` answers: "How can a client reach this environment right now?" - -Examples: - -- loopback URL -- LAN URL -- Tailscale IP URL -- MagicDNS/Serve URL -- manual URL -- future T3 Tunnel URL -- browser compatibility and exposure level - -Those are different lifecycles. One environment can have many endpoints, endpoints can appear/disappear as network interfaces change, and the same descriptor is returned regardless of which endpoint the client used. Extending the descriptor would blur environment identity with transport reachability and make saved environments harder to reason about. - -### Target Contract - -Add a schema in `packages/contracts`, likely `remoteAccess.ts`: - -```ts -type AdvertisedEndpointProvider = - | "loopback" - | "lan" - | "tailscale-ip" - | "tailscale-magicdns" - | "manual" - | "t3-tunnel"; - -type AdvertisedEndpointVisibility = "local" | "private-network" | "tailnet" | "public"; - -type AdvertisedEndpointCompatibility = { - hostedHttpsApp: "compatible" | "mixed-content-blocked" | "untrusted-certificate" | "unknown"; - desktopApp: "compatible" | "unknown"; -}; - -type AdvertisedEndpoint = { - id: string; - provider: AdvertisedEndpointProvider; - label: string; - httpBaseUrl: string; - wsBaseUrl: string; - visibility: AdvertisedEndpointVisibility; - compatibility: AdvertisedEndpointCompatibility; - source: "server" | "desktop" | "user"; - status: "available" | "unavailable" | "unknown"; - isDefault?: boolean; -}; -``` - -Keep the contract schema-only. All classification logic belongs in `packages/shared`, `apps/server`, `apps/desktop`, or `apps/web`. - -## HTTP/WS and HTTPS/WSS Readiness - -The codebase is partially ready, but the UX and compatibility model are not explicit enough. - -What is ready: - -- Remote target parsing already derives `ws://` from `http://` and `wss://` from `https://`. -- Saved environments store both HTTP and WebSocket base URLs. -- Remote auth uses bearer tokens instead of cookies, so cross-origin hosted clients are viable. -- WebSocket connections can use a dynamically issued `wsToken`. -- Server CORS support exists for browser remote auth endpoints. - -What is not solved by code alone: - -- `https://app.t3.codes` cannot reliably call `http://...` or `ws://...` endpoints because browsers block mixed content. -- `wss://100.x.y.z:3773` needs a certificate the browser trusts. A raw Tailscale IP does not solve certificate trust. -- LAN `http://192.168.x.y:3773` is usable from another desktop/native context but not from the hosted HTTPS app. -- The UI needs to explain why an endpoint is copyable for desktop pairing but not hosted-app compatible. - -Policy: - -- Support both HTTP/WS and HTTPS/WSS at the runtime layer. -- Mark endpoint compatibility at the product layer. -- Generate `app.t3.codes` links only from endpoints that are likely hosted-browser compatible, or show a warning with an explicit fallback. - -## Architecture - -### Endpoint Sources - -Endpoint records can come from several providers: - -1. **Server runtime** - - headless bind host and port - - server-known explicit advertised host config - -2. **Desktop shell** - - loopback backend URL - - LAN exposure state - - network interface discovery - - Tailscale CLI/status discovery - -3. **User configuration** - - manually added hostnames - - preferred endpoint labels - - hidden/disabled endpoints - -4. **Future cloud provider** - - T3 Tunnel endpoint - - billing/account status - - tunnel lifecycle state - -### Endpoint Registry - -Create a central runtime registry: - -- `packages/contracts/src/remoteAccess.ts` -- `packages/shared/src/remoteAccess.ts` for URL normalization and compatibility classification -- `apps/server/src/remoteAccess/*` for server/headless endpoints -- `apps/desktop/src/remoteAccess/*` for desktop-discovered endpoints -- `apps/web/src/environments/endpoints/*` for client-side display and pairing selection - -The web app should consume endpoint records and not care whether they came from LAN, Tailscale, or a future tunnel. - -### Pairing Link Generation - -Move hosted pairing link generation to endpoint-driven input: - -```ts -buildHostedPairingUrl({ - endpoint: AdvertisedEndpoint, - token, -}); -``` - -Generated URL: - -```text -https://app.t3.codes/pair?host=#token= -``` - -Use fragment tokens by default. Continue accepting `?token=` for compatibility. - -## Phase 1: Endpoint Abstraction - -### Goals - -- Centralize URL normalization, protocol derivation, and compatibility checks. -- Replace ad hoc desktop `endpointUrl` pairing logic with endpoint selection. -- Preserve all current remote behavior. - -### Tasks - -1. Add `AdvertisedEndpoint` schemas to `packages/contracts`. -2. Add shared helpers: - - normalize HTTP base URL - - derive WebSocket base URL - - classify loopback/private/LAN/Tailscale/public host - - classify hosted HTTPS compatibility -3. Add server endpoint discovery: - - loopback endpoint - - configured non-loopback endpoint - - explicit advertised host override -4. Add desktop endpoint discovery: - - local loopback - - LAN exposure endpoint - - endpoint status labels -5. Add WebSocket/API method or existing config field for endpoint snapshots. -6. Refactor settings connections UI: - - render endpoint rows - - endpoint picker for pairing link copy - - show compatibility warnings -7. Refactor hosted link builder to accept endpoint records. -8. Add tests for URL normalization and compatibility classification. - -### Acceptance Criteria - -- Existing LAN/network access UI still works. -- Pairing links are generated from endpoint records. -- Loopback endpoints never produce hosted pairing links silently. -- HTTP private-network endpoints are marked incompatible with `app.t3.codes`. -- No remote environment runtime changes are required for existing saved environments. - -## Phase 2: BYO Tailscale/MagicDNS - -### Goals - -- Detect free DIY Tailscale reachability. -- Surface Tailscale endpoints as normal advertised endpoints. -- Keep users in control of their own tailnet. - -### Tasks - -1. Detect Tailscale IPs from network interfaces: - - IPv4 `100.64.0.0/10` - - mark as `provider: "tailscale-ip"` -2. Add optional desktop-side `tailscale status --json` discovery: - - MagicDNS hostname - - Tailscale Serve/Funnel HTTPS endpoint if discoverable - - graceful failure if CLI is missing -3. Add manual Tailscale endpoint override: - - hostname - - label - - preferred/default flag -4. Show Tailscale endpoint rows in settings: - - raw IP HTTP endpoint: desktop-compatible, hosted-app likely blocked - - HTTPS MagicDNS/Serve endpoint: hosted-compatible if URL is HTTPS -5. Generate pairing links using selected Tailscale endpoint. -6. Document DIY setup: - - local desktop-to-desktop over Tailscale - - hosted app requirements - - why HTTPS matters - -### Acceptance Criteria - -- A machine on Tailscale shows a Tailscale endpoint without paid features. -- Users can copy a Tailscale-hosted pairing link when the endpoint is HTTPS-compatible. -- Users can still copy token-only/manual values when endpoint compatibility is unknown. -- Tailscale is optional and never required for regular LAN/loopback use. - -## Phase 3: Hosted Static App Completion - -### Goals - -- `app.t3.codes` works as a real client shell. -- It can pair, persist, reconnect, and clearly explain offline/incompatible states. - -### Tasks - -1. Finish hosted-static root behavior: - - no primary backend required - - saved environment hydration before initial routing decisions - - first saved environment selected as active -2. Add hosted empty state: - - no saved environments - - paste pairing URL - - add host + token -3. Add offline saved environment UI: - - last connected - - reconnect - - remove - - copy/add alternate endpoint -4. Audit primary-backend assumptions: - - command palette - - settings pages - - server config atom defaults - - keybindings - - provider/model lists - - update/desktop-only affordances -5. Add route tests for: - - hosted `/pair?host=...#token=...` - - hosted root with no saved environments - - hosted root with saved environment - - primary backend unavailable but saved environment present -6. Add deployment hardening: - - SPA fallback - - strict CSP - - no third-party scripts - - no query token logging - - disable or hide source maps in production if needed -7. Add browser error messages: - - mixed content - - unreachable backend - - CORS failure - - certificate failure - -### Acceptance Criteria - -- `app.t3.codes` can pair a reachable HTTPS backend and reconnect after reload. -- A saved environment can be used without any backend at `app.t3.codes`. -- Offline machines show a useful state instead of a generic boot error. -- HTTP endpoints are still supported in desktop/native/local contexts. -- Hosted HTTPS app only promises compatibility for HTTPS/WSS endpoints. - -## Phase 4: Future T3 Tunnel Provider - -Not part of the current implementation, but the endpoint abstraction should make it straightforward. - -Future tunnel provider responsibilities: - -- create endpoint with `provider: "t3-tunnel"` -- surface tunnel status -- provide stable HTTPS URL -- use existing backend pairing/session auth -- never bypass server auth - -The tunnel fabric can later be Pipenet-derived, Tailscale-derived, or another reverse tunnel implementation. The rest of T3 Code should only see an `AdvertisedEndpoint`. - -## Security Checklist - -- Pairing tokens are short-lived and one-time. -- Generated hosted pairing links put tokens in the fragment. -- The backend remains the authorization boundary. -- Endpoint discovery never disables backend auth. -- Hosted app does not silently downgrade to HTTP. -- Tunnel/public endpoints require explicit user action. -- Client sessions remain revocable. -- Endpoint URLs and request logs must avoid recording pairing tokens. -- Future cloud tunnel must authenticate tunnel creation and tunnel data connections separately from backend pairing. - -## Verification - -Each implementation PR should run: - -- `bun fmt` -- `bun lint` -- `bun typecheck` -- focused tests for changed backend/web behavior -- backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test` diff --git a/.plans/19-version-control-phase-1-vcs-driver-foundation.md b/.plans/19-version-control-phase-1-vcs-driver-foundation.md deleted file mode 100644 index e71c22d0..00000000 --- a/.plans/19-version-control-phase-1-vcs-driver-foundation.md +++ /dev/null @@ -1,216 +0,0 @@ -# Version Control Phase 1: VCS Driver Foundation - -## Goal - -Introduce a provider-neutral VCS layer and rewrite the local Git implementation as an Effect-native driver. This phase should preserve user-visible behavior while replacing the Git-first service boundary with an abstraction that can support Git, Jujutsu, and later Sapling or another viable VCS. - -The existing `GitCore` implementation is a behavior reference and source of regression tests, not the target architecture. New code should follow the newer package style used by `effect-acp` and `effect-codex-app-server`: typed service tags, schema-backed tagged errors, scoped process usage, explicit decode boundaries, and no Promise-based process helper as the core execution primitive. - -## Scope - -- Add VCS-domain contracts in `packages/contracts/src/vcs.ts`. -- Add shared runtime parsing helpers in `packages/shared/src/vcs/*` only when they are useful to both server and web. -- Add server services under `apps/server/src/vcs`: - - `Services/VcsDriver.ts` - - `Services/VcsRepositoryResolver.ts` - - `Services/VcsProcess.ts` - - `Layers/GitVcsDriver.ts` - - `errors.ts` -- Migrate server callers from Git-specific terms where the operation is actually VCS-generic. -- Update active consumers to the new VCS APIs in the same phase; do not add backwards-compatible export shims. -- Leave source-control hosting providers out of this phase except for remote metadata needed to describe repository status. - -## Non-Goals - -- No GitLab, Azure DevOps, or GitHub provider rewrite yet. -- No Jujutsu driver yet, but every interface must be designed so a Jujutsu driver does not have to pretend to be Git. -- No T3 Review implementation yet. -- No broad UI redesign. - -## Driver Model - -Use provider-neutral nouns in new APIs: - -- `VcsDriver`: local repository mechanics. -- `RepositoryIdentity`: detected VCS kind, root path, common metadata path when available, remotes. -- `WorkingCopyStatus`: dirty state, changed files, aggregate insertions/deletions, current branch/bookmark/change name. -- `ChangeSet`: a committed or pending unit of change, not necessarily a Git commit. -- `RefName`: branch, bookmark, tag, or provider-specific ref. - -The initial driver capabilities should be explicit: - -```ts -export interface VcsDriverCapabilities { - readonly kind: "git" | "jj" | "sapling" | "unknown"; - readonly supportsWorktrees: boolean; - readonly supportsBookmarks: boolean; - readonly supportsAtomicSnapshot: boolean; - readonly supportsPushDefaultRemote: boolean; -} -``` - -Do not model Jujutsu as `GitCoreShape extends ...`. The Git driver can expose Git-specific implementation details internally, but the public VCS layer should describe operations by intent: - -- `detectRepository(cwd)` -- `status(cwd, options)` -- `listRefs(cwd, query/pagination)` -- `checkoutRef(cwd, ref)` -- `createRef(cwd, ref, from?)` -- `createWorkspace(cwd, ref, path?)` -- `removeWorkspace(path)` -- `prepareChangeContext(cwd, filePaths?)` -- `createChange(cwd, message, options)` -- `push(cwd, target?)` -- `rangeContext(cwd, base, head)` -- `listWorkspaceFiles(cwd, options)` - -## Effect Process Layer - -Create a small reusable `VcsProcess` service instead of using `runProcess`. - -Requirements: - -- Implement with `ChildProcess` and `ChildProcessSpawner` from `effect/unstable/process`. -- Support scoped acquisition/release for long-running commands and interruption. -- Support bounded stdout/stderr collection with truncation markers. - - DO not eagerly consume full stdout/stderr, return stream apis and expose helpers for consumers so we don't consume streams to memory unnecessarily... -- Support stdin. -- Support timeout through Effect scheduling/interruption, not ad-hoc timers. -- Stream output lines to progress callbacks as Effects. -- Return a typed `ProcessOutput` value for successful execution. -- Fail with typed errors, not generic thrown exceptions. - -Errors should be schema-backed tagged classes, for example: - -- `VcsProcessSpawnError` -- `VcsProcessExitError` -- `VcsProcessTimeoutError` -- `VcsOutputDecodeError` -- `VcsRepositoryDetectionError` -- `VcsUnsupportedOperationError` - -Every error should carry operation name, command display string, cwd when applicable, exit code when applicable, stderr/stdout tails when useful, and original cause where available. Override `message` for user readable messages that provides meaning and hints where appropriate. Errors are schema backed so the full error details will be persisted and serialized properly when stored to DB/Logfiles. - -## Git Driver Rewrite - -Rewrite Git support against `VcsProcess`. - -Carry forward current behavior from: - -- `apps/server/src/git/Layers/GitCore.ts` -- `apps/server/src/git/Layers/GitCore.test.ts` -- current Git status/branch/worktree contracts - -But split the implementation into smaller modules: - -- command execution and hardening config -- repository detection -- status parsing -- branch/ref parsing -- worktree operations -- commit/range context generation -- push/pull operations - -Keep parsing deterministic. Prefer Git porcelain formats, null-separated output, and schema decoding for JSON-like command output. Avoid regex parsing where Git gives a structured format. - -## Freshness and Local Caching - -Define freshness rules in the VCS layer before adding more providers. Local VCS status is cheap enough to refresh often; network-backed status is not. - -Treat these as live/local: - -- repository detection for the active cwd -- working copy dirty state -- staged/unstaged/untracked file summaries -- current branch/bookmark/change name -- local branch/bookmark lists -- local worktree/workspace lists - -These may run on user-visible polling, but should still be debounced and coalesced per repository root. Prefer filesystem-triggered invalidation where available, with a short fallback poll interval. Concurrent requests for the same repository/status shape should share one in-flight Effect. - -Treat these as cached or explicit-refresh only: - -- remote tracking branch refreshes -- ahead/behind counts that require network fetches -- default branch discovery from a remote provider -- remote branch lists beyond locally known refs - -The VCS driver should expose freshness metadata with status results: - -```ts -export interface VcsFreshness { - readonly source: "live-local" | "cached-local" | "cached-remote" | "explicit-remote"; - readonly observedAt: string; - readonly expiresAt?: string; -} -``` - -Remote refreshes should be opt-in per operation, for example `refresh: "local-only" | "allow-cached-remote" | "force-remote"`. The default for background status should be `local-only`. - -Use Effect `Cache` for repository identity and expensive local metadata: - -- key by resolved repository root plus VCS kind -- invalidate on cwd/root changes and workspace mutation operations -- use short TTLs for local status caches when filesystem events are unavailable -- never hide command failures behind stale values unless the caller explicitly accepts stale data - -## Cutover Policy - -Prefer direct migration and deletion over compatibility wrappers. - -Rules: - -- Update consumers to call `VcsDriver`/`VcsRepositoryResolver` directly as soon as the new API exists. -- Delete migrated `GitCore` service methods and tests in the same PR that moves their consumers. -- Do not keep backwards-compatible export shims, barrel aliases, or old service names for convenience. -- Transitional modules are allowed only when a caller group is too complex or risky to migrate in the same PR. -- Every transitional module must have a narrow owner, a removal checklist, and a test proving it delegates to the new implementation. -- No new feature work may depend on transitional modules. - -Expected transitional candidates: - -- The highest-level `GitManager` orchestration can be migrated in slices if doing the full Commit + PR flow in one PR is too risky. -- WebSocket payload compatibility can remain only where changing it would require a coordinated UI/server protocol migration. Internal server code should still use the new VCS contracts. - -## Tests - -Add integration-style tests with real temporary Git repositories for the new Git driver: - -- non-repository detection -- status for clean/dirty/untracked/staged states -- branch/ref list with pagination -- checkout/create branch -- worktree create/remove -- commit context generation with file filters -- commit creation with hook progress events -- push behavior against a local bare remote -- status polling does not perform remote network refresh by default -- concurrent duplicate status requests are coalesced -- bounded output/truncation -- timeout/interruption -- typed error shape for command failure and missing executable - -Move or duplicate only the tests needed to prove behavior, then delete the old service tests in the same migration slice. - -## Migration Steps - -1. Add `vcs` contracts and tagged errors. -2. Add `VcsProcess` and unit tests around process execution semantics. -3. Add `VcsDriver` and `VcsRepositoryResolver` service contracts. -4. Implement `GitVcsDriver` with real Git command integration tests. -5. Move `GitStatusBroadcaster` and branch/worktree flows to the VCS service directly. -6. Move commit/range/push callers to the VCS service directly. -7. Delete migrated `GitCore` internals and tests as each caller group moves. -8. Add a transitional adapter only for any remaining `GitManager` path that is explicitly too complex to cut over safely in one PR. -9. Remove every transitional adapter before starting Phase 2 unless the adapter is documented as blocking on the provider cutover. - -## Acceptance Criteria - -- Current Git branch/status/worktree/commit behavior remains intact. -- New Git implementation does not depend on `processRunner.ts`. -- New errors are typed and inspectable by tests. -- VCS interfaces contain no GitHub/GitLab/Azure concepts. -- Active consumers use the new VCS APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim. -- Background status refresh is local-only by default and cannot hit provider rate limits. -- Jujutsu can be added by implementing a real driver instead of conforming to Git command semantics. -- `bun fmt`, `bun lint`, and `bun typecheck` pass. diff --git a/.plans/20-version-control-phase-2-source-control-provider-foundation.md b/.plans/20-version-control-phase-2-source-control-provider-foundation.md deleted file mode 100644 index ac1186ba..00000000 --- a/.plans/20-version-control-phase-2-source-control-provider-foundation.md +++ /dev/null @@ -1,268 +0,0 @@ -# Version Control Phase 2: Source Control Provider Foundation - -## Goal - -Introduce a pluggable source-control provider layer and rewrite GitHub support as an Effect-native provider. This phase should preserve the existing GitHub Commit + PR flow while making GitLab and Azure DevOps additive drivers rather than branches inside GitHub-oriented code. - -The existing `GitHubCli` service and GitHub-specific `GitManager` paths are behavior references. The new provider layer should use detailed tagged errors, schema decode boundaries, `effect/unstable/process`, capability flags, and provider-neutral change-request types. - -## Scope - -- Add provider-domain contracts in `packages/contracts/src/sourceControl.ts`. -- Add provider URL/reference parsing helpers in `packages/shared/src/sourceControl/*`. -- Add server services under `apps/server/src/sourceControl`: - - `Services/SourceControlProvider.ts` - - `Services/SourceControlProviderRegistry.ts` - - `Services/SourceControlProcess.ts` - - `Layers/GitHubSourceControlProvider.ts` - - `errors.ts` -- Migrate PR creation, PR lookup, default-branch lookup, clone URL lookup, and PR checkout through the provider layer. -- Update active consumers to the provider APIs directly; do not add backwards-compatible `GitHubCli` export shims. -- Keep GitHub as the only production provider at the end of this phase, but make GitLab and Azure implementation paths obvious and bounded. - -## Non-Goals - -- No GitLab implementation in this phase, except fixtures/contracts that prove the abstraction can represent merge requests. -- No Azure DevOps implementation in this phase, except URL/reference parser test cases if cheap. -- No in-app review UI yet. -- No hard dependency on one CLI forever. The first GitHub driver may use `gh`, but the interface should support REST/GraphQL implementations later. - -## Provider Model - -Use provider-neutral names: - -- `SourceControlProvider`: hosted repository and change-request mechanics. -- `ChangeRequest`: GitHub pull request, GitLab merge request, Azure pull request. -- `ChangeRequestThread`: review or discussion thread. -- `ChangeRequestComment`: top-level or inline comment. -- `ProviderRepository`: owner/project/repo identity plus clone URLs. - -Core provider operations: - -- `detectRemote(remoteUrl)` -- `checkAuth(cwd)` -- `getRepository(cwd | remoteUrl)` -- `getDefaultTargetRef(repository)` -- `listChangeRequests(repository, filters)` -- `getChangeRequest(repository, reference)` -- `createChangeRequest(repository, input)` -- `checkoutChangeRequest(cwd, changeRequest, options)` -- `getCloneUrls(repository)` - -Review-facing operations should be designed now, even if unimplemented: - -- `listReviewThreads(changeRequest)` -- `createReviewComment(changeRequest, input)` -- `replyToReviewThread(thread, input)` -- `resolveReviewThread(thread)` -- `submitReview(changeRequest, input)` - -Each operation should be guarded by capabilities: - -```ts -export interface SourceControlProviderCapabilities { - readonly kind: "github" | "gitlab" | "azure-devops" | "unknown"; - readonly supportsCreateChangeRequest: boolean; - readonly supportsCheckoutChangeRequest: boolean; - readonly supportsReviewThreads: boolean; - readonly supportsInlineComments: boolean; - readonly supportsDraftChangeRequests: boolean; -} -``` - -## Provider Registry - -Add a registry that resolves a provider from repository remotes and explicit user input. - -Rules: - -- Detection should be pure where possible and testable without spawning CLIs. -- Remote URL parsing belongs in `packages/shared`, not server-only provider layers. -- Unknown providers should return explicit unsupported-operation errors, not silently fall back to GitHub. -- Provider selection should be stable per operation and logged with enough context to debug bad remote detection. - -The registry should support multiple provider implementations at runtime, not a single dispatcher file with inline provider branches. - -## Rate Limits and Provider Caching - -Design the provider layer around a strict freshness budget. Provider API and CLI calls must not be part of frequent background polling unless the operation is explicitly marked safe and cached. - -Default behavior: - -- Pure URL/remote parsing is always live because it is local. -- Provider detection from local remotes is live-local. -- Authentication checks are cached. -- Repository metadata is cached. -- Default branch metadata is cached. -- Change-request lists are cached and refreshed on explicit user actions or coarse intervals. -- Full review threads, comments, file diffs, and timeline data are fetched only when the user opens the relevant review surface or explicitly refreshes it. -- Create/update operations invalidate affected cache keys immediately after success. - -The provider API should make freshness explicit: - -```ts -export interface SourceControlFreshness { - readonly source: "live-local" | "cached-provider" | "live-provider"; - readonly observedAt: string; - readonly expiresAt?: string; - readonly stale?: boolean; -} - -export type ProviderRefreshPolicy = - | "cache-first" - | "stale-while-revalidate" - | "force-refresh" - | "local-only"; -``` - -Every read operation that can touch a provider should accept a refresh policy. Background UI reads should default to `cache-first` or `stale-while-revalidate`; direct user actions like pressing refresh can use `force-refresh`. - -Use Effect `Cache` for provider data: - -- auth status: key by provider kind, hostname, workspace identity, and account if known; TTL around minutes, not seconds -- repository metadata/default branch: key by provider repository stable ID or normalized remote URL; TTL around tens of minutes -- change-request summary lists: key by provider repository, state/filter, source ref, target ref; short TTL with stale-while-revalidate -- individual change-request summaries: key by provider repository and provider CR ID; short TTL, invalidated after create/update/comment operations -- review threads/comments/diffs: key by provider CR ID and head SHA/version when available; fetch on demand for T3 Review - -Provider drivers should surface rate-limit signals when available: - -- remaining quota -- reset time -- retry-after duration -- whether the limit is primary, secondary/abuse, or unknown - -Rate-limit errors should be typed, retryable when the provider gives a reset/retry time, and visible enough for the UI to avoid repeatedly retrying a blocked operation. - -Avoid rate-limit footguns: - -- no provider calls from render loops or fast status polling -- no listing all PRs/MRs across all repos to infer one branch state -- no silent GitHub fallback for unknown providers -- no unbounded cache cardinality for branch names or free-form search queries -- no per-thread duplicate provider refresh when multiple views observe the same repository - -## GitHub Provider Rewrite - -Rewrite GitHub support as `GitHubSourceControlProvider`. - -Carry forward behavior from: - -- `apps/server/src/git/Layers/GitHubCli.ts` -- `apps/server/src/git/Layers/GitHubCli.test.ts` -- `apps/server/src/git/githubPullRequests.ts` -- GitHub-specific `GitManager` PR paths - -Implementation requirements: - -- Use `SourceControlProcess` built on `effect/unstable/process`, not `runProcess`. -- Decode `gh api` and `gh pr --json` responses with Effect Schema. -- Use typed errors for auth failure, missing CLI, command failure, output decode failure, unsupported reference, and provider mismatch. -- Keep stdout/stderr bounded. -- Avoid global mutable auth caches unless they are Effect `Cache` values with explicit keys, TTLs, and invalidation behavior. -- Parse provider rate-limit headers or CLI/API error payloads when available and map them to typed rate-limit errors. -- Keep GitHub nouns inside the GitHub driver; convert to `ChangeRequest` at the provider boundary. - -## GitManager Cutover - -Refactor `GitManager` so it coordinates three independent services: - -- `VcsDriver` for local repository mechanics. -- `SourceControlProviderRegistry` for hosted provider selection. -- `TextGeneration` for message/body generation. - -`GitManager` should stop depending directly on GitHub services. User-visible step labels should be provider-neutral unless the selected provider is known and the label is intentionally provider-specific. - -The Commit + PR flow should become: - -1. Resolve VCS repository and local status. -2. Resolve source-control provider from remotes. -3. Generate commit content through the existing text generation service. -4. Create local change through `VcsDriver`. -5. Push through `VcsDriver` or a narrow provider push helper only if the VCS requires provider-specific target syntax. -6. Generate change-request title/body. -7. Create the change request through `SourceControlProvider`. - -## Cutover Policy - -This phase should aggressively remove old GitHub-specific internals. - -Rules: - -- Move each active consumer directly to `SourceControlProviderRegistry` or a concrete provider test layer. -- Delete migrated `GitHubCli` methods, tests, and GitHub-specific helper exports in the same PR that moves their final consumer. -- Do not add compatibility export shims from `apps/server/src/git` to `apps/server/src/sourceControl`. -- Transitional modules are allowed only for a bounded `GitManager` slice that cannot move safely with the rest of the provider cutover. -- Every transitional module must have an owner comment, a removal checklist, and no public exports consumed by new code. -- Provider-neutral web parsing should replace GitHub-only parsing directly; do not keep parallel parser stacks unless a route still requires both during a single PR. - -## GitLab and Azure Readiness - -Use the triaged references as implementation inputs, not merge targets: - -- GitLab PR #592 is useful for `glab mr` command mapping and JSON normalization. -- Azure issue #1138 defines a good first Azure slice: remote/URL detection and change-request thread setup for same-repo URLs. - -The abstraction should let Phase 3 add: - -- `GitLabSourceControlProvider` using `glab`. -- `AzureDevOpsSourceControlProvider` using `az repos pr` or REST APIs. - -No provider should need to edit GitHub code to join the registry. - -## T3 Review Design Constraint - -Do not optimize only for creation/checkout. The provider layer must be able to support a future in-app review surface. - -That means contracts should include stable IDs and enough metadata for: - -- file-level diffs -- inline review threads -- resolved/unresolved state -- top-level discussion comments -- pending review submission -- provider URL back-links - -Provider-specific fields can live in a metadata bag, but core review behavior should not require the UI to know whether the backing service is GitHub, GitLab, or Azure DevOps. - -## Tests - -Add tests at three levels: - -- Pure parser tests for GitHub, GitLab, and Azure remote URLs and change-request references. -- Provider unit tests with fake `SourceControlProcess` output and schema decode failures. -- Integration-style GitHub CLI tests only where they can run hermetically or be skipped without hiding unit coverage. - -Required cases: - -- GitHub PR URL, number, and branch-ish references. -- GitLab MR URL/reference parsing. -- Azure DevOps PR URL parsing for same-repo URLs. -- unknown provider returns unsupported-operation errors. -- missing CLI and auth failures produce distinct typed errors. -- invalid CLI JSON fails at decode boundary with useful context. - -## Migration Steps - -1. Add `sourceControl` contracts and provider-neutral schemas. -2. Add shared remote/reference parser helpers and tests. -3. Add `SourceControlProcess` and provider errors. -4. Add provider registry with GitHub-only registration. -5. Implement `GitHubSourceControlProvider` from scratch against the new process layer. -6. Cut GitHub PR operations in `GitManager` over to the provider registry. -7. Replace web PR-reference parsing with provider-neutral parser output while keeping current GitHub UX. -8. Add provider cache metrics and tests for cache hit, stale refresh, invalidation, and rate-limit error mapping. -9. Delete the migrated `GitHubCli` implementation, tests, and GitHub-specific helper exports unless an explicit transitional checklist remains. - -## Acceptance Criteria - -- Existing GitHub Commit + PR and PR checkout flows still work. -- `GitManager` no longer imports or depends on `GitHubCli`. -- Active consumers use source-control provider APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim. -- Source-control contracts can represent GitHub PRs, GitLab MRs, and Azure DevOps PRs. -- Unknown/unsupported providers fail explicitly and visibly. -- GitHub command execution does not depend on `processRunner.ts`. -- Background provider reads are cached/coalesced and do not consume provider API quota on every status refresh. -- Rate-limit responses become typed errors with retry/reset metadata where available. -- The provider API includes the review operations needed by future T3 Review work, even if they are capability-gated. -- `bun fmt`, `bun lint`, and `bun typecheck` pass. diff --git a/.plans/21-cafe-code-rebrand-burst-002.md b/.plans/21-cafe-code-rebrand-burst-002.md deleted file mode 100644 index bb84e3ee..00000000 --- a/.plans/21-cafe-code-rebrand-burst-002.md +++ /dev/null @@ -1,149 +0,0 @@ -# Cafe Code Rebrand Burst 002 - -Status: Complete -Last updated: 2026-05-21T01:54:00Z -Plan file: `.plans/21-cafe-code-rebrand-burst-002.md` - -## 0) Guiding Constraints - -1. Implement the approved Burst 002 plan against `.selene/bursts/002/design/agreed_plan.md`. -2. Preserve Nightly version, tag, dist-tag, updater channel, update manifest, app ID, and user data directory identity unless the plan explicitly allows a visible-copy rename. -3. Preserve legal/provenance/test-lineage references and do not edit existing `.selene/adrs/` or `.selene/invariants/` files. -4. Prefer Cafe Code identifiers while retaining legacy aliases or dual-read paths for persisted state, environment variables, CLI bins, protocol endpoints, checkpoint refs, and repository config paths. -5. Run `bun fmt`, `bun lint`, and `bun typecheck`; do not run `bun test`. - -## 1) Glossary - -- Cafe Code: The active product and repository-owned brand for this fork. -- Legacy T3 identifiers: Existing names retained only for compatibility, migration, current hosted defaults, release continuity, legal/provenance, or quoted/test fixture lineage. -- Matrix row: One of the 58 Burst 001 rename-classification rows used as the falsification checklist. - -## 2) Scope - -### 2.1 Goal - -Land the full repository-owned Cafe Code rebrand in source, docs, package metadata, and compatibility helpers, then produce Burst 002 artifacts that map every matrix row and remaining legacy match to the four classes cited from `.selene/classifications.py`. - -### 2.2 Non-Goals - -- No existing ADR or invariant edits. -- No invented Cafe hosted domains or migration of active `t3.codes` hosted defaults. -- No updater-sensitive artifact filename, manifest schema, app ID, user-data directory, or publish-repo migration. -- No removal of legacy env vars, CLI bin alias, local state paths, protocol endpoint, checkpoint ref, or config path fallbacks. - -### 2.3 Assumptions - -- The current `AGENTS.md` modification is project context supplied by the user; any edits there must be narrow and preserve the policy content. -- `bun install` may regenerate `bun.lock` after package identity changes. -- Focused tests may be added or run with `bun run test` only if migration or precedence code needs direct coverage. - -## 3) Milestone Plan - -### 3.1 Milestone 1: Inventory And Package Identity - -- Deliverables: Source inventory, `@cafecode/*` package scope updates, `cafe-code` CLI bin with `t3` alias, lockfile regeneration. -- Dependencies: Burst 001 matrix and current package metadata. -- Acceptance Criteria: - - Root/workspace package names and imports use `@cafecode/*`. - - `apps/server` exposes `cafe-code` as the preferred bin and retains `t3`. - - Tooling filters/scripts resolve renamed packages. - - `bun.lock` is regenerated by package-manager tooling. -- Test Plan: Run targeted `rg` checks, `bun install`, and later `bun typecheck`. - -### 3.2 Milestone 2: Runtime Compatibility Spine - -- Deliverables: Shared Cafe-first env-var helper, dual-read persisted storage/config paths, checkpoint/worktree aliases, well-known endpoint alias. -- Dependencies: Inventory of env, storage, VCS, and endpoint call sites. -- Acceptance Criteria: - - Env-var reads use one shared helper where practical, with `CAFE_CODE_*` preferred and `T3CODE_*` fallback. - - Browser persisted keys use Cafe keys with legacy T3 dual-read migration. - - `~/.cafecode`/`.cafecode` and legacy paths are handled deterministically. - - Checkpoint refs, worktree branch prefix, and well-known endpoint alias preserve legacy compatibility. -- Test Plan: Add focused tests if existing tests can cover helper and migration paths; run via `bun run test` only. - -### 3.3 Milestone 3: User-Facing And Metadata Rebrand - -- Deliverables: Web, desktop, server, marketing, docs, workflow copy, release notification copy, provider visible titles, and observability display names updated. -- Dependencies: Runtime compatibility helpers and package rename. -- Acceptance Criteria: - - Repository-owned visible `T3 Code` and `T3 Server` copy becomes Cafe Code / Cafe Code Server. - - Nightly visible copy becomes Cafe branded while preserving Nightly mechanics. - - Legal/provenance/testimonial/current-hosted-domain references stay classified and unchanged where required. -- Test Plan: Targeted `rg` audit and final quality gates. - -### 3.4 Milestone 4: Artifacts And Verification - -- Deliverables: `.selene/bursts/002/artifacts/IMPLEMENTATION_REPORT.md`, `.selene/bursts/002/design/domain-migration-checklist.md`, embedded audit log, command outputs. -- Dependencies: Source changes and final audits. -- Acceptance Criteria: - - Implementation report includes all 58 rows with allowed classifications and anchors. - - Domain checklist covers every deferred surface from the agreed plan. - - Every markdown artifact created in this burst includes the canonical hard non-claims block. - - `bun fmt`, `bun lint`, and `bun typecheck` pass or failures are recorded with the blocking reason. -- Test Plan: Re-open artifacts, run final `rg` checks and quality gates. - -## 4) Verification Matrix - -| Requirement | Code Location | Test Coverage | Status | -| --- | --- | --- | --- | -| Package/workspace identity renamed with compatibility where needed | `package.json`, `apps/*/package.json`, `packages/*/package.json`, `bun.lock` | `bun install`, `bun typecheck` | complete | -| Runtime aliases preserve legacy env, storage, path, endpoint, ref, and branch behavior | `packages/shared/src/compatEnv.ts`, `apps/web/src/clientPersistenceStorage.ts`, `packages/shared/src/environmentEndpoint.ts`, checkpoint/git helpers | focused `bun run test` commands recorded in implementation report | complete | -| User-facing product copy renamed while preserving admitted legacy references | web, desktop, server, marketing, docs, release scripts | targeted `rg` audit | complete | -| Selene artifacts complete with hard non-claims | `.selene/bursts/002/artifacts/IMPLEMENTATION_REPORT.md`, `.selene/bursts/002/design/domain-migration-checklist.md` | file review | complete | -| Project quality gates pass | repo root | `bun fmt`, `bun lint`, `bun typecheck` | complete | - -## 5) Completion Formula - -`completion = (completed acceptance criteria / total acceptance criteria) * 100` - -Current completion: `100%` - -## 6) Current Checkpoint - -- Current Stage Flag: - ```text - /ᐠ > ˕ <マ 🌸 かんせい!! - ``` -- Active Milestone: Complete -- Current Task: Burst 002 rebrand implementation and verification complete. -- Next Action: User review or commit. -- Blockers: None. -- Resume Point: Review final diff and Selene artifacts. - -## 7) Verification Checklist - -- [x] Matrix and seed reviewed against current source. -- [x] Package/workspace identity changes complete. -- [x] Runtime compatibility helper and migrations complete. -- [x] User-facing and metadata rename complete. -- [x] Required Selene markdown artifacts created with canonical hard non-claims. -- [x] Final targeted `rg` audit embedded in implementation report. -- [x] `bun fmt`, `bun lint`, and `bun typecheck` outputs recorded. -- [x] Completion reached exactly 100%. - -## 8) Execution Log - -- 2026-05-21T00:00:00Z: Plan initialized from approved Burst 002 agreed plan. -- 2026-05-21T01:44:04Z: Manual recovery completed active repo metadata cleanup and wrote Selene artifacts. -- 2026-05-21T01:54:00Z: Focused `bun run test` checks, `bun fmt`, `bun lint`, `bun typecheck`, `selene freeze-invariants`, and `selene verify` passed. - -## 9) Handoff Payload (Copy 1:1) - -When handing off this plan to another window/agent, copy the full plan exactly with no summarization and preserve the exact Current Checkpoint fields. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.plans/22-electron-only-hosted-web-removal.md b/.plans/22-electron-only-hosted-web-removal.md deleted file mode 100644 index 129648a4..00000000 --- a/.plans/22-electron-only-hosted-web-removal.md +++ /dev/null @@ -1,145 +0,0 @@ -# Electron-Only Hosted Web Removal - -Status: Complete -Last updated: 2026-05-21T11:32:33Z -Plan file: .plans/22-electron-only-hosted-web-removal.md - -## 0) Guiding Constraints - -1. Preserve the Electron desktop application and the local React/Vite renderer it loads. -2. Remove hosted static web deployment, hosted pairing, Vercel configuration, and marketing-site runtime surfaces. -3. Keep direct desktop/server pairing behavior only where it is still useful to an Electron-launched backend. -4. Do not revert existing rebrand/licensing edits or unrelated dirty worktree changes. -5. Require `bun fmt`, `bun lint`, `bun typecheck`, and `bun run test` before completion. - -## 1) Glossary - -- Electron shell: The native desktop app in `apps/desktop`. -- Renderer: The React/Vite UI in `apps/web` that Electron loads from local build output. -- Hosted static web app: The optional Vercel-deployed browser app and its channel/domain routing. -- Direct pairing: Pairing directly against the local or exposed backend `/pair` endpoint. -- Marketing site: The Astro site under `apps/marketing`. - -## 2) Scope - -### 2.1 Goal - -Make Cafe Code Electron-only by removing hosted web/Vercel and marketing app surfaces while keeping the Electron renderer and backend functional. - -### 2.2 Non-Goals - -- Do not rewrite the renderer in native UI. -- Do not remove the server backend used by Electron. -- Do not remove SSH/Tailscale/direct remote backend access unless it is only coupled to hosted static web pairing. -- Do not delete legacy compatibility data unrelated to hosted web removal. - -### 2.3 Assumptions - -- `apps/web` remains necessary because Electron uses it as the renderer. -- Hosted web channel selection is unnecessary when the renderer always runs under Electron. -- Direct pairing URLs are sufficient for any remaining external backend access. - -## 3) Milestone Plan - -### 3.1 Milestone 1: Inventory And Boundary - -- Deliverables: - - Map hosted/Vercel/marketing references. - - Identify contract fields and UI branches used only by hosted static pairing. -- Dependencies: - - Existing source tree and current rebrand state. -- Acceptance Criteria: - - Hosted-only files, envs, docs, and workflow sections are identified. - - Renderer/server/desktop build dependencies that must remain are identified. -- Test Plan: - - Targeted `rg` searches over repository source excluding dependency/build directories. - -### 3.2 Milestone 2: Remove Hosted Web And Marketing Runtime - -- Deliverables: - - Delete hosted Vercel config and marketing app package. - - Remove hosted web deployment workflow jobs/scripts/envs. - - Remove hosted pairing URL generation and hosted channel selection. - - Simplify endpoint compatibility away from hosted app compatibility. -- Dependencies: - - Milestone 1 inventory. -- Acceptance Criteria: - - No active source references to Vercel hosted app deployment remain. - - No active source references to `app.t3.codes`, `latest.app.t3.codes`, or `nightly.app.t3.codes` remain. - - Pairing UI offers direct backend links only. - - Desktop packaging still builds from the renderer. -- Test Plan: - - Unit tests adjusted for direct-only pairing and endpoint compatibility. - -### 3.3 Milestone 3: Documentation, Package Graph, And Verification - -- Deliverables: - - Update docs to describe Electron-only architecture. - - Remove marketing workspace/scripts from package graph. - - Run required repository checks. -- Dependencies: - - Milestone 2 implementation. -- Acceptance Criteria: - - Docs no longer describe hosted web/Vercel as an active product surface. - - Workspace/package metadata no longer includes the removed marketing app. - - Required checks pass. -- Test Plan: - - `bun fmt` - - `bun lint` - - `bun typecheck` - - `bun run test` - -## 4) Verification Matrix - -| Requirement | Code Location | Test Coverage | Status | -| --- | --- | --- | --- | -| Preserve Electron renderer build path | `apps/desktop/src/electron/ElectronProtocol.ts:96`; `apps/server/scripts/cli.ts:168`; `apps/desktop/turbo.jsonc` | `bun fmt`; `bun lint`; `bun typecheck`; `bun run test` | complete | -| Remove hosted web/Vercel deployment | `.github/workflows/release.yml:417`; deleted `apps/web/vercel.ts`; package metadata | Required checks plus targeted search | complete | -| Remove hosted pairing UI/logic | deleted `apps/web/src/hostedPairing.ts`; `apps/web/src/components/settings/pairingUrls.ts:3`; `apps/web/src/components/settings/ConnectionsSettings.tsx` | Unit tests | complete | -| Simplify endpoint compatibility | `packages/contracts/src/remoteAccess.ts:40`; `packages/shared/src/advertisedEndpoint.ts:45`; desktop endpoint providers | Unit tests | complete | -| Remove marketing app surface | deleted `apps/marketing`; root scripts/workspaces in `package.json:31` | Required checks plus targeted search | complete | -| Update docs for Electron-only behavior | `README.md`; `REMOTE.md:113`; `docs/release.md:31`; `.docs/architecture.md:3` | Targeted search | complete | - -## 5) Completion Formula - -`completion = (completed acceptance criteria / total acceptance criteria) * 100` - -Current completion: `100%` - -## 6) Current Checkpoint - -- Current Stage Flag: - ```text - /ᐠ > ˕ <マ 🌸 さいごチェック!! - ``` -- Active Milestone: 3.3 Milestone 3: Documentation, Package Graph, And Verification -- Current Task: Verification complete. -- Next Action: Report results. -- Blockers: None. -- Resume Point: Complete; no remaining plan work. - -## 7) Verification Checklist - -- [x] Hosted-only files, envs, docs, and workflow sections inventoried. -- [x] Renderer/server/desktop dependencies to preserve inventoried. -- [x] Hosted Vercel config and deployment workflow removed. -- [x] Hosted pairing and hosted channel UI removed. -- [x] Endpoint compatibility no longer exposes hosted app compatibility. -- [x] Marketing app surface removed from workspace/scripts/docs. -- [x] Electron renderer build path preserved. -- [x] Documentation updated for Electron-only behavior. -- [x] Targeted stale hosted-domain/Vercel/marketing searches completed. -- [x] `bun fmt` passed. -- [x] `bun lint` passed. -- [x] `bun typecheck` passed. -- [x] `bun run test` passed. - -## 8) Execution Log - -- 2026-05-21T11:00:39Z: Plan initialized. -- 2026-05-21T11:21:00Z: Removed hosted pairing source, Vercel config/workflow, marketing app, hosted endpoint compatibility, and primary hosted docs references. -- 2026-05-21T11:32:33Z: Refreshed lockfile, ran targeted stale-reference searches, and completed required verification. `bun fmt`, `bun lint`, `bun typecheck`, and `bun run test` passed. Lint retained 9 pre-existing warnings and no errors. - -## 9) Handoff Payload (Copy 1:1) - -When handing off this plan to another window/agent, copy the full plan exactly with no summarization and preserve the exact Current Checkpoint fields. diff --git a/.plans/23-codex-idle-token-drain.md b/.plans/23-codex-idle-token-drain.md deleted file mode 100644 index 08757e78..00000000 --- a/.plans/23-codex-idle-token-drain.md +++ /dev/null @@ -1,154 +0,0 @@ -# Codex Idle Token Drain Investigation And Fix - -Status: Complete -Last updated: 2026-05-21T11:57:29Z -Plan file: .plans/23-codex-idle-token-drain.md - -## 0) Guiding Constraints - -1. Fix or conclusively rule out the local source of upstream T3 Code issue #2720: Codex plan credits being consumed while the app is idle. -2. Do not use Selene for this task. -3. Keep searches targeted to this repository and relevant upstream issue/PR pages. -4. Prefer preventing background network/API activity over preserving eager provider metadata refresh. -5. Preserve user-initiated provider refresh and actual turn execution. -6. Require `bun fmt`, `bun lint`, `bun typecheck`, and `bun run test` before completion if code changes are made. - -## 1) Upstream Evidence - -- Issue: https://github.com/pingdotgg/t3code/issues/2720 -- Status at inspection: Open, filed 2026-05-15. -- Reported behavior: Codex Pro credits drain while the app is idle/minimized, with activity stopping when T3 Code processes are killed. -- Reported recurring activity: `model/list`, `account/rateLimits/read`, and expensive `responses_websocket`/`sse::responses` bursts. -- Reported strongest suspects: - - `probeCodexAppServerProvider` spawns `codex app-server` during provider status refresh. - - `makeManagedServerProvider` runs an unconditional refresh loop every provider `refreshInterval`. - - `CodexDriver` sets the snapshot interval to 5 minutes. - - `ProviderSessionReaper` skips stale sessions whenever `activeTurnId != null`. - -## 2) Local Initial Findings - -1. `apps/server/src/provider/Drivers/CodexDriver.ts` sets `SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5)` and passes it to `makeManagedServerProvider`. -2. `apps/server/src/provider/makeManagedServerProvider.ts` forks an unconditional `Effect.forever(Effect.sleep(...).flatMap(refreshSnapshot))` loop. -3. `apps/server/src/provider/Layers/CodexProvider.ts` probes via `codex app-server`, then sends `initialize`, `account/read`, `skills/list`, and `model/list`. -4. `apps/server/src/provider/Layers/ProviderSessionReaper.ts` currently refuses to reap a stale persisted session if the projection has any non-null `activeTurnId`. - -## 3) Scope - -### 3.1 Goal - -Eliminate idle background Codex API activity caused by Cafe Code-managed provider maintenance, and add tests that fail if the automatic Codex snapshot loop is reintroduced. - -### 3.2 Non-Goals - -- Do not redesign the provider registry. -- Do not remove user-triggered provider refresh. -- Do not remove actual Codex session/turn execution paths. -- Do not depend on live Codex credentials or hit Codex/OpenAI services in tests. -- Do not implement the broader upstream background/power policy PR unless the narrow fix is insufficient. - -### 3.3 Working Hypotheses - -1. The confirmed local bug is the Codex driver opting into the generic 5-minute managed provider refresh loop even though its health check is not local-only. -2. The likely minimal fix is to let providers opt out of automatic periodic refresh and have Codex opt out. -3. A secondary hardening fix may be needed in `ProviderSessionReaper` so abandoned sessions with stale active-turn state cannot live forever. -4. If neither code path can explain local behavior, report that no source was found and document exactly what was ruled out. - -## 4) Milestone Plan - -### 4.1 Milestone 1: Trace Idle-Capable Background Work - -- Deliverables: - - Map every automatic path that can call Codex provider refresh or spawn `codex app-server`. - - Distinguish automatic background refresh from user-initiated refresh and turn execution. -- Acceptance Criteria: - - All identified automatic Codex status refresh paths have file/line references. - - The plan states whether each path is idle-capable. -- Test Plan: - - Targeted `rg` over provider registry, Codex provider/driver, WebSocket config refresh, and session reaper files. - -### 4.2 Milestone 2: Stop Automatic Codex App-Server Probing While Idle - -- Deliverables: - - Add a provider-managed way to disable automatic periodic refresh. - - Configure Codex to disable that automatic periodic refresh. - - Preserve manual/explicit refresh. -- Acceptance Criteria: - - Codex no longer schedules a 5-minute background provider status loop. - - Other providers keep existing refresh behavior unless deliberately changed. - - Manual provider refresh still calls the provider check. -- Test Plan: - - Unit test `makeManagedServerProvider` opt-out behavior. - - Unit test or code-level assertion that `CodexDriver` opts out. - -### 4.3 Milestone 3: Harden Stale Session Reaping If Needed - -- Deliverables: - - Inspect whether the local projection already clears `activeTurnId` on all terminal Codex events. - - If a stale active-turn session can survive forever, add bounded stale-session reaping behavior with tests. -- Acceptance Criteria: - - A valid active turn is not reaped prematurely. - - A stale persisted session is not immortal solely because the read model has a stale `activeTurnId`. -- Test Plan: - - Add or update `ProviderSessionReaper` tests only if code changes are necessary. - -### 4.4 Milestone 4: Verification And Report - -- Deliverables: - - Required repo checks. - - Short report stating the found source or that no source was found. - - Link upstream issue source used for investigation. -- Acceptance Criteria: - - `bun fmt`, `bun lint`, `bun typecheck`, and `bun run test` pass after any code change. - - Stale-reference checks confirm no automatic Codex refresh interval remains. - -## 5) Verification Matrix - -| Requirement | Code Location | Test Coverage | Status | -| --- | --- | --- | --- | -| Identify automatic Codex app-server probe path | `apps/server/src/provider/Drivers/CodexDriver.ts`; `apps/server/src/provider/makeManagedServerProvider.ts`; `apps/server/src/provider/Layers/CodexProvider.ts` | Targeted source inspection | complete | -| Disable Codex periodic idle refresh | `apps/server/src/provider/Drivers/CodexDriver.ts`; `apps/server/src/provider/makeManagedServerProvider.ts` | `makeManagedServerProvider.test.ts` | complete | -| Preserve manual provider refresh | `apps/server/src/provider/makeManagedServerProvider.ts` | `makeManagedServerProvider.test.ts` | complete | -| Evaluate stale active-turn reaper risk | `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`; `apps/server/src/provider/Layers/ProviderSessionReaper.ts` | `ProviderRuntimeIngestion.test.ts` | complete | -| Required verification | repo root | `bun fmt`; `bun lint`; `bun typecheck`; `bun run test` | complete | - -## 6) Completion Formula - -`completion = (completed acceptance criteria / total acceptance criteria) * 100` - -Current completion: `100%` - -## 7) Current Checkpoint - -- Current Stage Flag: - ```text - /ᐠ > ˕ <マ 🌸 かんせい!! - ``` -- Active Milestone: 4.4 Milestone 4: Verification And Report -- Current Task: Complete. -- Next Action: None. -- Blockers: None. -- Resume Point: Plan complete at 100%. - -## 8) Verification Checklist - -- [x] Upstream issue #2720 inspected. -- [x] Related Claude idle-token bug #2191 inspected for prior pattern. -- [x] Automatic Codex provider refresh call graph documented. -- [x] Codex periodic idle refresh disabled or ruled out as source. -- [x] Manual provider refresh preserved. -- [x] Stale active-turn session reaper risk evaluated. -- [x] Tests added or updated for changed behavior. -- [x] `bun fmt` passed. -- [x] `bun lint` passed. -- [x] `bun typecheck` passed. -- [x] `bun run test` passed. - -## 9) Execution Log - -- 2026-05-21T11:40:00Z: Plan initialized from upstream issue #2720 and local source inspection. -- 2026-05-21T11:45:00Z: Disabled Codex periodic provider refresh by adding a `refreshInterval: null` opt-out and configuring `CodexDriver` to use it. Added lifecycle handling for `turn.aborted` so aborted Codex turns clear `activeTurnId`. Targeted tests for both changes passed, and package typecheck passed. -- 2026-05-21T11:56:42Z: Final verification passed. `bun fmt` completed successfully. `bun lint` completed with 9 existing warnings and 0 errors. `bun typecheck` passed. `bun run test` passed with 13 successful tasks; the server package reported 123 passed test files, 1 skipped file, 1025 passed tests, and 4 skipped tests. `git diff --check` passed with no whitespace errors. Targeted source search confirmed Codex uses `PERIODIC_SNAPSHOT_REFRESH_INTERVAL: null`, while non-Codex providers retain their existing periodic refresh intervals. - -## 10) Handoff Payload (Copy 1:1) - -When handing off this plan to another window/agent, copy the full plan exactly with no summarization and preserve the exact Current Checkpoint fields. diff --git a/.plans/README.md b/.plans/README.md deleted file mode 100644 index 379158d4..00000000 --- a/.plans/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Maintainability Plans - -1. `01-shared-model-normalization.md` -2. `02-typed-ipc-boundaries.md` -3. `03-split-codex-app-server-manager.md` -4. `04-split-chatview-component.md` -5. `05-zod-persisted-state-validation.md` -6. `06-provider-logstream-lifecycle.md` -7. `07-ci-quality-gates.md` -8. `08-precommit-format-and-lint.md` -9. `09-event-state-test-expansion.md` -10. `10-unify-process-session-abstraction.md` -19. `19-version-control-phase-1-vcs-driver-foundation.md` -20. `20-version-control-phase-2-source-control-provider-foundation.md` diff --git a/.plans/branch-environment-picker-in-chatview-input.md b/.plans/branch-environment-picker-in-chatview-input.md deleted file mode 100644 index 2c1994d2..00000000 --- a/.plans/branch-environment-picker-in-chatview-input.md +++ /dev/null @@ -1,74 +0,0 @@ -# Branch/Environment Picker in ChatView Input - -## Summary - -Add a secondary toolbar below the ChatView input area (similar to Codex UI) that lets users select the target branch and environment mode (Local vs New worktree) before sending their first message. - -## UX - -- A toolbar appears **below** the input form (always visible when it's a git repo) -- Two controls: - 1. **Environment mode** (left side): toggles between "Local" and "New worktree" — **locked after first message** (no longer clickable, just shows current mode as label) - 2. **Branch picker** (right side): dropdown showing local branches — **always changeable**, even after messages are sent -- If not a git repo, the toolbar is hidden entirely (thread uses project cwd as-is) - -## Changes - -### 0. Install `@tanstack/react-query` in `apps/renderer` - -Add dependency + wrap app in `QueryClientProvider`. - -### 1. `apps/renderer/src/store.ts` — MODIFY - -Add a new action to the reducer: - -```ts -| { type: "SET_THREAD_BRANCH"; threadId: string; branch: string | null; worktreePath: string | null } -``` - -Reducer case updates `branch` and `worktreePath` on the thread. - -### 2. `apps/renderer/src/components/ChatView.tsx` — MODIFY - -**Fetch branches** via `useQuery`: - -```ts -const branchQuery = useQuery({ - queryKey: ["git-branches", activeProject?.cwd], - queryFn: () => api.git.listBranches({ cwd: activeProject!.cwd }), - enabled: !!activeProject, -}); -``` - -**Local state:** - -- `envMode: "local" | "worktree"` — environment mode (local component state) - -**UI:** Below the `
`, render a toolbar bar (hidden if `!branchQuery.data?.isRepo`): - -- Left side: env mode button ("Local" / "New worktree") — disabled after first message (locked in) -- Right side: branch dropdown from `branchQuery.data.branches` -- Both styled like existing model picker (small text, chevron, dropdown menus) - -**Behavior:** - -- Branch picker is always active — changing branch dispatches `SET_THREAD_BRANCH` immediately -- Env mode is only clickable when `activeThread.messages.length === 0`. After first message, it becomes a static label showing the locked-in mode -- On first send (`onSend`): if `envMode === "worktree"` and a branch is selected, call `api.git.createWorktree` before starting the session, then dispatch `SET_THREAD_BRANCH` with the worktreePath -- `ensureSession` already uses `activeThread.worktreePath ?? activeProject.cwd` - -### Files to modify - -1. `apps/renderer/package.json` — add `@tanstack/react-query` -2. `apps/renderer/src/main.tsx` (or App entry) — wrap in `QueryClientProvider` -3. `apps/renderer/src/store.ts` — add `SET_THREAD_BRANCH` action -4. `apps/renderer/src/components/ChatView.tsx` — branch/env picker UI with `useQuery` - -## Verification - -1. `turbo build` — compiles -2. Create a new thread → branch bar appears below input with "Local" + current branch -3. Change branch in dropdown → branch updates on thread -4. Toggle "New worktree" → send message → worktree created, session uses worktree cwd -5. After first message: env mode label locks to "Worktree" (not clickable), branch picker still works -6. Non-git project → no branch bar shown diff --git a/.plans/effect-atom.md b/.plans/effect-atom.md deleted file mode 100644 index ff6894f5..00000000 --- a/.plans/effect-atom.md +++ /dev/null @@ -1,89 +0,0 @@ -# Replace React Query With AtomRpc + Atom State - -## Summary -- Use `effect/unstable/reactivity/AtomRpc` over the existing `WsRpcGroup`; stop wrapping RPC in promises via [wsRpcClient.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsRpcClient.ts) and [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts). -- Keep Zustand for orchestration read model and UI state. -- Keep a narrow `desktopBridge` adapter for dialogs, menus, external links, theme, and updater APIs. -- Do not introduce Suspense in this migration. Atom-backed hooks should keep returning `data`, `error`, `isLoading|isPending`, `refresh`, and `mutateAsync`-style surfaces so component churn stays low. - -## Target Architecture -- Extract the websocket `RpcClient.Protocol` layer from [wsTransport.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsTransport.ts) into `rpc/protocol.ts`. -- Define one `AtomRpc.Service` for `WsRpcGroup` in `rpc/client.ts`. -- Add `rpc/invalidation.ts` with explicit scoped invalidation keys: `git:${cwd}`, `project:${cwd}`, `checkpoint:${threadId}`, `server-config`. -- Add `platform/desktopBridge.ts` as the only browser/desktop facade. -- Remove from web by the end: [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts), [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts), [wsNativeApiState.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiState.ts), [wsNativeApiAtoms.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiAtoms.tsx), [wsRpcClient.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsRpcClient.ts), and all `*ReactQuery.ts` modules. - -## Phase 1: Infrastructure First -1. Extract the shared websocket RPC protocol layer from [wsTransport.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsTransport.ts) without changing behavior. -2. Build the AtomRpc client on top of that layer. -3. Add one temporary `runRpc` helper for imperative handlers that still want `Promise` ergonomics; it must call the AtomRpc service directly and must not reintroduce a facade object. -4. Replace manual registry wiring with one app-level registry provider based on `@effect/atom-react`. -5. Land this as a no-behavior-change PR. - -## Phase 2: Replace `wsNativeApi`-Owned Push State -1. Migrate welcome/config/provider/settings state first, because it is already atom-shaped and is the lowest-risk way to delete `wsNativeApi` responsibilities. -2. Replace [wsNativeApiState.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiState.ts) with `rpc/serverState.ts`, updated directly from `subscribeServerLifecycle` and `subscribeServerConfig`. -3. Keep the current hook names for one PR: `useServerConfig`, `useServerSettings`, `useServerProviders`, `useServerKeybindings`, `useServerWelcomeSubscription`, `useServerConfigUpdatedSubscription`. -4. Move bootstrap side effects out of [wsNativeApiAtoms.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiAtoms.tsx) into a new root bootstrap component mounted from [__root.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/routes/__root.tsx). -5. Delete the `server.getConfig()` fallback logic from [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts); snapshot fetch now lives beside the stream atoms. - -## Phase 3: Replace React Query Domain By Domain -1. Replace [gitReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/gitReactQuery.ts) first. -2. Add `rpc/gitAtoms.ts` and `rpc/useGit.ts` with `useGitStatus`, `useGitBranches`, `useResolvePullRequest`, and `useGitMutation`. -3. Mutation settlement must invalidate scoped keys, not a global cache. `checkout`, `pull`, `init`, `createWorktree`, `removeWorktree`, `preparePullRequestThread`, and stacked actions invalidate `git:${cwd}`. Worktree create/remove also invalidates `project:${cwd}`. -4. Replace [projectReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/projectReactQuery.ts) second. `useProjectSearchEntries` must preserve current “keep previous results while loading” behavior. -5. Replace [providerReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/providerReactQuery.ts) third. Preserve current checkpoint error normalization and retry/backoff semantics inside the atom effect. Invalidate by `checkpoint:${threadId}`. -6. Defer the desktop updater until the last phase. - -## Phase 4: Move Root Invalidation Off `queryClient` -1. In [__root.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/routes/__root.tsx), remove `QueryClient` usage and replace the throttled `invalidateQueries` block with throttled invalidation helpers. -2. Keep Zustand orchestration/event application unchanged. -3. Map current effects exactly: -- git or checkpoint-affecting orchestration events touch `checkpoint:${threadId}` -- file creation/deletion/restoration touches `project:${cwd}` -- config-affecting server events touch `server-config` - -## Phase 5: Remove Imperative `NativeApi` Usage -1. Create narrow modules instead of a replacement mega-facade: -- `rpc/orchestrationActions.ts` -- `rpc/terminalActions.ts` -- `rpc/gitActions.ts` -- `rpc/projectActions.ts` -- `platform/desktopBridge.ts` -2. Migrate direct [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts) callers by domain, not file-by-file: git-heavy components first, then orchestration/thread actions, then shell/dialog helpers. -3. After the last caller is gone, delete [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts) and the `window.nativeApi` fallback entirely. -4. In the final cleanup PR, remove `NativeApi` from [ipc.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/packages/contracts/src/ipc.ts) if nothing outside web still needs it. - -## Phase 6: Remove React Query Completely -1. Delete `@tanstack/react-query` from `apps/web/package.json`. -2. Remove `QueryClientProvider` and router context from [router.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/router.ts) and [__root.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/routes/__root.tsx). -3. Replace [desktopUpdateReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/desktopUpdateReactQuery.ts) with a writable atom plus `desktopBridge.onUpdateState`. -4. Delete the old query-option tests. - -## Public Interfaces And Types -- Preserve the current server-state hook names during the transition. -- Add permanent domain hooks: `useGitStatus`, `useGitBranches`, `useResolvePullRequest`, `useProjectSearchEntries`, `useCheckpointDiff`, `useDesktopUpdateState`. -- Do not expose raw AtomRpc clients to components. -- Do not add Suspense as part of this migration. -- Final boundary is direct RPC for server features plus `desktopBridge` for local desktop features. - -## Test Plan -- Add unit tests for `rpc/serverState.ts`: snapshot bootstrapping, stream replay, provider/settings updates. -- Add unit tests for git/project/checkpoint hooks: loading, error mapping, retry behavior, invalidation, keep-previous-result behavior. -- Update the browser harness in [wsRpcHarness.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/test/wsRpcHarness.ts) to assert direct RPC + atom behavior instead of `__resetNativeApiForTests`. -- Replace [wsNativeApi.test.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.test.ts), `gitReactQuery.test.ts`, `providerReactQuery.test.ts`, and `desktopUpdateReactQuery.test.ts` with equivalent atom-backed coverage. -- Acceptance scenarios: -- welcome still bootstraps snapshot and navigation -- keybindings toast still responds to config stream updates -- git status/branches refresh after checkout/pull/worktree actions -- PR resolve dialog keeps cached result while typing -- `@` path search refreshes after file mutations and orchestration events -- diff panel refreshes when checkpoints arrive -- desktop updater still reflects push events and button actions - -## Assumptions And Defaults -- Zustand stays in scope; only `react-query` is being removed. -- `desktopBridge` remains the only non-RPC boundary. -- The migration lands as 5-6 small PRs, each green independently. -- Invalidations are explicit and scoped; do not recreate a global cache client abstraction. -- Orchestration recovery/order logic stays as-is; only the data-fetching and mutation layer changes. diff --git a/.plans/git-flows-integration-tests.md b/.plans/git-flows-integration-tests.md deleted file mode 100644 index 70e233a0..00000000 --- a/.plans/git-flows-integration-tests.md +++ /dev/null @@ -1,99 +0,0 @@ -# Git Flows Integration Tests - -## Overview - -Real integration tests that run actual git commands against temporary repos. No mocking. - -## Step 1: Extract git functions into `apps/desktop/src/git.ts` - -The git functions (`listGitBranches`, `createGitWorktree`, `removeGitWorktree`, `createGitBranch`, `checkoutGitBranch`, `initGitRepo`) and their helper `runTerminalCommand` are currently private in `main.ts`. Extract them into a new `apps/desktop/src/git.ts` module with named exports. - -`main.ts` will import and re-use them — no behavior change, just moving code. - -**Files modified:** - -- `apps/desktop/src/git.ts` — new file with all git functions exported -- `apps/desktop/src/main.ts` — import from `./git` instead of defining inline - -## Step 2: Create `apps/desktop/src/git.test.ts` - -Integration tests using real temp git repos. Each test group creates a fresh temp directory with `git init`, makes commits, creates branches as needed, and cleans up after. - -### Setup/teardown pattern - -```ts -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - listGitBranches, - createGitBranch, - checkoutGitBranch, - createGitWorktree, - removeGitWorktree, - initGitRepo, -} from "./git"; - -// Helper: run a raw git command in a dir (for test setup, not under test) -// Helper: create an initial commit (git needs at least one commit for branches) -``` - -### Test groups - -**1. initGitRepo** - -- Creates a valid git repo in a temp dir -- listGitBranches reports `isRepo: true` after init - -**2. listGitBranches** - -- Returns `isRepo: false` for non-git directory -- Returns the current branch with `current: true` -- Sorts current branch first -- Lists multiple branches after creating them -- `isDefault` is false when no remote (no origin/HEAD) - -**3. checkoutGitBranch** - -- Checks out an existing branch (current flag moves) -- Throws when branch doesn't exist -- Throws when checkout would overwrite uncommitted changes (dirty working tree) - -**4. createGitBranch** - -- Creates a new branch (appears in listGitBranches) -- Throws when branch already exists - -**5. createGitWorktree + removeGitWorktree** - -- Creates a worktree directory at the expected path -- Worktree has the correct branch checked out -- Throws when branch is already checked out in another worktree -- removeGitWorktree cleans up the worktree - -**6. Full flow: local branch checkout** - -- init → commit → create branch → checkout → verify current - -**7. Full flow: worktree creation from selected branch** - -- init → commit → create branch → create worktree → verify worktree dir exists and has correct branch - -**8. Full flow: thread switching simulation** - -- init → commit → create branch-a, branch-b → checkout a → checkout b → checkout a → verify current matches - -**9. Full flow: checkout conflict** - -- init → commit → create branch → modify file (unstaged) → checkout other branch → expect error - -## Verification - -```bash -# Run the git integration tests -cd apps/desktop && bun run test - -# Or just the git test file -npx vitest run apps/desktop/src/git.test.ts -``` diff --git a/.plans/git-flows-test-plan.md b/.plans/git-flows-test-plan.md deleted file mode 100644 index 45b86b62..00000000 --- a/.plans/git-flows-test-plan.md +++ /dev/null @@ -1,103 +0,0 @@ -# Git Flows Test Plan - -## Overview - -Add tests for git branch/worktree flows. Two files: - -1. **Extend** `apps/renderer/src/store.test.ts` — reducer tests for `SET_THREAD_BRANCH` -2. **Create** `apps/renderer/src/git-flows.test.ts` — flow logic tests - -All tests are pure Vitest unit tests (no React rendering). They test the reducer directly and simulate handler logic via sequential reducer dispatches + mocked API calls. - -## File 1: `apps/renderer/src/store.test.ts` (extend) - -Add `describe("SET_THREAD_BRANCH reducer")` with 6 tests: - -- Sets branch + worktreePath atomically -- Clears both to null -- Updates branch while preserving worktreePath -- Does not affect other threads (multi-thread state) -- No-op for nonexistent thread id -- Does not mutate messages, error, or session fields - -Uses existing `makeThread`, `makeState` factories. - -## File 2: `apps/renderer/src/git-flows.test.ts` (new) - -### Factories - -- `makeThread()`, `makeState()`, `makeSession()` — same pattern as store.test.ts -- `makeBranch()` — creates `GitBranch` objects -- `makeMessage()` — creates `ChatMessage` objects -- `makeGitApi()` — returns `{ checkout, createWorktree, createBranch, listBranches }` with `vi.fn()` mocks - -### Test groups (~30 tests total) - -**1. Local branch checkout flow** (2 tests) - -- Successful checkout → SET_THREAD_BRANCH updates branch -- Checkout failure → SET_ERROR, branch unchanged - -**2. Thread branch conflict on send** (3 tests) - -- Two threads maintain independent branch state after SET_ACTIVE_THREAD -- Branch state preserved through multiple thread switches + updates -- Checkout failure on thread switch sets error only on target thread - -**3. Worktree creation on send** (5 tests) - -- First message in worktree mode → createWorktree → SET_THREAD_BRANCH with worktreePath -- No worktree when messages already exist -- No worktree in local envMode -- No worktree when worktreePath already set -- createWorktree failure → SET_ERROR, send aborted, no messages pushed - -**4. Env mode locking** (4 tests) - -- envLocked=false when no messages -- envLocked=true with messages -- Transitions false→true after PUSH_USER_MESSAGE -- Remains true after SET_ERROR and UPDATE_SESSION - -**5. Auto-fill current branch** (3 tests) - -- Dispatches SET_THREAD_BRANCH when thread has no branch and current branch exists -- Does not overwrite existing branch -- No-op when no branch is marked current - -**6. Default branch detection** (2 tests) - -- isDefault flag on branch objects -- current and isDefault can be on different branches - -**7. Branch creation + checkout** (3 tests) - -- Successful create + checkout updates branch -- createBranch failure → error, branch unchanged -- checkout failure after successful create → error, branch unchanged - -**8. Session CWD resolution** (3 tests) - -- Uses worktreePath when available -- cwdOverride takes precedence over worktreePath -- Falls back to project cwd when no worktree - -**9. Error handling patterns** (4 tests) - -- SET_ERROR sets error on correct thread -- SET_ERROR with null clears error -- Error on one thread doesn't affect others -- Error cleared before successful branch operations - -## Verification - -```bash -# Run all renderer tests -cd apps/renderer && bun run test - -# Run just the new test file -npx vitest run apps/renderer/src/git-flows.test.ts - -# Run just the store tests -npx vitest run apps/renderer/src/store.test.ts -``` diff --git a/.plans/git-integration-branch-picker-worktrees.md b/.plans/git-integration-branch-picker-worktrees.md deleted file mode 100644 index b5b5e82e..00000000 --- a/.plans/git-integration-branch-picker-worktrees.md +++ /dev/null @@ -1,115 +0,0 @@ -# Git Integration: Branch Picker + Worktrees - -## Summary - -Add git integration to let users start new threads from a specific branch, optionally creating a git worktree for isolated agent work. - -## UX Flow - -- **Left click** "+ New thread" → immediately creates a thread (current behavior, unchanged) -- **Right click** "+ New thread" → opens a context menu with git options: - - List of local branches → clicking one creates a thread on that branch (uses project cwd) - - Each branch has a "worktree" sub-option → creates a worktree, then creates thread with worktree as cwd -- When thread has a worktree, the agent session uses the worktree path as its cwd -- If git fails (not a repo), context menu shows "Not a git repository" disabled item - -## Changes - -### 1. `packages/contracts/src/git.ts` — CREATE - -New Zod schemas and types: - -- `gitListBranchesInputSchema` — `{ cwd: string }` -- `gitCreateWorktreeInputSchema` — `{ cwd: string, branch: string, path?: string }` -- `gitRemoveWorktreeInputSchema` — `{ cwd: string, path: string }` -- `gitBranchSchema` — `{ name: string, current: boolean }` -- Result types for each - -### 2. `packages/contracts/src/ipc.ts` — MODIFY - -- Add 3 IPC channels: `git:list-branches`, `git:create-worktree`, `git:remove-worktree` -- Add `git` namespace to `NativeApi` with `listBranches`, `createWorktree`, `removeWorktree` - -### 3. `packages/contracts/src/index.ts` — MODIFY - -- Add `export * from "./git"` - -### 4. `apps/desktop/src/main.ts` — MODIFY - -Add 3 IPC handlers + helper functions: - -- `listGitBranches()` — runs `git branch --no-color`, parses output into `{ name, current }[]` -- `createGitWorktree()` — runs `git worktree add `, defaults path to `../{repo}-worktrees/{branch}` -- `removeGitWorktree()` — runs `git worktree remove ` - -Reuses existing `runTerminalCommand()`. - -### 5. `apps/desktop/src/preload.ts` — MODIFY - -Add `git` namespace with 3 `ipcRenderer.invoke` calls. - -### 6. `apps/renderer/src/types.ts` — MODIFY - -Add to `Thread`: - -``` -branch: string | null -worktreePath: string | null -``` - -### 7. `apps/renderer/src/persistenceSchema.ts` — MODIFY - -- Add optional `branch`/`worktreePath` to persisted thread schema (`.nullable().optional()` for backwards compat) -- Add V3 schema, update union -- Update `hydrateThread` to default new fields to `null` -- Update `toPersistedState` to serialize new fields - -### 8. `apps/renderer/src/store.ts` — MODIFY - -- Update persisted state key to v3, keep v2 as legacy fallback - -### 9. `apps/renderer/src/components/Sidebar.tsx` — MODIFY (main UI work) - -- Keep existing left-click `handleNewThread` unchanged (immediate thread creation) -- Add `onContextMenu` handler to "+ New thread" buttons (both global and per-project) -- On right-click: fetch branches via `api.git.listBranches`, show a custom context menu -- Context menu items: branch names, each with a nested option to create with worktree -- Clicking a branch → creates thread with `branch` set, title = branch name -- Clicking "with worktree" → calls `api.git.createWorktree` first, then creates thread with `worktreePath` -- Show branch badge on thread list items -- If not a git repo, show "Not a git repository" as disabled menu item - -Context menu component: a positioned `
` with `position: fixed` anchored to the click position, dismissed on click-outside or Escape. Follows the existing dropdown pattern from ChatView's model picker. - -### 10. `apps/renderer/src/components/ChatView.tsx` — MODIFY - -- Line 157: use `activeThread.worktreePath ?? activeProject.cwd` as session cwd -- Show branch/worktree badge in header bar - -## Implementation Order - -1. `packages/contracts/src/git.ts` (new schemas) -2. `packages/contracts/src/ipc.ts` + `index.ts` (wire up channels) -3. `apps/desktop/src/main.ts` (git command handlers) -4. `apps/desktop/src/preload.ts` (bridge methods) -5. `apps/renderer/src/types.ts` (Thread type update) -6. `apps/renderer/src/persistenceSchema.ts` + `store.ts` (persistence migration) -7. `apps/renderer/src/components/Sidebar.tsx` (branch picker UI) -8. `apps/renderer/src/components/ChatView.tsx` (worktree cwd + badge) - -## Edge Cases - -- **Not a git repo**: `git branch` fails → context menu shows "Not a git repository" disabled item -- **Branch has slashes**: `feature/foo` → worktree dir becomes `feature-foo` -- **Worktree exists**: git error surfaces to user via inline error message in context menu -- **No persistence breakage**: `.nullable().optional()` fields parse fine with old data - -## Verification - -1. `turbo build` — confirm contracts/desktop/renderer all compile -2. Launch app, add a project pointing to a git repo -3. Click "+ New thread" → verify branch list loads -4. Select a branch, click Start → thread created with branch in title -5. Enable worktree checkbox, pick branch, Start → verify worktree directory created on disk -6. Send a message in worktree thread → verify agent runs in worktree cwd -7. Add a non-git project → verify graceful error, can still create thread diff --git a/.plans/spec-1-1-cutover-plan.md b/.plans/spec-1-1-cutover-plan.md deleted file mode 100644 index 7345995f..00000000 --- a/.plans/spec-1-1-cutover-plan.md +++ /dev/null @@ -1,252 +0,0 @@ -# Spec 1:1 Cutover Plan - -Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft. - -Execution mode for this plan: - -- Hard cutover only. Existing DB and migration history are disposable. -- Intermediate steps are allowed to break runtime, tests, typecheck, and lint. -- We optimize for small, reviewable work units, not continuous app operability. -- Only the final gate requires everything to run cleanly. - -## 1. Freeze SPEC contract as source of truth - -Work units: - -- Create `.plans/spec-contract-matrix.md` with one row per requirement in `SPEC.md` sections `7.1`-`7.4`. -- Add exact SQL-level requirements per row: table, column, type, nullability, PK/unique, index, and invariants. -- Add app-level requirements per row: writer path, reader path, and owning module. -- Mark each row with status labels: `required`, `implemented`, `to-replace`, `delete`. -- Identify any ambiguous spec lines and record a concrete interpretation in the matrix. - -Deliverables: - -- Complete matrix file with no unclassified rows. -- Single source checklist used by all later steps. - -Breakage allowed: - -- No code changes required yet. - -Exit criteria: - -- Every requirement in `7.1`-`7.4` has exactly one matrix row. - -## 2. Hard cutover migrations (replace current migration set) - -Work units: - -- Delete the current legacy migration files and rewrite migration loader ordering. -- Create `001_orchestration_events.ts` with full envelope columns and required event indexes. -- Create `002_orchestration_command_receipts.ts` with PK + lookup indexes. -- Create `003_checkpoint_diff_blobs.ts` with uniqueness on `(thread_id, from_turn_count, to_turn_count)`. -- Create `004_provider_session_runtime.ts` with PK and runtime lookup indexes. -- Create `005_projections.ts` with all projection tables: - - `projection_projects` - - `projection_threads` - - `projection_thread_messages` - - `projection_thread_activities` - - `projection_thread_sessions` - - `projection_thread_turns` - - `projection_checkpoints` - - `projection_pending_approvals` - - `projection_state` -- Add all required indexes/constraints in `005_projections.ts`. -- Ensure old tables (`projects`, `provider_checkpoints`, `provider_sessions`) are not recreated. - -Deliverables: - -- New 5-file migration chain. -- Updated migration loader references only new migrations. - -Breakage allowed: - -- Repositories/services can be temporarily broken due to removed old tables. - -Exit criteria: - -- Fresh DB initializes with only canonical tables plus migration bookkeeping. - -## 3. Align persistence row/request schemas to DB 1:1 - -Work units: - -- Define row schemas for each canonical table (contracts or persistence layer module). -- Define request schemas for every insert/update/query operation touching canonical tables. -- Remove or deprecate row/request schemas tied to deleted legacy tables. -- Normalize enum and null semantics to match contracts exactly. -- Ensure SQL aliases map 1:1 to schema field names (no implicit shape transforms). - -Deliverables: - -- Canonical row/request schemas committed. -- Zero references to legacy row schemas in active code paths. - -Breakage allowed: - -- Runtime can still fail while query layers are being rewired. - -Exit criteria: - -- Every canonical table used in code has a typed row schema and typed request schema. - -## 4. Rewrite event store for full persisted envelope - -Work units: - -- Refactor append path to write full envelope fields: - - `event_id`, `aggregate_kind`, `stream_id`, `stream_version`, `event_type`, `occurred_at`, `command_id`, `causation_event_id`, `correlation_id`, `actor_kind`, `payload_json`, `metadata_json` -- Implement stream version assignment/checking per aggregate stream. -- Refactor read/replay path to decode payload and metadata from JSON and return `OrchestrationEvent` consistently. -- Remove assumptions from old minimal schema (`aggregate_id`, missing metadata/actor). -- Add explicit SQL ordering guarantees for replay (`ORDER BY sequence ASC`). - -Deliverables: - -- Event store append/replay fully aligned with canonical envelope. - -Breakage allowed: - -- Command dispatch flow can be partially broken until receipts/projectors are updated. - -Exit criteria: - -- Event store no longer depends on legacy event table shape. - -## 5. Add command receipt idempotency - -Work units: - -- Introduce persistence access layer for `orchestration_command_receipts`. -- In command dispatch flow, check existing receipt by `commandId` before append. -- On first execution, persist accepted receipt with `resultSequence`. -- On domain rejection, persist rejected receipt with error payload. -- On duplicate command, return prior result from receipt without re-appending event. -- Ensure receipt write and event append ordering is deterministic. - -Deliverables: - -- Dispatch path with idempotency behavior wired through receipts. - -Breakage allowed: - -- Snapshot/read model may still be inconsistent until projectors are fully wired. - -Exit criteria: - -- Duplicate command IDs no longer create duplicate events. - -## 6. Build DB-backed projection pipeline - -Work units: - -- Create projector runner that consumes events and applies table-specific projections. -- Implement projector handlers for each projection table. -- For each handler, update target row(s) and `projection_state.last_applied_sequence` in the same transaction. -- Define projector names used in `projection_state` and make them stable constants. -- Add replay bootstrap from event store to bring projections up to latest sequence on startup. -- Add safe resume logic from projector `last_applied_sequence`. - -Deliverables: - -- Persistent projector pipeline writing all `projection_*` tables. - -Breakage allowed: - -- Web/API layer may still read old in-memory model until step 7. - -Exit criteria: - -- Events drive projection rows in DB; projection state advances transactionally. - -## 7. Move RPC reads to projections and diff blobs - -Work units: - -- Implement snapshot query service reading only projection tables. -- Build thread hydration from projection rows: messages, activities, checkpoints, session. -- Compute `snapshotSequence` as the minimum required projector sequence from `projection_state`. -- Implement `getTurnDiff` query backed by `checkpoint_diff_blobs` only. -- Remove or bypass in-memory snapshot construction for RPC responses. -- Validate replay handoff contract: snapshot sequence -> replay from `fromSequenceExclusive`. - -Deliverables: - -- `orchestration.getSnapshot` and `orchestration.getTurnDiff` served from DB projections/blob store. - -Breakage allowed: - -- Provider runtime persistence may still be partially legacy until step 8. - -Exit criteria: - -- No orchestration read RPC depends on legacy tables or in-memory-only state. - -## 8. Migrate provider runtime persistence to canonical table - -Work units: - -- Create repository/service for `provider_session_runtime`. -- Update adapter/session manager to persist runtime/resume cursor in new table. -- Ensure domain-visible session state still flows through orchestration events to `projection_thread_sessions`. -- Remove writes to legacy provider session tables. -- Verify restart/resume path reads runtime state from canonical table only. - -Deliverables: - -- Provider runtime state entirely backed by `provider_session_runtime`. - -Breakage allowed: - -- Some legacy interfaces may still exist but should be disconnected. - -Exit criteria: - -- Runtime restore no longer reads/writes legacy provider session persistence. - -## 9. Remove old cruft aggressively - -Work units: - -- Delete legacy repositories/services that map to removed tables. -- Remove dead migration imports and obsolete persistence service interfaces. -- Remove compatibility code paths that translate legacy row shapes. -- Remove unused contracts/types linked to deprecated persistence model. -- Update internal docs/comments to reference canonical projection/event model only. - -Deliverables: - -- Legacy persistence and translation layers removed from active codebase. - -Breakage allowed: - -- Temporary compile failures acceptable while deletion/refactor is in progress. - -Exit criteria: - -- No production code path references deleted legacy tables/services. - -## 10. Final verification gate (first point where green is required) - -Work units: - -- Add migration tests that assert canonical tables, columns, constraints, and indexes. -- Add event store tests for envelope persistence, metadata, actor kind, and replay. -- Add receipt idempotency tests for accept/reject/duplicate paths. -- Add projector tests for transactional row updates + `projection_state` updates. -- Add snapshot tests verifying projection-sourced output and `snapshotSequence` semantics. -- Add turn diff tests verifying `checkpoint_diff_blobs` source of truth. -- Add provider runtime tests for persist + restart + resume behavior. -- Run project lint/typecheck/tests and fix failures. - -Deliverables: - -- Green checks with canonical schema + persistence model in place. - -Breakage allowed: - -- None at end of step. - -Exit criteria: - -- SPEC `7.1`-`7.4` requirements satisfied and validated by tests. diff --git a/.plans/spec-contract-matrix.md b/.plans/spec-contract-matrix.md deleted file mode 100644 index 7cbb9509..00000000 --- a/.plans/spec-contract-matrix.md +++ /dev/null @@ -1,433 +0,0 @@ -# SPEC Contract Matrix (Sections 7.1-7.4) - -Status legend: - -- `required`: requirement acknowledged, no current implementation claim yet. -- `implemented`: requirement currently satisfied in code + schema. -- `to-replace`: partial/misaligned implementation exists and must be replaced. -- `delete`: current path actively conflicts with SPEC and should be removed. - -## 7.1 Write-Side Persisted Tables - -### W1 - -- Spec ref: `7.1.1 orchestration_events` -- Requirement: append-only event store with canonical envelope columns. -- SQL contract: - - `sequence INTEGER PRIMARY KEY` (global monotonic) - - `event_id TEXT UNIQUE NOT NULL` - - `aggregate_kind TEXT NOT NULL CHECK IN ('project','thread')` - - `stream_id TEXT NOT NULL` - - `stream_version INTEGER NOT NULL` - - `event_type TEXT NOT NULL` - - `occurred_at TEXT NOT NULL` - - `command_id TEXT NULL` - - `causation_event_id TEXT NULL` - - `correlation_id TEXT NULL` - - `actor_kind TEXT NOT NULL CHECK IN ('client','server','provider')` - - `payload_json TEXT NOT NULL` - - `metadata_json TEXT NOT NULL` -- Current writer path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` -- Current reader path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` -- Owner module: `apps/server/src/persistence` (event store + migrations) -- Status: `to-replace` -- Notes: current migration/table lacks `stream_id`, `stream_version`, `causation_event_id`, `correlation_id`, `actor_kind`, `metadata_json`. - -### W2 - -- Spec ref: `7.1.2 orchestration_command_receipts` -- Requirement: command idempotency + ack replay receipts table. -- SQL contract: - - `command_id TEXT PRIMARY KEY` - - `aggregate_kind TEXT NOT NULL CHECK IN ('project','thread')` - - `aggregate_id TEXT NOT NULL` - - `accepted_at TEXT NOT NULL` - - `result_sequence INTEGER NOT NULL` - - `status TEXT NOT NULL CHECK IN ('accepted','rejected')` - - `error TEXT NULL` -- Current writer path: none -- Current reader path: none -- Owner module: `apps/server/src/orchestration` dispatch boundary + `apps/server/src/persistence` -- Status: `to-replace` -- Notes: missing table and missing idempotency flow. - -### W3 - -- Spec ref: `7.1.3 checkpoint_diff_blobs` -- Requirement: store large plaintext diffs separate from checkpoint summaries. -- SQL contract: - - `thread_id TEXT NOT NULL` - - `from_turn_count INTEGER NOT NULL` - - `to_turn_count INTEGER NOT NULL` - - `diff TEXT NOT NULL` - - `created_at TEXT NOT NULL` - - `UNIQUE(thread_id, from_turn_count, to_turn_count)` -- Current writer path: none -- Current reader path: none -- Owner module: `apps/server/src/persistence` + turn diff query service -- Status: `to-replace` -- Notes: no canonical diff blob table yet. - -### W4 - -- Spec ref: `7.1.4 provider_session_runtime` -- Requirement: server-internal provider runtime/resume state. -- SQL contract: - - `provider_session_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `provider_name TEXT NOT NULL` - - `adapter_key TEXT NOT NULL` - - `provider_thread_id TEXT NULL` - - `status TEXT NOT NULL CHECK IN ('starting','running','stopped','error')` - - `last_seen_at TEXT NOT NULL` - - `resume_cursor_json TEXT NULL` - - `runtime_payload_json TEXT NULL` -- Current writer path: legacy provider session persistence (`apps/server/src/persistence/Layers/ProviderSessions.ts`) -- Current reader path: legacy provider session persistence (`apps/server/src/persistence/Layers/ProviderSessions.ts`) -- Owner module: provider runtime manager + persistence runtime repository -- Status: `to-replace` -- Notes: existing `provider_sessions` schema is incompatible and too small. - -## 7.2 Canonical Persisted Event Schema - -### E1 - -- Spec ref: `7.2 OrchestrationPersistedEventSchema` -- Requirement: full typed persisted event envelope in shared contracts. -- SQL contract: envelope fields in W1 must map 1:1 to contracts schema. -- Current writer path: contracts defined in `packages/contracts/src/orchestration.ts` -- Current reader path: used by persistence decode boundaries (partial) -- Owner module: `packages/contracts` -- Status: `implemented` -- Notes: contract schema exists; DB + store mapping still incomplete. - -### E2 - -- Spec ref: `7.2 Rules/payload discriminated by eventType` -- Requirement: `payload` validation keyed by `eventType`. -- SQL contract: `event_type` drives payload decode schema; invalid combinations rejected. -- Current writer path: `packages/contracts/src/orchestration.ts` -- Current reader path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` decode path -- Owner module: contracts + event store -- Status: `to-replace` -- Notes: decode is present but DB does not persist full envelope columns. - -### E3 - -- Spec ref: `7.2 Rules/provider ids scope` -- Requirement: provider ids live in metadata/provider payload, not as thread identity replacement. -- SQL contract: provider fields persisted inside `metadata_json`; `stream_id` remains project/thread id. -- Current writer path: `apps/server/src/orchestration/decider.ts` (metadata mostly empty) -- Current reader path: projector/event consumers -- Owner module: decider + provider ingestion + event store -- Status: `to-replace` -- Notes: metadata plumbing is incomplete in persistence path. - -### E4 - -- Spec ref: `7.2 Rules/streamVersion concurrency guard` -- Requirement: stream version monotonic per aggregate stream; enforced on write. -- SQL contract: `stream_version INTEGER NOT NULL` + uniqueness/invariant enforcement per stream. -- Current writer path: none -- Current reader path: none -- Owner module: event store append logic + DB constraints -- Status: `to-replace` -- Notes: no stream version assignment/checking today. - -## 7.3 Required Projected Tables (Read Models) - -### P1 - -- Spec ref: `7.3.1 projection_projects` -- Requirement: persisted project projection table. -- SQL contract: - - `project_id TEXT PRIMARY KEY` - - `title TEXT NOT NULL` - - `workspace_root TEXT NOT NULL` - - `default_model TEXT NULL` - - `created_at TEXT NOT NULL` - - `updated_at TEXT NOT NULL` - - `deleted_at TEXT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: legacy `projects` table is separate concept and should be removed from orchestration model. - -### P2 - -- Spec ref: `7.3.2 projection_threads` -- Requirement: persisted thread projection table. -- SQL contract: - - `thread_id TEXT PRIMARY KEY` - - `project_id TEXT NOT NULL` - - `title TEXT NOT NULL` - - `model TEXT NOT NULL` - - `branch TEXT NULL` - - `worktree_path TEXT NULL` - - `latest_turn_id TEXT NULL` - - `created_at TEXT NOT NULL` - - `updated_at TEXT NOT NULL` - - `deleted_at TEXT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: missing table and projector writes. - -### P3 - -- Spec ref: `7.3.3 projection_thread_messages` -- Requirement: persisted thread message projection table. -- SQL contract: - - `message_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `turn_id TEXT NULL` - - `role TEXT NOT NULL CHECK IN ('user','assistant','system')` - - `text TEXT NOT NULL` - - `is_streaming INTEGER/BOOLEAN NOT NULL` - - `created_at TEXT NOT NULL` - - `updated_at TEXT NOT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: missing table and message projection writes. - -### P4 - -- Spec ref: `7.3.4 projection_thread_activities` -- Requirement: persisted thread activity projection table. -- SQL contract: - - `activity_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `turn_id TEXT NULL` - - `tone TEXT NOT NULL CHECK IN ('info','tool','approval','error')` - - `kind TEXT NOT NULL` - - `summary TEXT NOT NULL` - - `payload_json TEXT NOT NULL` - - `created_at TEXT NOT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: no canonical activity projection persistence. - -### P5 - -- Spec ref: `7.3.5 projection_thread_sessions` -- Requirement: persisted thread session projection table. -- SQL contract: - - `thread_id TEXT PRIMARY KEY` - - `status TEXT NOT NULL CHECK IN ('idle','starting','running','ready','interrupted','stopped','error')` - - `provider_name TEXT NULL` - - `provider_session_id TEXT NULL` - - `provider_thread_id TEXT NULL` - - `active_turn_id TEXT NULL` - - `last_error TEXT NULL` - - `updated_at TEXT NOT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: current provider session table is not this domain projection. - -### P6 - -- Spec ref: `7.3.6 projection_thread_turns` -- Requirement: persisted thread turn projection table. -- SQL contract: - - `turn_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `turn_count INTEGER NOT NULL` - - `status TEXT NOT NULL CHECK IN ('running','completed','interrupted','error')` - - `user_message_id TEXT NULL` - - `assistant_message_id TEXT NULL` - - `started_at TEXT NOT NULL` - - `completed_at TEXT NULL` -- Current writer path: none -- Current reader path: none -- Owner module: projector pipeline + session/turn query helpers -- Status: `to-replace` -- Notes: missing table and projection logic. - -### P7 - -- Spec ref: `7.3.7 projection_checkpoints` -- Requirement: persisted checkpoint summary projection table. -- SQL contract: - - `thread_id TEXT NOT NULL` - - `turn_id TEXT NOT NULL` - - `checkpoint_turn_count INTEGER NOT NULL` - - `checkpoint_ref TEXT NOT NULL` - - `status TEXT NOT NULL CHECK IN ('ready','missing','error')` - - `files_json TEXT NOT NULL` - - `assistant_message_id TEXT NULL` - - `completed_at TEXT NOT NULL` - - `UNIQUE(thread_id, checkpoint_turn_count)` -- Current writer path: legacy `provider_checkpoints` writes in `apps/server/src/persistence/Layers/Checkpoints.ts` -- Current reader path: legacy checkpoint repository -- Owner module: projector pipeline + checkpoint query layer -- Status: `to-replace` -- Notes: current table semantics do not match canonical checkpoint projection schema. - -### P8 - -- Spec ref: `7.3.8 projection_pending_approvals` -- Requirement: persisted pending-approval projection table. -- SQL contract: - - `request_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `turn_id TEXT NULL` - - `status TEXT NOT NULL CHECK IN ('pending','resolved')` - - `decision TEXT NULL CHECK IN ('accept','acceptForSession','decline','cancel')` - - `created_at TEXT NOT NULL` - - `resolved_at TEXT NULL` -- Current writer path: none -- Current reader path: none -- Owner module: projector pipeline + approval query layer -- Status: `to-replace` -- Notes: missing table and projection logic. - -### P9 - -- Spec ref: `7.3.9 projection_state` -- Requirement: projector progress tracking table. -- SQL contract: - - `projector TEXT PRIMARY KEY` - - `last_applied_sequence INTEGER NOT NULL` - - `updated_at TEXT NOT NULL` -- Current writer path: none -- Current reader path: none -- Owner module: projector runner/checkpointing -- Status: `to-replace` -- Notes: missing table and projector bookkeeping. - -### P10 - -- Spec ref: `7.3 Projection consistency rules` -- Requirement: projector row updates and `projection_state` update must be atomic per event. -- SQL contract: per-projector transaction boundary covering both projection write and state update. -- Current writer path: none (in-memory projector has no SQL transaction) -- Current reader path: none -- Owner module: projector runner -- Status: `to-replace` -- Notes: requires transactional projection executor. - -### P11 - -- Spec ref: `7.3 Optional debug field` -- Requirement: `lastEventSequence` on projection rows is optional and not required for correctness. -- SQL contract: optional; not required in baseline schema. -- Current writer path: none -- Current reader path: none -- Owner module: projector runner -- Status: `required` -- Notes: interpretation: exclude from first cutover unless debugging requires it. - -## 7.4 Snapshot and RPC Requirements - -### R1 - -- Spec ref: `7.4.1` -- Requirement: `orchestration.getSnapshot` fully served from projection tables and returns `snapshotSequence`. -- SQL contract: snapshot query joins/reads only `projection_*` + `projection_state`. -- Current writer path: in-memory model built in `apps/server/src/orchestration/projector.ts` -- Current reader path: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts#getReadModel` -- Owner module: snapshot query service + ws RPC handler -- Status: `delete` -- Notes: current in-memory read model path must be removed for SPEC compliance. - -### R2 - -- Spec ref: `7.4.2` -- Requirement: snapshot `projects[]` source is `projection_projects`. -- SQL contract: `projects` collection assembled from `projection_projects` rows. -- Current writer path: none -- Current reader path: in-memory thread/project arrays -- Owner module: snapshot query service -- Status: `to-replace` -- Notes: no DB project projection reader exists yet. - -### R3 - -- Spec ref: `7.4.3` -- Requirement: thread snapshot `checkpoints[]` source is `projection_checkpoints` with required fields. -- SQL contract: fields `turnId`, `completedAt`, `status`, `files[]`, `checkpointRef`, optional `assistantMessageId`, `checkpointTurnCount`. -- Current writer path: legacy checkpoint repo data model -- Current reader path: in-memory checkpoints from orchestration events -- Owner module: snapshot query service + checkpoint projector -- Status: `to-replace` -- Notes: canonical projection table and reader not implemented. - -### R4 - -- Spec ref: `7.4.4` -- Requirement: no `listCheckpoints` orchestration RPC; list in snapshot + full diff via `getTurnDiff` from diff blobs. -- SQL contract: `getTurnDiff` reads `checkpoint_diff_blobs` only. -- Current writer path: none for diff blobs -- Current reader path: `orchestration.getTurnDiff` schema exists, data backing incomplete -- Owner module: ws RPC handler + diff query service -- Status: `to-replace` -- Notes: current checkpoint repository is not canonical source. - -### R5 - -- Spec ref: `7.4.5` -- Requirement: client acts on `ThreadId`; server resolves provider session via `projection_thread_sessions`. -- SQL contract: session lookup by `thread_id` from projection table. -- Current writer path: mixed provider/session handling paths -- Current reader path: legacy provider session persistence lookups -- Owner module: provider dispatch/session resolution -- Status: `to-replace` -- Notes: remove provider-session-as-routing-key behavior. - -### R6 - -- Spec ref: `7.4.6` -- Requirement: `snapshotSequence` derived from `projection_state` minimum over dependent projectors. -- SQL contract: `MIN(last_applied_sequence)` across required projector keys. -- Current writer path: none -- Current reader path: currently from in-memory event projection sequence -- Owner module: snapshot query service -- Status: `to-replace` -- Notes: must move from in-memory sequence to DB projection-state semantics. - -### R7 - -- Spec ref: `7.4.7` -- Requirement: snapshot/replay handoff has no gap (`getSnapshot` -> subscribe from snapshot sequence). -- SQL contract: read consistency strategy guaranteeing no missing events between snapshot visibility and replay start. -- Current writer path: event stream via `OrchestrationEventStore.readFromSequence` -- Current reader path: ws replay flow in `apps/server/src/wsServer.ts` -- Owner module: ws RPC + event stream handoff layer -- Status: `to-replace` -- Notes: interpretation requires explicit consistency boundary (transaction, sequence fence, or equivalent). - -## Ambiguous/Interpretation Decisions (tracked upfront) - -### A1 - -- Topic: `orchestration_events.stream_id` vs event runtime `aggregateId` naming. -- Decision: persist canonical DB column name `stream_id`; map to runtime `aggregateId` where needed in decider/projector code. - -### A2 - -- Topic: JSON column typing in SQLite for `payload`, `metadata`, projection payload/files, runtime cursor/payload. -- Decision: store as `TEXT` JSON with strict encode/decode schemas at boundaries. - -### A3 - -- Topic: `snapshotSequence` dependency set for min-sequence computation. -- Decision: include all projectors used to construct snapshot payload (`projects`, `threads`, `messages`, `activities`, `sessions`, `turns`, `checkpoints`, `pending_approvals`). - -### A4 - -- Topic: no-gap handoff mechanism in `7.4.7`. -- Decision: implement explicit sequence fence semantics at snapshot time; replay starts from fence `fromSequenceExclusive`. - -## Checklist Completeness Statement - -- Coverage scope: `SPEC.md` sections `7.1`, `7.2`, `7.3`, `7.4`. -- Requirement rows present: `W1-W4`, `E1-E4`, `P1-P11`, `R1-R7`. -- Unclassified rows: `0`. diff --git a/.selene/adrs/0001-adopt-selene-strict-close-methodology.md b/.selene/adrs/0001-adopt-selene-strict-close-methodology.md deleted file mode 100644 index 3176f763..00000000 --- a/.selene/adrs/0001-adopt-selene-strict-close-methodology.md +++ /dev/null @@ -1,8 +0,0 @@ -# ADR-0001: Adopt Selene strict-close methodology - -- Status: Accepted -- Date: 2026-05-21 - -## Context - -This project uses Selene for cross-LLM research bursts. The methodology includes append-only ADRs, hashed invariants over Python/Rust/JavaScript/TypeScript AST signatures, SHA-256 manifests for canonical files, executable strict-close classifications, and a mandatory hard-non-claims block on every artifact. See README.md for the loop. diff --git a/.selene/adrs/0002-remove-hosted-t3-domains-later.md b/.selene/adrs/0002-remove-hosted-t3-domains-later.md deleted file mode 100644 index 109bb843..00000000 --- a/.selene/adrs/0002-remove-hosted-t3-domains-later.md +++ /dev/null @@ -1,31 +0,0 @@ -# ADR-0002: Remove hosted t3.codes domains in a later migration - -- Status: Accepted -- Date: 2026-05-21 - -## Context - -Cafe Code still references hosted `t3.codes` domains for the current router, -latest, Nightly, and pairing surfaces. Those domains are active external -routing contracts today, and this repository does not currently define or host -replacement Cafe-owned domains. - -## Decision - -Keep the current hosted `t3.codes` defaults until Cafe-owned domains are -selected, configured, deployed, and verified. This is temporary. All -`t3.codes` hosted defaults must be removed in a later domain migration once the -replacement domains are live. - -The later migration must cover DNS ownership, TLS, deploy configuration, -router/latest/Nightly channel routing, pairing URLs, channel cookies/routes, -redirects from old URLs, and update-channel validation before removing the old -domain defaults. - -## Consequences - -- Current hosted `t3.codes` references are allowed only as temporary external - routing defaults. -- New code should not introduce additional `t3.codes` surfaces. -- Documentation must continue to track this as required cleanup, not as a - permanent Cafe Code domain strategy. diff --git a/.selene/bursts/001/approved.json b/.selene/bursts/001/approved.json deleted file mode 100644 index d9a05ef1..00000000 --- a/.selene/bursts/001/approved.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "approved_at": "2026-05-21T00:22:21.159773+00:00", - "burst": 1, - "note": "User approved Burst 001 to proceed from the adjudicated Cafe Code rename design plan through the full Selene burst lifecycle.", - "plan_path": ".selene/bursts/001/design/agreed_plan.md", - "plan_sha256": "40ec1156936fd7d13b2922a251b250f6623f30450a7b6d9890da47de4b8dec76" -} diff --git a/.selene/bursts/001/artifacts/IMPLEMENTATION_REPORT.md b/.selene/bursts/001/artifacts/IMPLEMENTATION_REPORT.md deleted file mode 100644 index 289257a8..00000000 --- a/.selene/bursts/001/artifacts/IMPLEMENTATION_REPORT.md +++ /dev/null @@ -1,67 +0,0 @@ -# Burst 001 Implementation Report - -## Terminology - -This report follows `.selene/terminology_lock_v1.md`. - -## Summary - -Implemented Burst 001 as a planning-only rename classification artifact. No -application source, package names, release configs, ADRs, or invariant files were -edited. The implementation classifies Cafe Code rename surfaces using only the -four labels admitted by the agreed plan and cites `.selene/classifications.py`. - -Security-related note: no security behavior was changed. The matrix explicitly -keeps persisted state, browser storage, auth-bearing paths, update channels, -provider client IDs, and external env vars stable unless a later burst adds a -tested migration or compatibility alias. - -## Artifacts - -- `.selene/bursts/001/artifacts/rename-classification.md` -- `.selene/bursts/001/design/rename-classification.md` mirrored to match the - artifact required by the agreed plan. -- `.selene/bursts/001/artifacts/IMPLEMENTATION_REPORT.md` - -## Self-Checks - -- Confirmed artifact/design matrix files are byte-identical: - `rename matrix artifact/design cmp exit=0`. -- Confirmed no old hyphenated or extra classification labels remain in the - matrix files. -- Confirmed canonical Hard Non-Claims block matches `.selene/nonclaims.md` by - diffing the tail of both created/updated markdown artifacts. -- Confirmed the matrix has 58 data rows and the three - `internal_rename_with_migration` rows each name a concrete migration - technique. -- Confirmed `git diff --stat` outside `.selene` is empty after checks. -- Confirmed no ADR or invariant file was edited. - -## Smoke and Gate Output - -Direct `bun` was not on PATH, so gates were run with transient Bun 1.3.11 via -`npm exec --yes --package=bun@1.3.11 -- bun ...`, matching `packageManager`. - -- `bun fmt`: passed. Output included `Finished ... on 1101 files using 16 threads.` -- `bun lint`: passed with warnings only. Output: `Found 9 warnings and 0 errors.` -- `bun typecheck`: passed. Output: `Tasks: 13 successful, 13 total`. -- `bun run release:smoke`: passed. Output: `Release smoke checks passed.` -- `bun run test` was not run because this burst touched only Selene planning - artifacts and no source behavior. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/001/artifacts/rename-classification.md b/.selene/bursts/001/artifacts/rename-classification.md deleted file mode 100644 index 3ccff93e..00000000 --- a/.selene/bursts/001/artifacts/rename-classification.md +++ /dev/null @@ -1,137 +0,0 @@ -# Burst 001 Rename Classification Matrix - -## Terminology - -This artifact follows `.selene/terminology_lock_v1.md`. - -- `Cafe Code` is the new user-facing product brand for this fork. -- `T3 Code` is the inherited/upstream product name and remains only for - compatibility, migration, release continuity, or historical provenance. -- `Nightly` remains a channel/stage label. Visible rename work must preserve - the channel identity and version/tag semantics end-to-end. - -## Classification Module Citation - -Classifications in this matrix are constrained by the Burst 001 agreed plan and -cited against `.selene/classifications.py`, the project-local executable -strict-close classification module. This artifact uses only these four admitted -rename classes: - -- `user_facing_rename_now` -- `internal_rename_with_migration` -- `compatibility_alias_leave_stable` -- `provenance_reference_leave_as_t3_code` - -## Source Review Scope - -Reviewed source surfaces came from `rg` scans for `T3 Code`, `t3code`, -`@t3tools`, `~/.t3`, `com.t3tools.t3code`, `T3CODE_*`, `t3.codes`, install -registry identifiers, hosted channel identifiers, and `refs/t3` across source, -docs, scripts, release workflow files, and Selene metadata. No ADR or invariant -file was edited. - -## Classification Matrix - -| ID | Surface and evidence | Class | Rationale | Later implementation note | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 1 | Web brand fallback: `apps/web/src/branding.ts` `APP_BASE_NAME = "T3 Code"` and derived `APP_DISPLAY_NAME`. | `user_facing_rename_now` | Fallback display copy is user-visible and not a persisted contract. | Rename base to `Cafe Code`; keep `Nightly`, `Latest`, `Dev`, and `Alpha` stage labels unchanged. | -| 2 | Desktop brand base: `apps/desktop/src/app/DesktopEnvironment.ts` `APP_BASE_NAME = "T3 Code"`. | `user_facing_rename_now` | It drives visible desktop display name, About panel, dock/menu name, and web injected branding. | Rename base to `Cafe Code`; preserve `resolveDesktopAppStageLabel` and `isNightlyDesktopVersion`. | -| 3 | Desktop runtime display and About panel: `DesktopAppIdentity.configure()` uses `environment.displayName`. | `user_facing_rename_now` | The name is user-visible application chrome. | Covered by the desktop brand base rename; smoke About panel and OS app name. | -| 4 | Desktop dev launcher display: `apps/desktop/scripts/electron-launcher.mjs` `T3 Code (Dev)` and `T3 Code (Alpha)`. | `user_facing_rename_now` | Dev app display names are human-facing and not external contracts. | Rename to `Cafe Code (Dev)` and `Cafe Code (Alpha)`; keep bundle ID stable. | -| 5 | Web boot shell and splash: `apps/web/index.html`, `apps/web/src/components/SplashScreen.tsx`. | `user_facing_rename_now` | Title, aria label, and image alt text are presentation copy. | Rename visible/accessibility text to Cafe Code. | -| 6 | Web user-facing status/copy: settings, update prompts, provider-disabled fallback messages, SSH password prompt, `T3 Server` fallback label. | `user_facing_rename_now` | These strings are visible to users and are not protocol IDs. | Rename to Cafe Code / Cafe Code Server while leaving machine keys untouched. | -| 7 | Server and CLI visible messages: `apps/server/src/bin.ts`, `startupAccess.ts`, provider disabled messages, maintenance runner messages. | `user_facing_rename_now` | These are human-facing logs/errors/help text. | Rename copy to Cafe Code; do not rename env vars, command names, or service IDs in the same burst. | -| 8 | Git author display names: `apps/server/src/vcs/GitVcsDriver.ts` `GIT_AUTHOR_NAME` and `GIT_COMMITTER_NAME` as `T3 Code`. | `user_facing_rename_now` | Commit author display name is visible metadata. | Rename names to Cafe Code only if keeping author email stable. | -| 9 | Git author emails: `t3code@users.noreply.github.com`. | `compatibility_alias_leave_stable` | Email identity may be consumed by tooling and history filters. | Keep stable until a release/operator decision creates and validates a new noreply identity. | -| 10 | Marketing site copy: `apps/marketing/src/pages/index.astro`, `download.astro`, `layouts/Layout.astro`. | `user_facing_rename_now` | Headings, CTAs, descriptions, alt text, and nav brand are public presentation copy. | Rename prose to Cafe Code while preserving active GitHub links unless the repo moves. | -| 11 | Marketing testimonial quotes: `apps/marketing/src/lib/tweets.ts`. | `provenance_reference_leave_as_t3_code` | Quoted historical user text names the upstream-era product. | Leave quote text unchanged unless replacing the testimonial corpus. | -| 12 | README and general docs product prose: `README.md`, `REMOTE.md`, `KEYBINDINGS.md`, `docs/*.md`, provider docs. | `user_facing_rename_now` | Product prose is user-facing documentation. | Rename prose to Cafe Code and add compatibility notes for old commands, env vars, and paths. | -| 13 | README install registry commands: `winget install T3Tools.T3Code`, `brew install --cask t3-code`, `yay -S t3code-bin`. | `compatibility_alias_leave_stable` | Registry IDs are external distribution contracts. | Keep commands stable until registry aliases/renames are submitted and verified. | -| 14 | AGENTS project snapshot and Codex-first prose. | `user_facing_rename_now` | Repository guidance should name the current project. | Rename to Cafe Code and mention the T3 Code lineage where relevant. | -| 15 | Root package identity: root `package.json` `@t3tools/monorepo`. | `compatibility_alias_leave_stable` | Workspace identity affects tooling, filters, and lockfile references. | Leave stable during brand rename. | -| 16 | Scoped workspace packages: `@t3tools/desktop`, `web`, `marketing`, `client-runtime`, `contracts`, `shared`, `ssh`, `tailscale`, `scripts`, `oxlint-plugin-t3code`. | `compatibility_alias_leave_stable` | Package names and imports are broad internal contracts. | Rename only in a later namespace cleanup with lockfile regeneration and import migration. | -| 17 | Server package and CLI: `apps/server/package.json` name `t3`, bin `t3`, docs using `npx t3`, `t3 serve`, `t3 auth`. | `compatibility_alias_leave_stable` | CLI name is an external user and remote-launch contract. | Keep `t3`; if desired, add a Cafe CLI alias later without removing `t3`. | -| 18 | Internal oxlint plugin: directory `oxlint-plugin-t3code`, package `@t3tools/oxlint-plugin-t3code`, plugin rule prefix `t3code/*`. | `compatibility_alias_leave_stable` | Tooling config and tests depend on the plugin package and rule namespace. | Rename only with a workspace/tooling migration that preserves old rule names or documents the break. | -| 19 | Desktop package product name: `apps/desktop/package.json` `productName: "T3 Code (Alpha)"`. | `user_facing_rename_now` | Product name is visible desktop packaging metadata. | Rename to `Cafe Code (Alpha)` only while preserving app ID and user data directory. | -| 20 | Desktop build product display: `scripts/build-desktop-artifact.ts` `T3 Code` / `T3 Code (Nightly)`. | `compatibility_alias_leave_stable` | Nightly release/build identity is tied to updater channel semantics in this burst. | Defer until a desktop packaging burst; preserve `nightly` channel, suffix, dist-tag, and update manifest behavior. | -| 21 | Desktop artifact filename: `T3-Code-${version}-${arch}.${ext}`. | `internal_rename_with_migration` | Artifact filenames are referenced by updater manifests and release assets. | Migrate with updater-manifest validation, old asset retention or redirect, and release smoke tests. | -| 22 | Staged desktop package name: generated `package.json` `name: "t3code"`. | `compatibility_alias_leave_stable` | Electron-builder/updater behavior may depend on the staged package identity. | Keep stable unless a packaging migration proves no updater impact. | -| 23 | Staged desktop package description/author: `"T3 Code desktop build"`, `"T3 Tools"`. | `user_facing_rename_now` | Description/author are human-readable artifact metadata. | Rename description to Cafe Code where safe; author/legal owner remains an operator/legal question. | -| 24 | Commit metadata field: `t3codeCommitHash` in build script and `DesktopAppIdentity.ts`. | `compatibility_alias_leave_stable` | Writer/reader schema coupling creates upgrade risk for no user-visible benefit. | Keep field stable, or add dual-read/dual-write before any rename. | -| 25 | Electron app IDs: `com.t3tools.t3code`, `com.t3tools.t3code.dev`. | `compatibility_alias_leave_stable` | App IDs bind OS identity, updater behavior, and installed app continuity. | Keep stable for visible rename. | -| 26 | Linux desktop identity: `t3code.desktop`, `t3code-dev.desktop`, `t3code`, `t3code-dev`, `StartupWMClass`. | `compatibility_alias_leave_stable` | Desktop entry and WM class are OS integration contracts. | Rename only with Linux package/desktop-entry migration and compatibility testing. | -| 27 | User data directory: `userDataDirName = "t3code"`, dev `t3code-dev`, legacy `T3 Code (Alpha)` / `T3 Code (Dev)`. | `compatibility_alias_leave_stable` | Renaming can orphan existing desktop state, tokens, and settings. | Keep stable. If later moving to `cafecode`, reuse the existing legacy fallback pattern in `DesktopAppIdentity.resolveUserDataPath` and include `t3code` as legacy. | -| 28 | Base home directory and server data: default `~/.t3`, `T3CODE_HOME`, logs/docs/keybindings paths. | `compatibility_alias_leave_stable` | Server state and docs rely on this persisted path and env var. | Add `CAFE_CODE_HOME` only as a tested alias with explicit precedence; keep `T3CODE_HOME`. | -| 29 | SSH launch state and package specs: `~/.t3/ssh-launch`, `t3 serve`, `t3@latest`, `t3@nightly`. | `compatibility_alias_leave_stable` | Remote scripts and saved launch state depend on these names. | Keep stable; any Cafe alias must preserve old specs and state paths. | -| 30 | SSH askpass internals: `t3code-ssh-askpass`, temp prefixes, `DISPLAY=t3code`, `T3_SSH_AUTH_SECRET`. | `compatibility_alias_leave_stable` | SSH helper names and env var are runtime contracts. | Add aliases only after proving no interaction with OpenSSH askpass behavior regresses. | -| 31 | Browser persisted storage keys: `t3code:client-settings:v1`, `t3code:saved-environment-registry:v1`. | `internal_rename_with_migration` | Renaming without migration loses browser settings and saved remote environments. | Use dual-read old/new keys, write-through to new key after successful decode, and tests for old-key migration. | -| 32 | Hosted app domains: `app.t3.codes`, `latest.app.t3.codes`, `nightly.app.t3.codes`. | `compatibility_alias_leave_stable` | Domains are external routing and Nightly channel contracts. | Keep until Cafe domains exist; migrate with dual routing, cookies, TLS, and Nightly verification. | -| 33 | Hosted channel route/cookie: `/__t3code/channel`, `t3code_web_channel`, `apps/web/vercel.ts`. | `compatibility_alias_leave_stable` | Existing hosted channel selection depends on these route/cookie names. | Future rename requires dual route plus dual cookie read/write before old names are retired. | -| 34 | Hosted pairing URL default: `VITE_HOSTED_APP_URL`, default `https://app.t3.codes`, `/pair`. | `compatibility_alias_leave_stable` | Pairing links are shared externally and may be saved in clients. | Keep stable until Cafe domain routing is live and old pairing URLs redirect safely. | -| 35 | Well-known environment endpoint: `/.well-known/t3/environment` in web, desktop, server, tailscale. | `compatibility_alias_leave_stable` | Remote clients, SSH discovery, and hosted pairing fetch this protocol endpoint. | Add `/.well-known/cafe-code/environment` only as an alias; keep the T3 endpoint. | -| 36 | Release workflow stable release name: `.github/workflows/release.yml` `name=T3 Code v$version`. | `user_facing_rename_now` | Stable release display name is public copy, not the channel identity itself. | Rename to Cafe Code in a release-copy burst with release smoke updates. | -| 37 | Nightly release names/version/tag/dist-tag: `scripts/resolve-nightly-release.ts`, `vX.Y.Z-nightly.YYYYMMDD.N`, npm `nightly`. | `compatibility_alias_leave_stable` | Nightly identity must be preserved end-to-end in Burst 001. | Do not alter version pattern, tags, dist-tag, or updater channel; any visible copy rename must keep these invariants and update smoke tests. | -| 38 | Update manifests and electron-updater channels: `latest*.yml`, `nightly*.yml`, `app-update.yml`, GitHub publish config. | `compatibility_alias_leave_stable` | Updater continuity is an installed-user contract. | Keep channels stable; use an electron-updater redirect/manifest strategy before changing publisher or asset names. | -| 39 | Release/publish repository identity: `pingdotgg/t3code` in package metadata, README, marketing links, build publish config env fallback. | `compatibility_alias_leave_stable` | Active release URLs and package metadata depend on the current repository. | Leave until a repo move is decided; then rely on GitHub redirects and update release configs in a dedicated burst. | -| 40 | Historical upstream/test URLs: `t3dotgg/t3-code` in release notification tests and upstream references/comments. | `provenance_reference_leave_as_t3_code` | These identify historical upstream or fixture references. | Keep unless the fixture purpose changes. | -| 41 | Release notification copy: `scripts/notify-discord-release.ts` and tests. | `user_facing_rename_now` | Discord announcement descriptions are user-facing release copy. | Rename copy in a release-copy burst; keep Nightly version/tag semantics unchanged. | -| 42 | `T3CODE_*` runtime/config env vars across server, desktop, web workflow, observability, dev-runner, source-control docs. | `compatibility_alias_leave_stable` | Env vars are operational interfaces likely used by scripts and deployments. | Add `CAFE_CODE_*` aliases only with explicit precedence, tests, and old-name compatibility. | -| 43 | `T3CODE_PROJECT_ROOT` and `T3CODE_WORKTREE_PATH` project script env vars. | `compatibility_alias_leave_stable` | User scripts may consume these variables. | Keep stable; aliases require tests preserving script behavior. | -| 44 | Observability service names/defaults: `t3-server`, `t3-local`, `T3CODE_OTLP_SERVICE_NAME`, trace docs. | `compatibility_alias_leave_stable` | Renaming fragments dashboards and alert queries. | Keep initially; optional Cafe aliases must preserve old defaults or document a dashboard migration. | -| 45 | Effect service IDs: strings such as `t3/desktop/AppIdentity`, `t3/persistence/...`, `t3/vcs/...`. | `compatibility_alias_leave_stable` | IDs are internal diagnostic/dependency identity and log correlation surfaces. | Rename only in optional internal cleanup after snapshots/log expectations are updated. | -| 46 | Effect span names such as `desktop.appIdentity.*`. | `compatibility_alias_leave_stable` | Current names are already brand-neutral and diagnostics may depend on them. | Leave unchanged. | -| 47 | Git checkpoint refs: `refs/t3/checkpoints` in checkpointing code/tests. | `compatibility_alias_leave_stable` | Existing refs must remain readable for recovery/revert flows. | Future rename needs dual-read support for old refs and tests for both prefixes. | -| 48 | Temporary worktree branch prefix: `WORKTREE_BRANCH_PREFIX = "t3code"`. | `compatibility_alias_leave_stable` | Existing/generated branch names may be active in repositories. | Rename only with acceptance of mixed history or config-driven dual-prefix handling. | -| 49 | VCS project config path: `.t3code/vcs.json`. | `compatibility_alias_leave_stable` | Repository-local config discovery depends on the path. | If `.cafecode/vcs.json` is added, read both with deterministic precedence. | -| 50 | Keybindings project config examples: `.t3code-keybindings.json` and `~/.t3/keybindings.json`. | `compatibility_alias_leave_stable` | These are user-editable persisted config paths. | Add Cafe paths only with dual discovery and migration docs. | -| 51 | Provider integration machine IDs: Codex `name: "t3code_desktop"`, Cursor `clientInfo.name: "t3-code"` / `t3-code-provider-probe`. | `compatibility_alias_leave_stable` | Provider-side integrations may cache or authorize these client IDs. | Change only after provider compatibility is verified; visible `title` strings can rename separately. | -| 52 | Provider integration visible titles: `T3 Code Desktop`, OpenCode session title `T3 Code ${threadId}`. | `user_facing_rename_now` | Titles are user-visible presentation strings. | Rename title text while keeping machine `name` fields stable. | -| 53 | Temp/test prefixes: `t3code-*`, `t3-*`, sample paths containing `t3code`. | `compatibility_alias_leave_stable` | Low-value test/internal names can affect snapshots and fixture intent. | Leave until touching adjacent tests; use deterministic migration only when behavior depends on names. | -| 54 | Asset filenames: `assets/prod/t3-black-*`, `assets/dev/.../T3.svg`, brand asset map. | `internal_rename_with_migration` | Build scripts reference these assets by path and visual assets need replacement art. | Create Cafe assets, update `scripts/lib/brand-assets.ts`, keep or redirect old filenames until all build refs are updated. | -| 55 | App icon/favicons visual content. | `user_facing_rename_now` | Icons are visible brand surfaces. | Rename only when Cafe artwork is available; otherwise mark as follow-up and keep inherited assets temporarily. | -| 56 | License copyright: `LICENSE` `Copyright (c) 2026 T3 Tools Inc.`. | `provenance_reference_leave_as_t3_code` | Copyright/legal owner is not a product-brand string. | Do not change without legal/operator direction. | -| 57 | Existing ADRs and Selene methodology references: `.selene/adrs/0001-*`, `.selene/terminology_lock_v1.md`. | `provenance_reference_leave_as_t3_code` | ADRs are append-only records and terminology lock identifies upstream lineage. | Do not edit existing ADRs or invariant files; create a new ADR only if a future approved plan requires it. | -| 58 | Generated lockfile/package-manager state: `bun.lock` references package names and workspaces. | `compatibility_alias_leave_stable` | Lockfile is generated from package-name policy and should not be edited directly. | Let package-manager updates regenerate it only in a package rename burst. | - -## Open Questions - -1. Should the CLI remain permanently `t3`, or should Cafe Code add a new CLI - alias while retaining `t3`? -2. Are Cafe Code icon/favicons available for the first visible rename burst, or - should inherited T3 assets remain until art exists? -3. Will the active release repository remain `pingdotgg/t3code`, or is a GitHub - repository rename planned? -4. Is there a target Cafe Code hosted domain for router, latest, and Nightly - channels? -5. Should `CAFE_CODE_*` env vars be introduced as aliases, and what precedence - should they have over `T3CODE_*`? -6. Should the `@t3tools/*` workspace scope ever be renamed, or is it a stable - internal lineage namespace? -7. Who owns any legal/author metadata change from `T3 Tools` to a Cafe Code - entity? -8. What is the exact electron-updater choreography if artifact filenames, - publish repository, or package product metadata are renamed? - -## Burst 001 Non-Implementation Boundary - -Burst 001 lands planning artifacts only. It does not rename packages, move -files, change release channels, alter persisted paths, or modify source -behavior. Later implementation bursts must choose one tier, run repository gates, -and preserve compatibility for persisted state and external contracts. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/001/audit.md b/.selene/bursts/001/audit.md deleted file mode 100644 index 0a57d46e..00000000 --- a/.selene/bursts/001/audit.md +++ /dev/null @@ -1,304 +0,0 @@ -# Burst 001 Audit — claude (auditor) - -## Terminology - -This audit follows `.selene/terminology_lock_v1.md`. - -## Scope - -Verify the Burst 001 planning artifact at -`.selene/bursts/001/artifacts/rename-classification.md` (mirrored to -`.selene/bursts/001/design/rename-classification.md`) and the implementation -report at `.selene/bursts/001/artifacts/IMPLEMENTATION_REPORT.md` against the -agreed plan at `.selene/bursts/001/design/agreed_plan.md`. - -## Method - -- Re-read the agreed plan and falsifying criteria. -- Diffed the two matrix copies (artifact vs. design) for byte identity. -- Diffed the trailing Hard Non-Claims blocks of both copies and the - implementation report against `.selene/nonclaims.md`. -- Enumerated the four admitted class labels and confirmed only those four - appear in the matrix as row classifications. -- Counted matrix data rows; counted occurrences of each class label. -- Verified every `internal_rename_with_migration` row carries a concrete - migration technique. -- Verified surfaces tied to Nightly channel identity are - `compatibility_alias_leave_stable` (or, where copy can rename, carry an - explicit instruction to preserve Nightly tag/version/dist-tag semantics). -- Spot-checked >25 cited source surfaces in `apps/web`, `apps/desktop`, - `apps/server`, `packages/shared`, `packages/ssh`, `scripts/`, `.github/`, - `README.md`, `LICENSE`, and `apps/server/package.json`. -- Verified the manifest hash in `approved.json`: - `plan_sha256 = 40ec1156936fd7d13b2922a251b250f6623f30450a7b6d9890da47de4b8dec76` - matches `sha256(.selene/bursts/001/design/agreed_plan.md)`. - -## Findings - -### Hashes and structural integrity - -- `cmp` of the two `rename-classification.md` copies returns exit 0 — the - artifact and design copies are byte-identical, as the implementation report - claims. -- `sha256(.selene/bursts/001/design/agreed_plan.md)` matches the - `plan_sha256` recorded in `.selene/bursts/001/approved.json`. - -### Hard Non-Claims block - -- The canonical Hard Non-Claims block at `.selene/nonclaims.md` (5 bullets, - 16 lines including heading and blank line) matches verbatim: - - the trailing block of `.selene/bursts/001/artifacts/rename-classification.md`, - - the trailing block of `.selene/bursts/001/design/rename-classification.md`, - - the trailing block of `.selene/bursts/001/artifacts/IMPLEMENTATION_REPORT.md`. -- `diff -u .selene/nonclaims.md ` returns no differences for each. - -### Strict-close compliance (classification labels) - -- Only the four labels admitted by the Burst 001 agreed plan and cited against - `.selene/classifications.py` appear in the matrix: - - `user_facing_rename_now` - - `internal_rename_with_migration` - - `compatibility_alias_leave_stable` - - `provenance_reference_leave_as_t3_code` -- No hyphenated, legacy, or extra labels are present. -- Matrix has 58 data rows. Label distribution across rows (excluding the - legend listing): - - `compatibility_alias_leave_stable`: 34 - - `user_facing_rename_now`: 17 - - `provenance_reference_leave_as_t3_code`: 4 - - `internal_rename_with_migration`: 3 - - Total: 58 (matches the implementation report's claim). - -### Falsifying-criteria check (agreed plan section "Falsifying criteria") - -1. Branch coverage (1–6) — PASS. - - Branch 1 display-name surfaces: rows 1 (web `branding.ts`), 2 (desktop - `DesktopEnvironment.ts`), 3 (`DesktopAppIdentity` About panel), 4 - (dev launcher), 5 (web boot/splash), 10 (marketing copy), 12 - (`README.md` / general docs), 14 (`AGENTS.md`). - - Branch 2 desktop product/artifact metadata: rows 19 (desktop - `package.json` `productName`), 20 (`scripts/build-desktop-artifact.ts` - productName), 21 (`artifactName = "T3-Code-${version}-${arch}.${ext}"`), - 24 (`t3codeCommitHash`), 25 (Electron `appId = "com.t3tools.t3code"`), - 26 (Linux desktop identity incl. `StartupWMClass`), 42 (`T3CODE_*` - env vars). - - Branch 3 workspace identity: rows 15 (`@t3tools/monorepo`), 16 - (`@t3tools/*` workspaces — 10 in actual repo, all enumerated), 17 - (server `t3` package and bin `t3`), 18 (`@t3tools/oxlint-plugin-t3code`). - - Branch 4 persisted/identity surfaces: rows 25 (`appUserModelId`), 26 - (`linuxDesktopEntryName`/`linuxWmClass`), 27 (`userDataDirName`), 28 - (`~/.t3` + `T3CODE_HOME`), 29 (SSH-launch state), 30 (askpass internals), - 32 (`app.t3.codes`), 13 (install registries `T3Tools.T3Code`, `t3-code`, - `t3code-bin`). - - Branch 5 update/release surfaces: rows 36 (release workflow), 37 - (Nightly version/tag/dist-tag), 38 (`latest*.yml` / `nightly*.yml` / - `app-update.yml`), 39 (`pingdotgg/t3code`), 41 (Discord release script). - - Branch 6 internal IDs / provenance: rows 45 (`t3/...` service IDs), 46 - (`desktop.appIdentity.*` spans), 57 (ADR-0001 and methodology), 56 - (license attribution). -2. Only admitted classes used — PASS (see label scan above). -3. `internal_rename_with_migration` rows carry concrete migration technique — - PASS. - - Row 21 (artifact filename): "updater-manifest validation, old asset - retention or redirect, and release smoke tests". - - Row 31 (browser persisted storage keys `t3code:client-settings:v1` and - `t3code:saved-environment-registry:v1`): "dual-read old/new keys, - write-through to new key after successful decode, and tests for - old-key migration". - - Row 54 (asset filenames): "Create Cafe assets, update - `scripts/lib/brand-assets.ts`, keep or redirect old filenames until all - build refs are updated". -4. Nightly channel identity surfaces are `compatibility_alias_leave_stable` — - PASS (with notes). - - Row 20 (`T3 Code (Nightly)` display in build script): - `compatibility_alias_leave_stable`. - - Row 37 (`vX.Y.Z-nightly.YYYYMMDD.N`, npm `nightly`): - `compatibility_alias_leave_stable`. - - Row 38 (`latest*.yml` / `nightly*.yml` / `app-update.yml`): - `compatibility_alias_leave_stable`. - - Row 32 (`nightly.app.t3.codes`): `compatibility_alias_leave_stable`. - - Rows that touch Nightly only via copy (Row 1 web brand fallback, - Row 41 Discord notification) are `user_facing_rename_now` but each - carries an explicit instruction to preserve Nightly stage/tag/version - semantics. The channel identity (tag pattern, dist-tag, manifest - endpoint, updater config) remains in compatibility-alias rows. - - Row 21 (desktop artifact filename) is `internal_rename_with_migration`, - not `compatibility_alias_leave_stable`. The artifact filename is - consumed by `electron-updater` manifests for all channels including - Nightly. The migration note "old asset retention or redirect, and - release smoke tests" preserves Nightly channel identity end-to-end by - keeping prior filenames resolvable. Reading the agreed-plan criterion - as "Nightly channel identity itself" (tag/dist-tag/manifest endpoint), - this classification is acceptable. Reading the criterion strictly as - "any surface whose rename affects Nightly artifact resolution" would - argue for `compatibility_alias_leave_stable`. Flagging as borderline, - not falsifying — see Advisory 1. -5. No code change proposed for Burst 001 — PASS. The matrix's - "Burst 001 Non-Implementation Boundary" section explicitly states - "Burst 001 lands planning artifacts only. It does not rename packages, - move files, change release channels, alter persisted paths, or modify - source behavior." - -### Source-citation spot checks (sampled) - -All checks below confirm the matrix row's source surface exists at the cited -location with the cited content. - -- `apps/web/src/branding.ts:18` — - `export const APP_BASE_NAME = ... ?? "T3 Code";` (matches Row 1). -- `apps/desktop/src/app/DesktopEnvironment.ts:83` — - `const APP_BASE_NAME = "T3 Code";` and the - `appUserModelId`/`linuxDesktopEntryName`/`linuxWmClass`/`userDataDirName` - bindings around lines 66–205 (matches Rows 2, 25, 26, 27). -- `apps/desktop/package.json:35` — `"productName": "T3 Code (Alpha)"` - (matches Row 19). -- `scripts/build-desktop-artifact.ts` — `appId: "com.t3tools.t3code"` (line - 571), `StartupWMClass: "t3code"` (line 607), `t3codeCommitHash` (lines 213 - and 785), `T3CODE_DESKTOP_*` env-var Config bindings (lines 241–251), - `artifactName: "T3-Code-${version}-${arch}.${ext}"` (line 573), all - matching Rows 20, 21, 24, 25, 26, 42. -- Root `package.json:2` — `"name": "@t3tools/monorepo"` (matches Row 15). -- `apps/server/package.json` — `"name": "t3"`, `"bin": { "t3": "./dist/bin.mjs" }`, - `repository.url = "https://github.com/pingdotgg/t3code"` (matches Rows 17, - 39). -- `apps/server/src/vcs/GitVcsDriver.ts:611–614` — git author/committer - configured as `T3 Code` with email `t3code@users.noreply.github.com` - (matches Rows 8 and 9). -- `packages/shared/src/git.ts:13` — `WORKTREE_BRANCH_PREFIX = "t3code"` - (matches Row 48). -- `packages/ssh/src/auth.ts:61` and surrounding — `SSH_ASKPASS_DIR_NAME = -"t3code-ssh-askpass"`, `T3_SSH_AUTH_SECRET` env var (matches Row 30). -- `scripts/dev-runner.ts:166, 483–484` and `dev-runner.test.ts:50–67` — - `T3CODE_HOME` env var, default `~/.t3` (matches Row 28). -- `apps/web/src/components/chat/ProviderModelPicker.browser.tsx` — uses - `t3code:client-settings:v1` localStorage key (matches Row 31). -- `apps/web/vercel.ts:3,5,6,36,46` — `ROUTER_HOST = "app.t3.codes"`, - `latest.app.t3.codes`, `nightly.app.t3.codes`, `/__t3code/channel` - (matches Rows 32, 33). -- `apps/web/src/environments/primary/context.ts:13` — - `SERVER_ENVIRONMENT_DESCRIPTOR_PATH = "/.well-known/t3/environment"` - (matches Row 35). -- `apps/server/src/orchestration/projector.test.ts` and `ProjectionPipeline.test.ts` - reference `refs/t3/checkpoints/...` (matches Row 47). -- `apps/server/src/provider/Layers/CodexProvider.ts:241–242, 280–281` — - `name: "t3code_desktop"`, `title: "T3 Code Desktop"`; `CursorProvider.ts:427` — - `clientInfo: { name: "t3-code-provider-probe", ... }` (matches Rows 51, 52). -- `apps/server/src/cli/config.ts:106` — default service name `t3-server` - (matches Row 44). -- `scripts/resolve-nightly-release.ts:59` — Nightly version pattern - `${baseVersion}-nightly.${date}.${runNumber}` (matches Row 37). -- `LICENSE` — `Copyright (c) 2026 T3 Tools Inc.` (matches Row 56). -- `.selene/adrs/0001-adopt-selene-strict-close-methodology.md` exists - (matches Row 57). - -### Method-signature invariants - -This burst was planning-only. No code was modified by the implementer's -recorded work, so no public method, function, schema, or context-service -signature changed. Confirmed by `git diff --stat` on tracked files (no -changes in app/package/lib paths; see Advisory 2 below for an unrelated -working-tree note on `AGENTS.md`). - -### `.selene/classifications.py` citation - -The agreed plan instructs that the four rename classes be "admitted by the -executable strict-close rules" of `.selene/classifications.py`. The project -file re-exports only `Classification`, `DEFAULT_TOLERANCE`, and -`classify_pair` from `selene.discipline.classifications`, whose runtime -classifier currently emits numeric-distance-series labels -(`coincides-pair`, `distinguishes-pair`, etc.) — not the four rename labels. -The four rename labels are predeclared in the agreed plan itself. The -matrix's wording "constrained by the Burst 001 agreed plan and cited against -`.selene/classifications.py`" is the agreed plan's chosen citation, so the -implementer is following the plan as written. No matrix rows use any label -outside the four predeclared ones. No action required for Burst 001; -auditor note for future ADR work — see Advisory 3. - -### Open questions list - -The matrix carries an explicit "Open Questions" section with 8 numbered -items: CLI alias scope (Q1), Cafe icon availability (Q2), -`pingdotgg/t3code` GitHub repo rename (Q3), Cafe hosted domain (Q4), -`CAFE_CODE_*` env var precedence (Q5), `@t3tools/*` workspace scope (Q6), -legal/author entity (Q7), and electron-updater choreography (Q8). These -overlap with the agreed plan's enumerated topics (GitHub repo rename -choreography vs. electron-updater, hosted pairing URL future, npm bin `t3` -alias scope) and add three further open items. Three agreed-plan topics -(install-registry submissions, oxlint plugin scope as a standalone item, -ADR append-only handling) are present in the matrix as decided rows -(Rows 13, 18, 57) rather than open questions. The agreed plan's wording -is "open-questions list (… install-registry submissions, oxlint plugin -scope, ADR append-only handling)"; reading this as a suggested coverage -rather than a strict membership requirement, the deliverable is satisfied -because each topic has a row with rationale and a per-surface -implementation note. See Advisory 4. - -## Advisories (non-blocking) - -1. Row 21 borderline classification. Desktop artifact filename - `T3-Code-${version}-${arch}.${ext}` is `internal_rename_with_migration` - with an updater-manifest-validation migration. A stricter reading of - "any surface affecting Nightly channel identity" could argue this row - should be `compatibility_alias_leave_stable`. The implementer's - classification preserves Nightly identity end-to-end via the named - migration technique and is defensible, but a follow-up ADR could pin - the interpretation explicitly. - -2. `AGENTS.md` working-tree note. `git status` at audit time shows - `AGENTS.md` modified outside `.selene/`. The file's mtime - (`May 21 09:38:49 2026`) is after the implementation report's mtime - (`May 21 09:33:34 2026`), and the diff adds a `## Search Discipline` - section about restricting broad `find` scans. This modification post-dates - the implementer's `git diff --stat outside .selene is empty` check and - appears to be a session-side or hook-side artifact unrelated to the - burst's classified surfaces. Not a Burst 001 defect; flagged so the - project owner can decide whether to revert or keep the addition. - -3. `.selene/classifications.py` does not currently declare the four - rename-class labels as executable strict-close categories. The - agreed plan and matrix treat the four labels as predeclared by the - plan and "cited against" classifications.py. A future ADR that codifies - these labels (either as exported names or as a declarative project - policy) would make the citation phrase literal rather than indicative. - Out of scope for Burst 001. - -4. Open-questions list coverage. The agreed plan's parenthetical - enumeration of suggested open questions includes "install-registry - submissions, oxlint plugin scope, ADR append-only handling". These - appear as decided matrix rows (13, 18, 57) rather than as items in - the Open Questions section. Surfaces are covered; the categorization - is more decisive than the plan suggested. A follow-up burst could - surface remaining triggers (e.g., "when do we submit a winget alias?") - as explicit open questions if those decisions are still pending. - -## Verdict - -- All five falsifying criteria are satisfied. -- Hard Non-Claims block is present and canonical in all required artifacts. -- Only the four predeclared rename classes appear in the matrix; no - unallowed label is present. -- Source citations spot-checked against actual files all match. -- Manifest hash `approved.json:plan_sha256` matches the agreed plan file - on disk. -- No method signatures changed; no source modified by the burst. - -Burst 001 is complete as a planning-only deliverable. - -AUDIT_PASS - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/001/design/adjudication.json b/.selene/bursts/001/design/adjudication.json deleted file mode 100644 index 6e393823..00000000 --- a/.selene/bursts/001/design/adjudication.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "burst": 1, - "decision": "auditor", - "note": "Both reconciliations agreed substantively on a source-grounded tiered rename classification plan. Adopt Claude's reconciliation for the agreed-plan skeleton and treat .selene/bursts/001/design/rename-classification.md as the detailed Burst 001 planning artifact for the Cafe Code rename. No implementation burst is approved yet.", - "phase": "design", - "recorded_at": "2026-05-21T00:16:27.273268+00:00", - "synthesised_agreed_plan": ".selene/bursts/001/design/agreed_plan.md" -} diff --git a/.selene/bursts/001/design/adjudication_request.md b/.selene/bursts/001/design/adjudication_request.md deleted file mode 100644 index 64d6a288..00000000 --- a/.selene/bursts/001/design/adjudication_request.md +++ /dev/null @@ -1,25 +0,0 @@ -# Adjudication Required — Burst 001 Design Phase - -The two agents disagreed during the design reconciliation. - -## Claude - -- Verdict: agree -- Preferred focus: source-derived-classification-matrix -- Document: `.selene/bursts/001/design/claude_reconciliation.md` - -## Codex - -- Verdict: agree -- Preferred focus: source-grounded-tiered-rename-classification -- Document: `.selene/bursts/001/design/codex_reconciliation.md` - -## Resolution - -Run one of: - -- `selene adjudicate 1 --decision=auditor [--note="..."]` -- `selene adjudicate 1 --decision=implementer [--note="..."]` -- `selene adjudicate 1 --decision=custom --note="..."` - -Then run `selene approve 1` to proceed. diff --git a/.selene/bursts/001/design/agreed_plan.md b/.selene/bursts/001/design/agreed_plan.md deleted file mode 100644 index 9db90ec0..00000000 --- a/.selene/bursts/001/design/agreed_plan.md +++ /dev/null @@ -1,59 +0,0 @@ -# Agreed Plan — Burst 001 (synthesised via user adjudication) - -- Decision: auditor -- Source: .selene/bursts/001/design/claude_reconciliation.md -- Status: AGREED via user adjudication - -## User adjudication note - -Both reconciliations agreed substantively on a source-grounded tiered rename classification plan. Adopt Claude's reconciliation for the agreed-plan skeleton and treat .selene/bursts/001/design/rename-classification.md as the detailed Burst 001 planning artifact for the Cafe Code rename. No implementation burst is approved yet. - ---- - ---- - -verdict: agree -preferred_focus: source-derived-classification-matrix -synthesis_or_argument: Both proposals independently converge on the same direction — produce a tiered classification of every rename surface before any implementation, using the four predeclared classes (user-facing rename now / internal rename with migration / compatibility alias / provenance reference). Adopt codex's clean framing for the deliverable shape (a classification matrix) and claude's source-grounded inventory (specific file paths, identifiers, persisted-state surfaces, install registries, and the existing `userDataDirName` migration pattern already in `apps/desktop/src/app/DesktopEnvironment.ts`) as the starting evidence the matrix must cover. Preserve the Nightly channel identity end-to-end. Defer all code changes to later bursts; this burst delivers only the planning artifact and an explicit open-questions list. - ---- - -# Burst 001 Reconciliation — claude - -## Selected Direction - -Source-grounded rename classification matrix — produce a single planning artifact that classifies every concrete T3 Code rename surface into one of the four predeclared classes, with rationale and a per-surface implementation note, before any code change. - -## Why this direction - -Both proposals arrived at the same recommendation, which is the direction the Burst 001 seed explicitly asks for. The classification map is what makes later renaming PRs safe, reviewable, and revertible; without it, a global string substitution would silently break installed users, paired remote environments, and the Nightly update path. Synthesizing claude's source-grounded inventory with codex's clean four-class framing gives the matrix concrete starting material (display strings, package identity, bundle ID, persisted dirs, env vars, hosted URL, install registries, ADR provenance) while honoring the seed's deferral of implementation. - -## Concrete Burst 001 Plan - -- Goal: Deliver a single markdown classification matrix at `.selene/bursts/001/design/rename-classification.md` that lists every `T3 Code` / `t3code` / `@t3tools/*` / `~/.t3` / `com.t3tools.t3code` surface in the repository, classifies each under one of the four predeclared classes, gives a one-line rationale, and names any migration/alias technique to reuse (e.g., the existing legacy `userDataDirName` fallback pattern in `apps/desktop/src/app/DesktopEnvironment.ts`). -- Branches / sub-tasks: - 1. Display-name surfaces: `apps/desktop/src/app/DesktopEnvironment.ts` `APP_BASE_NAME`, `apps/web/src/branding.ts` `APP_BASE_NAME`, About dialog, dock/menu labels, marketing site copy, README, AGENTS.md. - 2. Desktop product/artifact metadata: `apps/desktop/package.json` `productName`, `scripts/build-desktop-artifact.ts` (productName, artifactName, appId, executableName, StartupWMClass, `T3CODE_*` env vars, `t3codeCommitHash`). - 3. Workspace identity: root `package.json` `@t3tools/monorepo` and the eight `@t3tools/*` workspaces; the unscoped `t3` server package with bin `t3`; `oxlint-plugin-t3code`. - 4. Persisted/identity surfaces: Electron `appUserModelId`, `linuxDesktopEntryName`, `linuxWmClass`, `userDataDirName` (already mid-migration), `~/.t3` home, SSH-launch state path, hosted URL `app.t3.codes`, install registries (winget `T3Tools.T3Code`, brew `t3-code`, AUR `t3code-bin`). - 5. Update/release surfaces: `app-update.yml`, GitHub publish `pingdotgg/t3code`, `scripts/notify-discord-release.ts`, Nightly version pattern `-nightly.YYYYMMDD.N`. Preserve Nightly identity end-to-end. - 6. Internal IDs / provenance: `Context.Service` IDs like `"t3/desktop/AppIdentity"`, telemetry span names (already `desktop.appIdentity.*`, safe), ADR-0001, license attribution, upstream lineage references. - 7. For each item, assign exactly one class per `.selene/classifications.py` with a one-line rationale; flag anything ambiguous as an explicit open question rather than guessing. -- Predeclared classifications: cite `.selene/classifications.py` — only the four classes (`user_facing_rename_now`, `internal_rename_with_migration`, `compatibility_alias_leave_stable`, `provenance_reference_leave_as_t3_code`) admitted by the executable strict-close rules may appear in the matrix. -- Falsifying criteria: - - Matrix is falsified if any surface enumerated in branches 1–6 is missing from the final document. - - Matrix is falsified if any row carries a class not admitted by `.selene/classifications.py`. - - Matrix is falsified if any row in `internal_rename_with_migration` lacks a concrete migration technique reference (legacy-dir fallback pattern, `electron-updater` redirect strategy, GitHub repo rename redirect, etc.). - - Matrix is falsified if any surface affecting Nightly channel identity is classified as anything other than `compatibility_alias_leave_stable` for this burst. - - Matrix is falsified if it proposes any code change to land in Burst 001. -- Required deliverables: - - `.selene/bursts/001/design/rename-classification.md` containing the matrix and an open-questions list (GitHub repo rename choreography vs. `electron-updater`, hosted pairing URL future, npm bin `t3` alias scope, install-registry submissions, oxlint plugin scope, ADR append-only handling). - - No code changes, no package renames, no file moves. Verification gates `bun fmt`, `bun lint`, `bun typecheck` are not exercised this burst because no source is touched; they become gating once Burst 002+ begins implementation on a single tier. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, deadlock-free, data-loss-free, or compatible with any upstream provider API outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted unless it is grounded in source review and the repository's required quality gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not make Claude, Codex, Cursor, OpenCode, or any other provider semantically correct. -- This artifact does not claim coverage of credentials, tokens, local files, persisted conversations, WebSocket sessions, or provider subprocess behavior beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/001/design/claude_proposal.md b/.selene/bursts/001/design/claude_proposal.md deleted file mode 100644 index ee1a6cc9..00000000 --- a/.selene/bursts/001/design/claude_proposal.md +++ /dev/null @@ -1,69 +0,0 @@ -I have enough context. Producing the proposal now. - -# Burst 001 Design Proposal — claude - -## Terminology - -This proposal uses the cafe-code Terminology Lock v1 at `.selene/terminology_lock_v1.md`. "T3 Code" refers to the upstream lineage / current internal naming; "cafe-code" is the project repo; "Cafe Code" is the new product brand requested by the user. No new primitives are introduced. - -## Context Found (concrete reference points the plan must hit) - -Skimming the repository surfaced these high-leverage rename surfaces — the plan must classify each one explicitly: - -- **Product display strings.** `apps/desktop/src/app/DesktopEnvironment.ts:83` defines `APP_BASE_NAME = "T3 Code"`. `apps/web/src/branding.ts:18` mirrors it as `APP_BASE_NAME = "T3 Code"`. The branding flows into `displayName`, About dialog, dock/menu names, hosted-channel labels. Stage label `"Nightly"` is computed independently in `apps/desktop/src/updates/updateChannels.ts` and preserved per user request. -- **Desktop product/artifact metadata.** `apps/desktop/package.json:35` `"productName": "T3 Code (Alpha)"`. `scripts/build-desktop-artifact.ts` references `productName "T3 Code"`, `"T3 Code (Nightly)"`, `artifactName "T3-Code-${version}-${arch}.${ext}"`, `appId "com.t3tools.t3code"`, Linux `executableName "t3code"` and `StartupWMClass "t3code"`, and a flotilla of `T3CODE_*` env vars and a `t3codeCommitHash` package field. -- **Workspace / package identity.** Root `package.json` is `@t3tools/monorepo` with workspaces `@t3tools/web`, `@t3tools/desktop`, `@t3tools/contracts`, `@t3tools/shared`, `@t3tools/client-runtime`, `@t3tools/marketing`, `@t3tools/ssh`, `@t3tools/tailscale`. Server package is the unscoped npm name `t3` with bin `t3`. The internal oxlint plugin is `oxlint-plugin-t3code`. -- **User-machine persistence / endpoints.** Electron `appUserModelId = "com.t3tools.t3code"`, `linuxDesktopEntryName = "t3code.desktop"`, `linuxWmClass = "t3code"`, `userDataDirName = "t3code"` with legacy `"T3 Code (Alpha)"` (already mid-migration), `t3Home` default `~/.t3`, SSH-launch state at `~/.t3/ssh-launch`. Hosted web URL `https://app.t3.codes`, install registries `T3Tools.T3Code` (winget), `t3-code` (brew), `t3code-bin` (AUR). -- **Update / release metadata.** `electron-updater` `app-update.yml`, GitHub publish `pingdotgg/t3code`, `release.yml` workflow, Discord release script `scripts/notify-discord-release.ts`, Nightly version pattern `-nightly.YYYYMMDD.N`. The Nightly channel identity must survive. -- **Internal identifiers.** `Context.Service` IDs like `"t3/desktop/AppIdentity"`, `"t3/desktop/Environment"`, schema field `t3codeCommitHash`, `devRemoteT3ServerEntryPath`, telemetry span names `desktop.appIdentity.*` (no `t3` prefix, safe), Tailscale-Serve flags `--tailscale-serve` (no rename), the `t3 serve` / `t3 auth` / `t3 project` CLI surface in `REMOTE.md`. -- **Provenance.** ADR-0001, `AGENTS.md`, README and docs all use "T3 Code" as the application name. Upstream OpenAI Codex references and the Codex-Monitor reference repo are unrelated (do not rename). - -## Candidate Directions - -### Direction A: Surface-only rename (display strings, marketing, docs) - -- **Goal:** Flip only the visible application name to "Cafe Code" everywhere the user reads it. Leave every package name, binary, bundle ID, file path, env var, and identifier as-is, with a one-paragraph note in `AGENTS.md` explaining the brand/identifier split. -- **Why useful:** Smallest possible blast radius. Zero migration risk for users who already have local data, paired environments, or installed updates. Lands in one or two small PRs and is easy to verify with `bun fmt`, `bun lint`, `bun typecheck` plus a screen pass through the marketing site, About dialog, and onboarding text. -- **What it would falsify:** That the project benefits from changing only its visible brand without follow-up work — i.e., that contributors and operators are comfortable with a permanent name mismatch between the product (Cafe Code) and the codebase (`t3`, `@t3tools/*`, `~/.t3`, `com.t3tools.t3code`). -- **Estimated burst size:** small (1 PR). -- **Tradeoffs:** Cheap and safe today, but creates lasting cognitive overhead. New contributors will be confused that grep for "Cafe" finds nothing in the source tree; bug reports will mention "Cafe Code" while logs, paths, and env vars say `t3code`. The split-brain compounds over time and makes a later full rename strictly harder. - -### Direction B: Branding-boundary plan with tiered rename classification (recommended) - -- **Goal:** Produce a single planning artifact (one markdown file checked into `.selene/bursts/001/design/`) that classifies every concrete rename target into one of the four predeclared classes (user-facing rename now / internal rename with migration / compatibility alias / provenance reference), then execute the rename as a sequence of small, independently-revertible commits matching that classification — preserving the Nightly channel identity end-to-end. -- **Why useful:** Honors the design seed exactly. Forces an up-front list of _every_ surface (display strings, package names, bundle ID, npm bin, Linux executable, user-data dirs, env vars, hosted URL, GitHub repo, update channel, Discord/release scripts, ADRs) and a written rationale for each. The four classes map naturally to the constraints in the seed: surfaces flip immediately; persisted identifiers get migrations like the existing `userDataDirName` "T3 Code (Alpha)" → "t3code" migration that's already in the code; protocol/network/install-registry names get compatibility aliases until external systems (Homebrew tap, winget, AUR, GitHub repo URL) are renamed; ADRs, upstream lineage, license attribution, and oxlint plugin internals keep "T3 Code" as provenance. Each commit can be gated on `bun fmt && bun lint && bun typecheck` and reviewed independently. -- **What it would falsify:** That a tiered/staged rename is necessary and feasible here. If the tier list collapses to "everything's user-facing" or "nothing has migration risk," the project gets a cleaner one-shot plan; if any tier turns out to be unimplementable (e.g., `electron-updater` cannot resolve updates after appId change without breaking installed users), the classification surfaces that risk _before_ code changes begin. -- **Estimated burst size:** medium for the _plan artifact_ alone (this burst); the implementation it describes is large and will span multiple later bursts. The plan deliberately separates "what we're committing to call things" from "the implementation PR sequence." -- **Tradeoffs:** Higher up-front planning cost than just flipping strings. The plan must be specific enough that an implementer does not rediscover the codebase, which means it has to enumerate file classes and migration rules concretely. It also defers some questions (e.g., whether to keep `~/.t3` as a compatibility alias forever, whether the GitHub repo gets renamed, whether the hosted `app.t3.codes` URL persists) — those become explicit open questions rather than surprises mid-implementation. - -### Direction C: Full mechanical rename in a single burst - -- **Goal:** Treat the rename as a global string substitution: rename `@t3tools/*` → `@cafe/*` (or similar), `t3code` → `cafecode`, `com.t3tools.t3code` → `cafe` equivalent, `~/.t3` → `~/.cafe`, hosted URL, GitHub repo, npm package, executable, env vars, Context.Service identifiers, brand assets — all in one coordinated sweep — then iterate to green on `bun fmt`, `bun lint`, `bun typecheck`. -- **Why useful:** Eliminates the brand/identifier split entirely. Single mental model going forward. Lower long-term maintenance. -- **What it would falsify:** That the project tolerates simultaneous changes to multiple user-machine contracts (Electron `appUserModelId`, Linux desktop entry, user-data directory, update-channel feed URL, hosted pairing URL, npm bin name) without breaking installed users, paired remote environments, or the Nightly update path. If installed-base safety can be demonstrated for all of these at once, this direction is viable. -- **Estimated burst size:** large. -- **Tradeoffs:** High blast radius. `appUserModelId` changes orphan Windows installs in Start menu / taskbar pinning. `userDataDirName` changes orphan local SQLite/settings unless migrated (and the code shows a recent migration already exists, suggesting prior pain). The npm bin name `t3` is documented (`npx t3`, `t3 serve`, `t3 auth`) and used in `REMOTE.md` for SSH-launch — renaming it without alias breaks existing pairing setups and remote shell launch scripts. The GitHub repo URL is baked into release assets; renaming it requires GitHub redirects to keep `electron-updater` working. Doing all of these atomically multiplies the risk of one bad surface masking another in `bun typecheck`-clean code that nonetheless breaks installed apps. - -## Recommended Direction - -**Direction B — Branding-boundary plan with tiered rename classification.** The design seed explicitly asks for this classification (`user-facing rename now / internal rename with migration / compatibility alias / provenance reference`) and explicitly forbids a single global string replacement, which rules out C; A leaves too many later bursts dependent on rediscovering this surface inventory. The plan artifact this burst produces is a concrete table mapping every surface to a class, a rationale, and an implementation note (e.g., "userDataDirName: internal rename with migration, reuse the existing legacy-dir fallback pattern in `DesktopAppIdentity.ts:83-94`"). The Nightly channel is preserved by treating the version pattern, update feed identity, and `electron-updater` channel as `compatibility alias / leave stable` on this burst. Implementation lands in later bursts, one tier at a time, each verified by `bun fmt && bun lint && bun typecheck` and (for any change touching persisted state or the desktop binary) a manual smoke pass on the desktop app. - -## Concerns / Open Questions - -- **GitHub repo rename.** `pingdotgg/t3code` is referenced in `apps/server/package.json` `repository.url`, in release workflows, and in `electron-updater` publish config. Is the GitHub repo being renamed? If yes, the rename has to be choreographed with GitHub's redirect behavior so `electron-updater` keeps resolving updates. If no, the repo URL is a provenance reference and stays. -- **Hosted pairing URL.** `https://app.t3.codes` is hard-coded into pairing links and into hosted-pairing logic in `apps/web/src/hostedPairing.ts`. Is a `cafe.codes` (or similar) DNS being acquired? If not, this URL is a compatibility alias indefinitely. -- **npm bin name `t3` and CLI commands `t3 serve / t3 auth / t3 project`.** These are advertised in `REMOTE.md` and used in scripted SSH launches. Even renaming to a longer name, we likely need to keep `t3` as a compatibility alias for at least one release cycle. Confirm scope. -- **Install-registry aliases.** Winget `T3Tools.T3Code`, brew cask `t3-code`, AUR `t3code-bin` are owned externally. The plan should explicitly note these are out of scope for the code change and require separate registry submissions. -- **Workspace scope `@t3tools/*`.** Renaming workspace package names is mechanical inside the monorepo (no public consumers because `private: true`), but the `t3` published-to-npm package and the `effect-acp` / `effect-codex-app-server` non-scoped packages have their own publishing identities — confirm whether those rename too. -- **`t3codeCommitHash` package field and `T3CODE_*` env vars.** Internal to the build but baked into installed builds' `package.json`. Renaming changes the field read by `DesktopAppIdentity.ts:17`, so the rename must update reader and writer in the same commit (no cross-version compat needed because the field is regenerated on every build). -- **Persisted identifiers already mid-migration.** The code at `DesktopEnvironment.ts:163-164` already supports a legacy `userDataDirName` of `"T3 Code (Alpha)"` falling back to `"t3code"`. The same pattern is the obvious template for migrating `"t3code"` → `"cafecode"` (or whatever the new name is) and should be reused rather than reinvented. -- **Oxlint plugin name `oxlint-plugin-t3code`.** Internal lint tooling; renaming is cosmetic but touches the workspace list. Classify as internal rename with migration or provenance reference, depending on whether the team wants the tooling to share the new brand. -- **ADRs.** ADR-0001 already uses "T3 Code"; per cafe-code's append-only ADR rule, the rename can be documented in a new ADR rather than rewriting ADR-0001. Confirm this is the intended pattern. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, deadlock-free, data-loss-free, or compatible with any upstream provider API outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted unless it is grounded in source review and the repository's required quality gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not make Claude, Codex, Cursor, OpenCode, or any other provider semantically correct. -- This artifact does not claim coverage of credentials, tokens, local files, persisted conversations, WebSocket sessions, or provider subprocess behavior beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/001/design/claude_reconciliation.md b/.selene/bursts/001/design/claude_reconciliation.md deleted file mode 100644 index 99cb5a26..00000000 --- a/.selene/bursts/001/design/claude_reconciliation.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -verdict: agree -preferred_focus: source-derived-classification-matrix -synthesis_or_argument: Both proposals independently converge on the same direction — produce a tiered classification of every rename surface before any implementation, using the four predeclared classes (user-facing rename now / internal rename with migration / compatibility alias / provenance reference). Adopt codex's clean framing for the deliverable shape (a classification matrix) and claude's source-grounded inventory (specific file paths, identifiers, persisted-state surfaces, install registries, and the existing `userDataDirName` migration pattern already in `apps/desktop/src/app/DesktopEnvironment.ts`) as the starting evidence the matrix must cover. Preserve the Nightly channel identity end-to-end. Defer all code changes to later bursts; this burst delivers only the planning artifact and an explicit open-questions list. ---- - -# Burst 001 Reconciliation — claude - -## Selected Direction - -Source-grounded rename classification matrix — produce a single planning artifact that classifies every concrete T3 Code rename surface into one of the four predeclared classes, with rationale and a per-surface implementation note, before any code change. - -## Why this direction - -Both proposals arrived at the same recommendation, which is the direction the Burst 001 seed explicitly asks for. The classification map is what makes later renaming PRs safe, reviewable, and revertible; without it, a global string substitution would silently break installed users, paired remote environments, and the Nightly update path. Synthesizing claude's source-grounded inventory with codex's clean four-class framing gives the matrix concrete starting material (display strings, package identity, bundle ID, persisted dirs, env vars, hosted URL, install registries, ADR provenance) while honoring the seed's deferral of implementation. - -## Concrete Burst 001 Plan - -- Goal: Deliver a single markdown classification matrix at `.selene/bursts/001/design/rename-classification.md` that lists every `T3 Code` / `t3code` / `@t3tools/*` / `~/.t3` / `com.t3tools.t3code` surface in the repository, classifies each under one of the four predeclared classes, gives a one-line rationale, and names any migration/alias technique to reuse (e.g., the existing legacy `userDataDirName` fallback pattern in `apps/desktop/src/app/DesktopEnvironment.ts`). -- Branches / sub-tasks: - 1. Display-name surfaces: `apps/desktop/src/app/DesktopEnvironment.ts` `APP_BASE_NAME`, `apps/web/src/branding.ts` `APP_BASE_NAME`, About dialog, dock/menu labels, marketing site copy, README, AGENTS.md. - 2. Desktop product/artifact metadata: `apps/desktop/package.json` `productName`, `scripts/build-desktop-artifact.ts` (productName, artifactName, appId, executableName, StartupWMClass, `T3CODE_*` env vars, `t3codeCommitHash`). - 3. Workspace identity: root `package.json` `@t3tools/monorepo` and the eight `@t3tools/*` workspaces; the unscoped `t3` server package with bin `t3`; `oxlint-plugin-t3code`. - 4. Persisted/identity surfaces: Electron `appUserModelId`, `linuxDesktopEntryName`, `linuxWmClass`, `userDataDirName` (already mid-migration), `~/.t3` home, SSH-launch state path, hosted URL `app.t3.codes`, install registries (winget `T3Tools.T3Code`, brew `t3-code`, AUR `t3code-bin`). - 5. Update/release surfaces: `app-update.yml`, GitHub publish `pingdotgg/t3code`, `scripts/notify-discord-release.ts`, Nightly version pattern `-nightly.YYYYMMDD.N`. Preserve Nightly identity end-to-end. - 6. Internal IDs / provenance: `Context.Service` IDs like `"t3/desktop/AppIdentity"`, telemetry span names (already `desktop.appIdentity.*`, safe), ADR-0001, license attribution, upstream lineage references. - 7. For each item, assign exactly one class per `.selene/classifications.py` with a one-line rationale; flag anything ambiguous as an explicit open question rather than guessing. -- Predeclared classifications: cite `.selene/classifications.py` — only the four classes (`user_facing_rename_now`, `internal_rename_with_migration`, `compatibility_alias_leave_stable`, `provenance_reference_leave_as_t3_code`) admitted by the executable strict-close rules may appear in the matrix. -- Falsifying criteria: - - Matrix is falsified if any surface enumerated in branches 1–6 is missing from the final document. - - Matrix is falsified if any row carries a class not admitted by `.selene/classifications.py`. - - Matrix is falsified if any row in `internal_rename_with_migration` lacks a concrete migration technique reference (legacy-dir fallback pattern, `electron-updater` redirect strategy, GitHub repo rename redirect, etc.). - - Matrix is falsified if any surface affecting Nightly channel identity is classified as anything other than `compatibility_alias_leave_stable` for this burst. - - Matrix is falsified if it proposes any code change to land in Burst 001. -- Required deliverables: - - `.selene/bursts/001/design/rename-classification.md` containing the matrix and an open-questions list (GitHub repo rename choreography vs. `electron-updater`, hosted pairing URL future, npm bin `t3` alias scope, install-registry submissions, oxlint plugin scope, ADR append-only handling). - - No code changes, no package renames, no file moves. Verification gates `bun fmt`, `bun lint`, `bun typecheck` are not exercised this burst because no source is touched; they become gating once Burst 002+ begins implementation on a single tier. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, deadlock-free, data-loss-free, or compatible with any upstream provider API outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted unless it is grounded in source review and the repository's required quality gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not make Claude, Codex, Cursor, OpenCode, or any other provider semantically correct. -- This artifact does not claim coverage of credentials, tokens, local files, persisted conversations, WebSocket sessions, or provider subprocess behavior beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/001/design/codex_proposal.md b/.selene/bursts/001/design/codex_proposal.md deleted file mode 100644 index 4c21c850..00000000 --- a/.selene/bursts/001/design/codex_proposal.md +++ /dev/null @@ -1,59 +0,0 @@ -# Burst 001 Design Proposal — codex - -## Terminology - -This proposal uses the canonical vocabulary in `.selene/terminology_lock_v1.md`. In particular, `cafe-code` names this fork, `T3 Code` is reserved for upstream lineage or compatibility/provenance references, and `provider`, `provider session`, `Codex app-server`, `WebSocket protocol`, `orchestration domain event`, `projection`, `environment`, `contracts package`, `shared package`, `desktop app`, and `security-sensitive data` retain their locked meanings. - -## Candidate Directions - -### Direction A: Source-Grounded Rename Classification Matrix - -- Goal: Build a repository-wide rename inventory that classifies every `T3 Code` / `t3-code` / related identity surface before implementation. -- Why useful: This directly satisfies the Burst 001 seed by turning the rename into an auditable map instead of a risky global string replacement, with each target classified as user-facing rename now, internal rename with migration, compatibility alias / leave stable, or provenance reference / leave as T3 Code. -- What it would falsify: It would falsify the assumption that the rename is mostly visible copy by exposing any storage keys, package names, bundle identifiers, protocol constants, release metadata, updater channels, desktop paths, or persisted data fields where renaming could break existing installs or sessions. -- Estimated burst size: medium -- Tradeoffs: It delays visible product changes, and it requires careful source review across `apps/server`, `apps/web`, `apps/desktop`, `packages/contracts`, `packages/shared`, docs, scripts, and release metadata. The upside is that it produces the safest implementation plan and a defensible migration boundary. - -### Direction B: User-Facing Brand Rename First - -- Goal: Prioritize visible product surfaces so the browser UI, desktop UI, window titles, onboarding text, settings, empty states, toasts, screenshots, and docs say `Cafe Code` while leaving internal identifiers stable. -- Why useful: This would create fast user-visible progress and avoid touching high-risk compatibility surfaces during the first implementation pass. -- What it would falsify: It would test whether most product identity is isolated to presentation code and documentation, and whether the Nightly identity can remain intact without conflicting with the new brand. -- Estimated burst size: small -- Tradeoffs: This is lower risk than broad internal renaming, but it may leave confusing mixed terminology in code, package metadata, artifact names, or release scripts. It also does not by itself answer which internal `T3 Code` references are provenance, compatibility, or migration candidates. - -### Direction C: Compatibility-First Identity Migration Plan - -- Goal: Focus the burst on high-risk identity surfaces such as storage keys, database fields, WebSocket protocol constants, desktop bundle identifiers, config directories, package names, updater metadata, and release artifacts. -- Why useful: These are the places most likely to affect saved sessions, local data, desktop installs, provider sessions, and update behavior, so planning them first reduces the chance of silent compatibility breaks. -- What it would falsify: It would falsify any claim that internal renames are safe without explicit migration or aliases, especially around persisted client storage, SQLite data, auth/session state, package import paths, and release/update channels. -- Estimated burst size: large -- Tradeoffs: This direction gives the strongest migration discipline but is too narrow on user-facing brand completion and may over-invest before the full inventory shows which surfaces actually exist. - -## Recommended Direction - -I recommend Direction A: Source-Grounded Rename Classification Matrix. Burst 001’s required-reading seed asks for a comprehensive rename plan, not implementation, and Direction A is the best fit because it creates the classification map needed to execute later bursts safely. It should explicitly preserve `Nightly` channel/version identity where appropriate, leave upstream/provenance `T3 Code` references intact, and identify every migration-sensitive surface before any code changes. The resulting plan should include the required verification gates: `bun fmt`, `bun lint`, and `bun typecheck`, with `bun run test` only if the touched implementation surface later needs tests. - -## Concerns / Open Questions - -- Which desktop identity fields are safe to rename immediately versus requiring a compatibility period for existing installs? -- Are any package names, import paths, storage keys, or protocol constants consumed outside this repository? -- Should `Cafe Code Nightly` be the canonical visible channel string, or should `Nightly` remain visually separate from the product name? -- Which references to `T3 Code` are upstream provenance and should remain permanently unchanged? - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/001/design/codex_reconciliation.md b/.selene/bursts/001/design/codex_reconciliation.md deleted file mode 100644 index 276cd16d..00000000 --- a/.selene/bursts/001/design/codex_reconciliation.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -verdict: agree -preferred_focus: source-grounded-tiered-rename-classification -synthesis_or_argument: Adopt a synthesis of codex Direction A and claude Direction B: Burst 001 should produce a source-grounded, tiered rename classification artifact, not perform the rename. Claude's proposal contributes the strongest concrete surface inventory, while codex's proposal frames the right safety boundary: every T3 Code/t3-code/t3code identity surface must be classified before implementation so user-facing brand changes, migration-sensitive identifiers, compatibility aliases, and provenance references are not collapsed into a risky global substitution. ---- - -# Burst 001 Reconciliation — codex - -## Selected Direction - -Source-grounded tiered rename classification — produce an auditable repository-wide plan for renaming T3 Code identity surfaces to Cafe Code without implementing the rename in this burst. - -## Why this direction - -Both proposals converge on the same core direction: a global rename is unsafe without first classifying source-derived surfaces. Claude's proposal adds useful concrete targets across desktop metadata, package identity, persistence, update/release metadata, hosted URLs, CLI names, and provenance docs. This burst should turn that inventory into a strict planning artifact whose classifications are reviewable and falsifiable before any user-facing or compatibility-sensitive changes land. - -## Concrete Burst 001 Plan - -- Goal: Create a single Burst 001 design artifact that inventories every discovered `T3 Code`, `t3-code`, `t3code`, `t3`, `@t3tools/*`, and related identity surface, then assigns each one a rename classification and rationale. -- Branches / sub-tasks: source inventory across `apps/desktop`, `apps/web`, `apps/server`, `packages/*`, root/package metadata, scripts, release workflows, docs, ADRs, hosted URLs, install/update metadata, env vars, persisted paths, protocol/session surfaces, and branding copy; classification table; migration notes; open external-dependency questions. -- Predeclared classifications: cite `.selene/classifications.py` and use only the admitted strict-close classes, including user-facing rename now, internal rename with migration, compatibility alias / leave stable, and provenance reference / leave as T3 Code. -- Falsifying criteria: the direction is falsified if source review shows the rename is either entirely presentation-only with no migration-sensitive surfaces, or if required classifications cannot be represented by `.selene/classifications.py`; individual classifications are falsified by source evidence that a surface is persisted, externally consumed, release-channel-sensitive, or provenance-only contrary to its assigned class. -- Required deliverables: one checked-in markdown design artifact under the Burst 001 Selene design area; a table of concrete file/surface references with classification, rationale, and implementation note; explicit Nightly preservation guidance; explicit non-goals for this burst; verification statement that no rename implementation was performed; required future gates of `bun fmt`, `bun lint`, and `bun typecheck` for implementation bursts. -- Hard non-claims: append the canonical block. - -## Disagreement (if verdict is "disagree") - -N/A. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/001/design/design.json b/.selene/bursts/001/design/design.json deleted file mode 100644 index 663aafcd..00000000 --- a/.selene/bursts/001/design/design.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "burst": 1, - "claude_proposal": ".selene/bursts/001/design/claude_proposal.md", - "claude_reconciliation": { - "path": ".selene/bursts/001/design/claude_reconciliation.md", - "preferred_focus": "source-derived-classification-matrix", - "synthesis_or_argument": "Both proposals independently converge on the same direction \u2014 produce a tiered classification of every rename surface before any implementation, using the four predeclared classes (user-facing rename now / internal rename with migration / compatibility alias / provenance reference). Adopt codex's clean framing for the deliverable shape (a classification matrix) and claude's source-grounded inventory (specific file paths, identifiers, persisted-state surfaces, install registries, and the existing `userDataDirName` migration pattern already in `apps/desktop/src/app/DesktopEnvironment.ts`) as the starting evidence the matrix must cover. Preserve the Nightly channel identity end-to-end. Defer all code changes to later bursts; this burst delivers only the planning artifact and an explicit open-questions list.", - "verdict": "agree" - }, - "codex_proposal": ".selene/bursts/001/design/codex_proposal.md", - "codex_reconciliation": { - "path": ".selene/bursts/001/design/codex_reconciliation.md", - "preferred_focus": "source-grounded-tiered-rename-classification", - "synthesis_or_argument": "Adopt a synthesis of codex Direction A and claude Direction B: Burst 001 should produce a source-grounded, tiered rename classification artifact, not perform the rename. Claude's proposal contributes the strongest concrete surface inventory, while codex's proposal frames the right safety boundary: every T3 Code/t3-code/t3code identity surface must be classified before implementation so user-facing brand changes, migration-sensitive identifiers, compatibility aliases, and provenance references are not collapsed into a risky global substitution.", - "verdict": "agree" - }, - "computed_at": "2026-05-21T00:12:50.163491+00:00", - "status": "disagreed" -} diff --git a/.selene/bursts/001/design/rename-classification.md b/.selene/bursts/001/design/rename-classification.md deleted file mode 100644 index 3ccff93e..00000000 --- a/.selene/bursts/001/design/rename-classification.md +++ /dev/null @@ -1,137 +0,0 @@ -# Burst 001 Rename Classification Matrix - -## Terminology - -This artifact follows `.selene/terminology_lock_v1.md`. - -- `Cafe Code` is the new user-facing product brand for this fork. -- `T3 Code` is the inherited/upstream product name and remains only for - compatibility, migration, release continuity, or historical provenance. -- `Nightly` remains a channel/stage label. Visible rename work must preserve - the channel identity and version/tag semantics end-to-end. - -## Classification Module Citation - -Classifications in this matrix are constrained by the Burst 001 agreed plan and -cited against `.selene/classifications.py`, the project-local executable -strict-close classification module. This artifact uses only these four admitted -rename classes: - -- `user_facing_rename_now` -- `internal_rename_with_migration` -- `compatibility_alias_leave_stable` -- `provenance_reference_leave_as_t3_code` - -## Source Review Scope - -Reviewed source surfaces came from `rg` scans for `T3 Code`, `t3code`, -`@t3tools`, `~/.t3`, `com.t3tools.t3code`, `T3CODE_*`, `t3.codes`, install -registry identifiers, hosted channel identifiers, and `refs/t3` across source, -docs, scripts, release workflow files, and Selene metadata. No ADR or invariant -file was edited. - -## Classification Matrix - -| ID | Surface and evidence | Class | Rationale | Later implementation note | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 1 | Web brand fallback: `apps/web/src/branding.ts` `APP_BASE_NAME = "T3 Code"` and derived `APP_DISPLAY_NAME`. | `user_facing_rename_now` | Fallback display copy is user-visible and not a persisted contract. | Rename base to `Cafe Code`; keep `Nightly`, `Latest`, `Dev`, and `Alpha` stage labels unchanged. | -| 2 | Desktop brand base: `apps/desktop/src/app/DesktopEnvironment.ts` `APP_BASE_NAME = "T3 Code"`. | `user_facing_rename_now` | It drives visible desktop display name, About panel, dock/menu name, and web injected branding. | Rename base to `Cafe Code`; preserve `resolveDesktopAppStageLabel` and `isNightlyDesktopVersion`. | -| 3 | Desktop runtime display and About panel: `DesktopAppIdentity.configure()` uses `environment.displayName`. | `user_facing_rename_now` | The name is user-visible application chrome. | Covered by the desktop brand base rename; smoke About panel and OS app name. | -| 4 | Desktop dev launcher display: `apps/desktop/scripts/electron-launcher.mjs` `T3 Code (Dev)` and `T3 Code (Alpha)`. | `user_facing_rename_now` | Dev app display names are human-facing and not external contracts. | Rename to `Cafe Code (Dev)` and `Cafe Code (Alpha)`; keep bundle ID stable. | -| 5 | Web boot shell and splash: `apps/web/index.html`, `apps/web/src/components/SplashScreen.tsx`. | `user_facing_rename_now` | Title, aria label, and image alt text are presentation copy. | Rename visible/accessibility text to Cafe Code. | -| 6 | Web user-facing status/copy: settings, update prompts, provider-disabled fallback messages, SSH password prompt, `T3 Server` fallback label. | `user_facing_rename_now` | These strings are visible to users and are not protocol IDs. | Rename to Cafe Code / Cafe Code Server while leaving machine keys untouched. | -| 7 | Server and CLI visible messages: `apps/server/src/bin.ts`, `startupAccess.ts`, provider disabled messages, maintenance runner messages. | `user_facing_rename_now` | These are human-facing logs/errors/help text. | Rename copy to Cafe Code; do not rename env vars, command names, or service IDs in the same burst. | -| 8 | Git author display names: `apps/server/src/vcs/GitVcsDriver.ts` `GIT_AUTHOR_NAME` and `GIT_COMMITTER_NAME` as `T3 Code`. | `user_facing_rename_now` | Commit author display name is visible metadata. | Rename names to Cafe Code only if keeping author email stable. | -| 9 | Git author emails: `t3code@users.noreply.github.com`. | `compatibility_alias_leave_stable` | Email identity may be consumed by tooling and history filters. | Keep stable until a release/operator decision creates and validates a new noreply identity. | -| 10 | Marketing site copy: `apps/marketing/src/pages/index.astro`, `download.astro`, `layouts/Layout.astro`. | `user_facing_rename_now` | Headings, CTAs, descriptions, alt text, and nav brand are public presentation copy. | Rename prose to Cafe Code while preserving active GitHub links unless the repo moves. | -| 11 | Marketing testimonial quotes: `apps/marketing/src/lib/tweets.ts`. | `provenance_reference_leave_as_t3_code` | Quoted historical user text names the upstream-era product. | Leave quote text unchanged unless replacing the testimonial corpus. | -| 12 | README and general docs product prose: `README.md`, `REMOTE.md`, `KEYBINDINGS.md`, `docs/*.md`, provider docs. | `user_facing_rename_now` | Product prose is user-facing documentation. | Rename prose to Cafe Code and add compatibility notes for old commands, env vars, and paths. | -| 13 | README install registry commands: `winget install T3Tools.T3Code`, `brew install --cask t3-code`, `yay -S t3code-bin`. | `compatibility_alias_leave_stable` | Registry IDs are external distribution contracts. | Keep commands stable until registry aliases/renames are submitted and verified. | -| 14 | AGENTS project snapshot and Codex-first prose. | `user_facing_rename_now` | Repository guidance should name the current project. | Rename to Cafe Code and mention the T3 Code lineage where relevant. | -| 15 | Root package identity: root `package.json` `@t3tools/monorepo`. | `compatibility_alias_leave_stable` | Workspace identity affects tooling, filters, and lockfile references. | Leave stable during brand rename. | -| 16 | Scoped workspace packages: `@t3tools/desktop`, `web`, `marketing`, `client-runtime`, `contracts`, `shared`, `ssh`, `tailscale`, `scripts`, `oxlint-plugin-t3code`. | `compatibility_alias_leave_stable` | Package names and imports are broad internal contracts. | Rename only in a later namespace cleanup with lockfile regeneration and import migration. | -| 17 | Server package and CLI: `apps/server/package.json` name `t3`, bin `t3`, docs using `npx t3`, `t3 serve`, `t3 auth`. | `compatibility_alias_leave_stable` | CLI name is an external user and remote-launch contract. | Keep `t3`; if desired, add a Cafe CLI alias later without removing `t3`. | -| 18 | Internal oxlint plugin: directory `oxlint-plugin-t3code`, package `@t3tools/oxlint-plugin-t3code`, plugin rule prefix `t3code/*`. | `compatibility_alias_leave_stable` | Tooling config and tests depend on the plugin package and rule namespace. | Rename only with a workspace/tooling migration that preserves old rule names or documents the break. | -| 19 | Desktop package product name: `apps/desktop/package.json` `productName: "T3 Code (Alpha)"`. | `user_facing_rename_now` | Product name is visible desktop packaging metadata. | Rename to `Cafe Code (Alpha)` only while preserving app ID and user data directory. | -| 20 | Desktop build product display: `scripts/build-desktop-artifact.ts` `T3 Code` / `T3 Code (Nightly)`. | `compatibility_alias_leave_stable` | Nightly release/build identity is tied to updater channel semantics in this burst. | Defer until a desktop packaging burst; preserve `nightly` channel, suffix, dist-tag, and update manifest behavior. | -| 21 | Desktop artifact filename: `T3-Code-${version}-${arch}.${ext}`. | `internal_rename_with_migration` | Artifact filenames are referenced by updater manifests and release assets. | Migrate with updater-manifest validation, old asset retention or redirect, and release smoke tests. | -| 22 | Staged desktop package name: generated `package.json` `name: "t3code"`. | `compatibility_alias_leave_stable` | Electron-builder/updater behavior may depend on the staged package identity. | Keep stable unless a packaging migration proves no updater impact. | -| 23 | Staged desktop package description/author: `"T3 Code desktop build"`, `"T3 Tools"`. | `user_facing_rename_now` | Description/author are human-readable artifact metadata. | Rename description to Cafe Code where safe; author/legal owner remains an operator/legal question. | -| 24 | Commit metadata field: `t3codeCommitHash` in build script and `DesktopAppIdentity.ts`. | `compatibility_alias_leave_stable` | Writer/reader schema coupling creates upgrade risk for no user-visible benefit. | Keep field stable, or add dual-read/dual-write before any rename. | -| 25 | Electron app IDs: `com.t3tools.t3code`, `com.t3tools.t3code.dev`. | `compatibility_alias_leave_stable` | App IDs bind OS identity, updater behavior, and installed app continuity. | Keep stable for visible rename. | -| 26 | Linux desktop identity: `t3code.desktop`, `t3code-dev.desktop`, `t3code`, `t3code-dev`, `StartupWMClass`. | `compatibility_alias_leave_stable` | Desktop entry and WM class are OS integration contracts. | Rename only with Linux package/desktop-entry migration and compatibility testing. | -| 27 | User data directory: `userDataDirName = "t3code"`, dev `t3code-dev`, legacy `T3 Code (Alpha)` / `T3 Code (Dev)`. | `compatibility_alias_leave_stable` | Renaming can orphan existing desktop state, tokens, and settings. | Keep stable. If later moving to `cafecode`, reuse the existing legacy fallback pattern in `DesktopAppIdentity.resolveUserDataPath` and include `t3code` as legacy. | -| 28 | Base home directory and server data: default `~/.t3`, `T3CODE_HOME`, logs/docs/keybindings paths. | `compatibility_alias_leave_stable` | Server state and docs rely on this persisted path and env var. | Add `CAFE_CODE_HOME` only as a tested alias with explicit precedence; keep `T3CODE_HOME`. | -| 29 | SSH launch state and package specs: `~/.t3/ssh-launch`, `t3 serve`, `t3@latest`, `t3@nightly`. | `compatibility_alias_leave_stable` | Remote scripts and saved launch state depend on these names. | Keep stable; any Cafe alias must preserve old specs and state paths. | -| 30 | SSH askpass internals: `t3code-ssh-askpass`, temp prefixes, `DISPLAY=t3code`, `T3_SSH_AUTH_SECRET`. | `compatibility_alias_leave_stable` | SSH helper names and env var are runtime contracts. | Add aliases only after proving no interaction with OpenSSH askpass behavior regresses. | -| 31 | Browser persisted storage keys: `t3code:client-settings:v1`, `t3code:saved-environment-registry:v1`. | `internal_rename_with_migration` | Renaming without migration loses browser settings and saved remote environments. | Use dual-read old/new keys, write-through to new key after successful decode, and tests for old-key migration. | -| 32 | Hosted app domains: `app.t3.codes`, `latest.app.t3.codes`, `nightly.app.t3.codes`. | `compatibility_alias_leave_stable` | Domains are external routing and Nightly channel contracts. | Keep until Cafe domains exist; migrate with dual routing, cookies, TLS, and Nightly verification. | -| 33 | Hosted channel route/cookie: `/__t3code/channel`, `t3code_web_channel`, `apps/web/vercel.ts`. | `compatibility_alias_leave_stable` | Existing hosted channel selection depends on these route/cookie names. | Future rename requires dual route plus dual cookie read/write before old names are retired. | -| 34 | Hosted pairing URL default: `VITE_HOSTED_APP_URL`, default `https://app.t3.codes`, `/pair`. | `compatibility_alias_leave_stable` | Pairing links are shared externally and may be saved in clients. | Keep stable until Cafe domain routing is live and old pairing URLs redirect safely. | -| 35 | Well-known environment endpoint: `/.well-known/t3/environment` in web, desktop, server, tailscale. | `compatibility_alias_leave_stable` | Remote clients, SSH discovery, and hosted pairing fetch this protocol endpoint. | Add `/.well-known/cafe-code/environment` only as an alias; keep the T3 endpoint. | -| 36 | Release workflow stable release name: `.github/workflows/release.yml` `name=T3 Code v$version`. | `user_facing_rename_now` | Stable release display name is public copy, not the channel identity itself. | Rename to Cafe Code in a release-copy burst with release smoke updates. | -| 37 | Nightly release names/version/tag/dist-tag: `scripts/resolve-nightly-release.ts`, `vX.Y.Z-nightly.YYYYMMDD.N`, npm `nightly`. | `compatibility_alias_leave_stable` | Nightly identity must be preserved end-to-end in Burst 001. | Do not alter version pattern, tags, dist-tag, or updater channel; any visible copy rename must keep these invariants and update smoke tests. | -| 38 | Update manifests and electron-updater channels: `latest*.yml`, `nightly*.yml`, `app-update.yml`, GitHub publish config. | `compatibility_alias_leave_stable` | Updater continuity is an installed-user contract. | Keep channels stable; use an electron-updater redirect/manifest strategy before changing publisher or asset names. | -| 39 | Release/publish repository identity: `pingdotgg/t3code` in package metadata, README, marketing links, build publish config env fallback. | `compatibility_alias_leave_stable` | Active release URLs and package metadata depend on the current repository. | Leave until a repo move is decided; then rely on GitHub redirects and update release configs in a dedicated burst. | -| 40 | Historical upstream/test URLs: `t3dotgg/t3-code` in release notification tests and upstream references/comments. | `provenance_reference_leave_as_t3_code` | These identify historical upstream or fixture references. | Keep unless the fixture purpose changes. | -| 41 | Release notification copy: `scripts/notify-discord-release.ts` and tests. | `user_facing_rename_now` | Discord announcement descriptions are user-facing release copy. | Rename copy in a release-copy burst; keep Nightly version/tag semantics unchanged. | -| 42 | `T3CODE_*` runtime/config env vars across server, desktop, web workflow, observability, dev-runner, source-control docs. | `compatibility_alias_leave_stable` | Env vars are operational interfaces likely used by scripts and deployments. | Add `CAFE_CODE_*` aliases only with explicit precedence, tests, and old-name compatibility. | -| 43 | `T3CODE_PROJECT_ROOT` and `T3CODE_WORKTREE_PATH` project script env vars. | `compatibility_alias_leave_stable` | User scripts may consume these variables. | Keep stable; aliases require tests preserving script behavior. | -| 44 | Observability service names/defaults: `t3-server`, `t3-local`, `T3CODE_OTLP_SERVICE_NAME`, trace docs. | `compatibility_alias_leave_stable` | Renaming fragments dashboards and alert queries. | Keep initially; optional Cafe aliases must preserve old defaults or document a dashboard migration. | -| 45 | Effect service IDs: strings such as `t3/desktop/AppIdentity`, `t3/persistence/...`, `t3/vcs/...`. | `compatibility_alias_leave_stable` | IDs are internal diagnostic/dependency identity and log correlation surfaces. | Rename only in optional internal cleanup after snapshots/log expectations are updated. | -| 46 | Effect span names such as `desktop.appIdentity.*`. | `compatibility_alias_leave_stable` | Current names are already brand-neutral and diagnostics may depend on them. | Leave unchanged. | -| 47 | Git checkpoint refs: `refs/t3/checkpoints` in checkpointing code/tests. | `compatibility_alias_leave_stable` | Existing refs must remain readable for recovery/revert flows. | Future rename needs dual-read support for old refs and tests for both prefixes. | -| 48 | Temporary worktree branch prefix: `WORKTREE_BRANCH_PREFIX = "t3code"`. | `compatibility_alias_leave_stable` | Existing/generated branch names may be active in repositories. | Rename only with acceptance of mixed history or config-driven dual-prefix handling. | -| 49 | VCS project config path: `.t3code/vcs.json`. | `compatibility_alias_leave_stable` | Repository-local config discovery depends on the path. | If `.cafecode/vcs.json` is added, read both with deterministic precedence. | -| 50 | Keybindings project config examples: `.t3code-keybindings.json` and `~/.t3/keybindings.json`. | `compatibility_alias_leave_stable` | These are user-editable persisted config paths. | Add Cafe paths only with dual discovery and migration docs. | -| 51 | Provider integration machine IDs: Codex `name: "t3code_desktop"`, Cursor `clientInfo.name: "t3-code"` / `t3-code-provider-probe`. | `compatibility_alias_leave_stable` | Provider-side integrations may cache or authorize these client IDs. | Change only after provider compatibility is verified; visible `title` strings can rename separately. | -| 52 | Provider integration visible titles: `T3 Code Desktop`, OpenCode session title `T3 Code ${threadId}`. | `user_facing_rename_now` | Titles are user-visible presentation strings. | Rename title text while keeping machine `name` fields stable. | -| 53 | Temp/test prefixes: `t3code-*`, `t3-*`, sample paths containing `t3code`. | `compatibility_alias_leave_stable` | Low-value test/internal names can affect snapshots and fixture intent. | Leave until touching adjacent tests; use deterministic migration only when behavior depends on names. | -| 54 | Asset filenames: `assets/prod/t3-black-*`, `assets/dev/.../T3.svg`, brand asset map. | `internal_rename_with_migration` | Build scripts reference these assets by path and visual assets need replacement art. | Create Cafe assets, update `scripts/lib/brand-assets.ts`, keep or redirect old filenames until all build refs are updated. | -| 55 | App icon/favicons visual content. | `user_facing_rename_now` | Icons are visible brand surfaces. | Rename only when Cafe artwork is available; otherwise mark as follow-up and keep inherited assets temporarily. | -| 56 | License copyright: `LICENSE` `Copyright (c) 2026 T3 Tools Inc.`. | `provenance_reference_leave_as_t3_code` | Copyright/legal owner is not a product-brand string. | Do not change without legal/operator direction. | -| 57 | Existing ADRs and Selene methodology references: `.selene/adrs/0001-*`, `.selene/terminology_lock_v1.md`. | `provenance_reference_leave_as_t3_code` | ADRs are append-only records and terminology lock identifies upstream lineage. | Do not edit existing ADRs or invariant files; create a new ADR only if a future approved plan requires it. | -| 58 | Generated lockfile/package-manager state: `bun.lock` references package names and workspaces. | `compatibility_alias_leave_stable` | Lockfile is generated from package-name policy and should not be edited directly. | Let package-manager updates regenerate it only in a package rename burst. | - -## Open Questions - -1. Should the CLI remain permanently `t3`, or should Cafe Code add a new CLI - alias while retaining `t3`? -2. Are Cafe Code icon/favicons available for the first visible rename burst, or - should inherited T3 assets remain until art exists? -3. Will the active release repository remain `pingdotgg/t3code`, or is a GitHub - repository rename planned? -4. Is there a target Cafe Code hosted domain for router, latest, and Nightly - channels? -5. Should `CAFE_CODE_*` env vars be introduced as aliases, and what precedence - should they have over `T3CODE_*`? -6. Should the `@t3tools/*` workspace scope ever be renamed, or is it a stable - internal lineage namespace? -7. Who owns any legal/author metadata change from `T3 Tools` to a Cafe Code - entity? -8. What is the exact electron-updater choreography if artifact filenames, - publish repository, or package product metadata are renamed? - -## Burst 001 Non-Implementation Boundary - -Burst 001 lands planning artifacts only. It does not rename packages, move -files, change release channels, alter persisted paths, or modify source -behavior. Later implementation bursts must choose one tier, run repository gates, -and preserve compatibility for persisted state and external contracts. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/001/design_seed.md b/.selene/bursts/001/design_seed.md deleted file mode 100644 index b451b4b0..00000000 --- a/.selene/bursts/001/design_seed.md +++ /dev/null @@ -1,67 +0,0 @@ -# Burst 001 Design Seed — Cafe Code Rename - -## User Request - -The product should be renamed from "T3 Code" to "Cafe Code". The user wants the -Nightly version retained, but the application brand should become Cafe Code. -Existing stylization where "Cafe" is visually emphasized can remain. - -## Scope To Plan - -Produce a comprehensive rename plan before implementation. The plan should map -all likely surfaces, including: - -- visible product names in the web UI, desktop UI, marketing pages, window - titles, screenshots, docs, onboarding text, settings, empty states, toasts, - release text, and update metadata; -- package/app names, binary names, bundle identifiers, desktop artifact names, - updater metadata, and configuration directories where changing identity is - safe and intentional; -- internal TypeScript symbols, CSS classes, route names, storage keys, - database fields, protocol constants, and package names where a mechanical - rename may create migration or compatibility risk; -- repository docs, comments, tests, fixtures, snapshots, scripts, GitHub - metadata, and deployment/release scripts; -- legacy/upstream references that should remain as provenance, compatibility, - or third-party names rather than being renamed. - -## Constraints - -- Preserve predictable behavior under existing local data, saved sessions, - WebSocket clients, update channels, and desktop installs. -- Do not silently break persisted client storage, SQLite data, auth/session - state, package import paths, or release/update channels. -- Classify each rename target as one of: - - user-facing rename now; - - internal rename with migration; - - compatibility alias / leave stable; - - provenance reference / leave as T3 Code. -- Include a verification plan using this repository's required gates: - `bun fmt`, `bun lint`, and `bun typecheck`. Do not run `bun test`; use - `bun run test` only if tests are needed. -- Include a staged implementation plan with small safe commits or bursts, - rather than a single global string replacement. - -## Expected Output - -The agreed plan should be specific enough that an implementer can execute the -rename without rediscovering the codebase. It should name concrete directories -or file classes to inspect, define migration/compatibility rules, and identify -high-risk surfaces before code changes begin. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/001/status.json b/.selene/bursts/001/status.json deleted file mode 100644 index 77319466..00000000 --- a/.selene/bursts/001/status.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "adjudication_decision": "auditor", - "adjudication_note": "Both reconciliations agreed substantively on a source-grounded tiered rename classification plan. Adopt Claude's reconciliation for the agreed-plan skeleton and treat .selene/bursts/001/design/rename-classification.md as the detailed Burst 001 planning artifact for the Cafe Code rename. No implementation burst is approved yet.", - "adjudication_phase": "design", - "audit": ".selene/bursts/001/audit.md", - "correction_round": 0, - "final_verdict": "AUDIT_PASS", - "finished_at": "2026-05-21T00:12:50.164213+00:00", - "finished_audit_at": "2026-05-21T00:45:41.736787+00:00", - "finished_burst_at": "2026-05-21T00:45:41.737313+00:00", - "finished_implementation_at": "2026-05-21T00:35:08.381454+00:00", - "implementation_log": ".selene/bursts/001/implement_codex.log", - "implementation_report": ".selene/bursts/001/artifacts/IMPLEMENTATION_REPORT.md", - "last_audit": ".selene/bursts/001/audit.md", - "last_verdict": "AUDIT_PASS", - "phase": "burst-complete", - "plan_path": ".selene/bursts/001/design/adjudication_request.md", - "started_at": "2026-05-21T00:22:30.344770+00:00", - "started_audit_at": "2026-05-21T00:35:08.381959+00:00", - "started_burst_at": "2026-05-21T00:22:30.344183+00:00", - "updated_at": "2026-05-21T00:45:41.737374+00:00" -} diff --git a/.selene/bursts/002/approved.json b/.selene/bursts/002/approved.json deleted file mode 100644 index 1f1da5fd..00000000 --- a/.selene/bursts/002/approved.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "approved_at": "2026-05-21T00:59:03.456505+00:00", - "burst": 2, - "note": "User approved Burst 002 full Cafe Code rebrand: repository-owned internal and visible T3/T3 Code/t3code/T3CODE/@t3tools surfaces should become Cafe equivalents, with legacy aliases/fallbacks where needed and a domain migration checklist for later hosting.", - "plan_path": ".selene/bursts/002/design/agreed_plan.md", - "plan_sha256": "c04604fb6a63e4604721d87e8278a850d5dc256d5013948b55a174899b4bfd36" -} diff --git a/.selene/bursts/002/artifacts/IMPLEMENTATION_REPORT.md b/.selene/bursts/002/artifacts/IMPLEMENTATION_REPORT.md deleted file mode 100644 index 16c9645e..00000000 --- a/.selene/bursts/002/artifacts/IMPLEMENTATION_REPORT.md +++ /dev/null @@ -1,175 +0,0 @@ -# Burst 002 Implementation Report - -Status: manual recovery implementation complete; final gates passed. - -The Selene implementation worker entered Burst 002 and produced the bulk -rewrite, but did not close with artifacts. This report records the recovered -implementation against the approved `.selene/bursts/002/design/agreed_plan.md` -and the Burst 001 58-row matrix. - -## Implementation Summary - -- Renamed repository-owned package scope and imports from `@t3tools/*` to - `@cafecode/*`. -- Renamed the preferred server package and CLI entrypoint to `cafe-code` while - keeping the `t3` bin alias. -- Added Cafe-first environment handling with `CAFE_CODE_*` preferred and - `T3CODE_*` retained as legacy fallback. -- Added Cafe-first browser storage keys with legacy `t3code:*` migration reads. -- Added Cafe-first home/config paths where safe, with deterministic legacy reads. -- Added `/.well-known/cafe-code/environment` while preserving - `/.well-known/t3/environment`. -- Renamed repository-owned visible copy, release copy, docs, observability - defaults, and active Cafe repo links. -- Preserved Nightly version/tag/channel semantics and updater-sensitive desktop - identifiers where the approved plan required compatibility. -- Wrote `.selene/bursts/002/design/domain-migration-checklist.md` for the - hosted-domain and external-distribution work that must happen later. - -## Security And Compatibility Notes - -- Legacy env vars, storage keys, endpoint paths, checkpoint refs, worktree - prefixes, and persisted directories were not silently removed. They either - remain stable or are dual-read so existing local state and automation are not - orphaned by the rename. -- No fake Cafe hosted domains were introduced. Current `t3.codes` defaults stay - as external routing contracts until DNS/TLS/deploy/cookie/update criteria are - met. -- The compatibility changes intentionally reduce accidental data loss during - the rebrand; this report does not claim the application is secure or correct - beyond the checks recorded here. - -## Row-By-Row Matrix - -Class values are limited to the four admitted labels in the approved plan: -`user_facing_rename_now`, `internal_rename_with_migration`, -`compatibility_alias_leave_stable`, and -`provenance_reference_leave_as_t3_code`. - -| ID | Cluster | Class | Burst 002 outcome and anchors | -| --- | ------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| 1 | C3 | `user_facing_rename_now` | Web brand fallback is Cafe Code in `apps/web/src/branding.ts` and tests. | -| 2 | C3 | `user_facing_rename_now` | Desktop brand base is Cafe Code in `apps/desktop/src/app/DesktopEnvironment.ts`. | -| 3 | C3 | `user_facing_rename_now` | About panel and app chrome inherit Cafe Code from `DesktopAppIdentity.configure()`. | -| 4 | C3 | `user_facing_rename_now` | Desktop dev launcher display was renamed in `apps/desktop/scripts/electron-launcher.mjs`. | -| 5 | C3 | `user_facing_rename_now` | Web shell and splash text use Cafe Code in `apps/web/index.html` and splash components. | -| 6 | C3 | `user_facing_rename_now` | Web status/settings/update/provider copy uses Cafe Code / Cafe Code Server. | -| 7 | C3 | `user_facing_rename_now` | Server and CLI visible help/log/error copy was renamed while env and service IDs stay compatible. | -| 8 | C3 | `user_facing_rename_now` | Git author display names are Cafe Code in VCS driver paths. | -| 9 | C2 | `compatibility_alias_leave_stable` | `t3code@users.noreply.github.com` remains as a stable author email until an operator-owned identity exists. | -| 10 | C3 | `user_facing_rename_now` | Marketing site copy and active GitHub links now point to Cafe Code / `cafeai/cafe-code`. | -| 11 | C3 | `provenance_reference_leave_as_t3_code` | Marketing testimonial quote text remains unchanged in `apps/marketing/src/lib/tweets.ts`. | -| 12 | C3 | `user_facing_rename_now` | README and docs prose were renamed, with compatibility notes for old commands/env/paths. | -| 13 | C6 | `compatibility_alias_leave_stable` | External registry IDs are removed from active install docs and tracked in the domain/distribution checklist. | -| 14 | C3 | `user_facing_rename_now` | `AGENTS.md` names Cafe Code and keeps the added targeted-search policy. | -| 15 | C1 | `internal_rename_with_migration` | Root package identity is `@cafecode/monorepo`; lockfile root metadata was updated after Bun regeneration left it stale. | -| 16 | C1 | `internal_rename_with_migration` | Workspace package names/imports use `@cafecode/*`; typecheck verifies import resolution. | -| 17 | C1 | `compatibility_alias_leave_stable` | Preferred package/bin is `cafe-code`; `t3` remains a bin alias for compatibility. | -| 18 | C1 | `compatibility_alias_leave_stable` | Oxlint plugin directory/package/rule prefix moved to Cafe, with `t3code/no-inline-schema-compile` legacy rule coverage. | -| 19 | C4 | `user_facing_rename_now` | Desktop `productName` and visible package metadata use Cafe Code while app IDs remain stable. | -| 20 | C4 | `user_facing_rename_now` | Desktop build display now produces Cafe Code / Cafe Code Nightly; Nightly version/channel semantics are unchanged. | -| 21 | C4 | `internal_rename_with_migration` | Desktop artifact filename pattern remains `T3-Code-${version}-${arch}.${ext}` and is deferred to checklist criteria. | -| 22 | C4 | `compatibility_alias_leave_stable` | Staged Electron package name remains `t3code` for updater/builder continuity. | -| 23 | C4 | `user_facing_rename_now` | Staged description is Cafe Code; legal/operator author metadata remains stable where not safely owned by this burst. | -| 24 | C4 | `compatibility_alias_leave_stable` | `t3codeCommitHash` remains as a coupled writer/reader metadata field. | -| 25 | C4 | `compatibility_alias_leave_stable` | Electron app IDs remain `com.t3tools.t3code*` to preserve installed-app continuity. | -| 26 | C4 | `compatibility_alias_leave_stable` | Linux desktop entry, executable, and WM class remain `t3code*` pending package migration. | -| 27 | C4 | `compatibility_alias_leave_stable` | Desktop user-data names remain compatible; legacy `T3 Code (Alpha/Dev)` path fallback is preserved. | -| 28 | C2 | `compatibility_alias_leave_stable` | Base home defaults prefer `~/.cafecode` with `~/.t3` and `T3CODE_HOME` fallback. | -| 29 | C2 | `compatibility_alias_leave_stable` | SSH launch state and package specs keep legacy paths/specs where remote automation depends on them. | -| 30 | C2 | `compatibility_alias_leave_stable` | SSH askpass internals remain stable; env aliases were not forced into that contract. | -| 31 | C2 | `internal_rename_with_migration` | Browser storage keys are `cafecode:*` with tested legacy `t3code:*` migration paths. | -| 32 | C6 | `compatibility_alias_leave_stable` | Hosted `app.t3.codes`, `latest.app.t3.codes`, and `nightly.app.t3.codes` remain current defaults and are checklist items. | -| 33 | C6 | `compatibility_alias_leave_stable` | `/__t3code/channel` and `t3code_web_channel` remain current hosted contracts and are checklist items. | -| 34 | C6 | `compatibility_alias_leave_stable` | `https://app.t3.codes/pair` remains the hosted pairing default pending live Cafe routing. | -| 35 | C2 | `compatibility_alias_leave_stable` | Added `/.well-known/cafe-code/environment`; kept `/.well-known/t3/environment`. | -| 36 | C4 | `user_facing_rename_now` | Stable release workflow name is `Cafe Code v$version`. | -| 37 | C4 | `compatibility_alias_leave_stable` | Nightly tag/version/dist-tag shape remains `vX.Y.Z-nightly.YYYYMMDD.N` and `nightly`. | -| 38 | C4 | `compatibility_alias_leave_stable` | Update manifest/channel schemas remain stable. | -| 39 | C4 | `internal_rename_with_migration` | Active repository metadata/marketing links now use `cafeai/cafe-code`; updater publish/domain criteria remain deferred. | -| 40 | C6 | `provenance_reference_leave_as_t3_code` | Historical upstream issue/comment references remain unchanged; generic test fixture repos were moved to Cafe identity. | -| 41 | C4 | `user_facing_rename_now` | Discord/release announcement copy uses Cafe Code while Nightly semantics remain stable. | -| 42 | C2 | `compatibility_alias_leave_stable` | `CAFE_CODE_*` env vars are preferred through shared helpers; `T3CODE_*` remains fallback. | -| 43 | C2 | `compatibility_alias_leave_stable` | Project script envs write Cafe and legacy names for child-process compatibility. | -| 44 | C5 | `internal_rename_with_migration` | Observability defaults and service labels now use `cafe-code-server`; `T3CODE_OTLP_*` remains fallback. | -| 45 | C5 | `compatibility_alias_leave_stable` | Effect service IDs such as `t3/...` remain stable diagnostic/dependency IDs. | -| 46 | C5 | `compatibility_alias_leave_stable` | Existing brand-neutral span names remain unchanged. | -| 47 | C2 | `compatibility_alias_leave_stable` | Checkpoint refs prefer `refs/cafe/checkpoints` and preserve `refs/t3/checkpoints` compatibility. | -| 48 | C2 | `compatibility_alias_leave_stable` | Worktree branches prefer `cafecode/*` and still recognize `t3code/*`. | -| 49 | C2 | `internal_rename_with_migration` | VCS project config supports `.cafecode/vcs.json` with `.t3code/vcs.json` fallback. | -| 50 | C2 | `compatibility_alias_leave_stable` | Keybinding config examples/docs prefer Cafe paths while legacy user paths remain accepted. | -| 51 | C2 | `compatibility_alias_leave_stable` | Provider machine IDs such as Cursor `t3-code` remain stable provider-side identifiers. | -| 52 | C3 | `user_facing_rename_now` | Provider-visible titles and OpenCode session titles use Cafe Code. | -| 53 | C6 | `compatibility_alias_leave_stable` | Low-value test/temp prefixes were renamed where touched; remaining legacy prefixes are classified as fixtures or machine IDs. | -| 54 | C6 | `internal_rename_with_migration` | Brand asset references remain compatible until Cafe replacement artwork is provided. | -| 55 | C6 | `user_facing_rename_now` | Visible alt/copy references use Cafe Code; inherited icon artwork remains outside this code-only burst. | -| 56 | C6 | `provenance_reference_leave_as_t3_code` | License/legal ownership text is unchanged. | -| 57 | C6 | `provenance_reference_leave_as_t3_code` | Existing ADRs, invariants, and terminology-lock records were not edited. | -| 58 | C1 | `internal_rename_with_migration` | `bun.lock` package metadata follows the Cafe workspace rename and remains generated dependency state. | - -## Remaining Legacy Match Classification - -- `T3 Code` / `T3 Server`: only testimonial quotes and legacy desktop - user-data fallback/test assertions remain. -- `@t3tools` / `oxlint-plugin-t3code`: no active source matches remain outside - deleted-file history in the working tree. -- `T3CODE_*`: retained as legacy env-var fallback paths; `CAFE_CODE_*` is the - preferred prefix. -- `t3code:*` localStorage keys: retained only as legacy migration reads. -- `/.well-known/t3/environment`: retained as compatibility endpoint beside the - new Cafe endpoint. -- `refs/t3/checkpoints` and `t3code` worktree prefixes: retained as legacy - recovery/branch compatibility. -- `app.t3.codes`, `latest.app.t3.codes`, `nightly.app.t3.codes`, - `/__t3code/channel`, and `t3code_web_channel`: deferred external hosted - routing surfaces listed in the domain checklist. -- `com.t3tools.t3code`, `t3code.desktop`, `t3code` Linux executable/WM class, - and `T3-Code-${version}-${arch}.${ext}`: updater/OS/package compatibility - surfaces deferred to a packaging migration. -- `t3-code` Cursor client IDs and probe names: provider machine identifiers - retained for compatibility. -- `https://github.com/pingdotgg/t3code/issues/2388`: upstream provenance in a - source comment. - -## Verification Results - -Final gate results: - -- `bun install`: passed after package rename; Bun left the root package name - stale in `bun.lock`, so the generated metadata line was corrected to match - `package.json`. -- `bun run --cwd packages/shared test src/compatEnv.test.ts`: passed, 3 tests. -- `bun run --cwd packages/shared test src/git.test.ts src/observability.test.ts`: - passed, 17 tests. -- `bun run --cwd apps/web test src/clientPersistenceStorage.test.ts src/composerDraftStore.test.ts src/versionSkew.test.ts src/rpc/serverState.test.ts src/hostedPairing.test.ts`: - passed, 81 tests. -- `bun run --cwd apps/server test src/cli/config.test.ts src/bin.test.ts src/environment/Layers/ServerEnvironment.test.ts src/provider/Layers/CodexAdapter.test.ts`: - passed, 41 tests. -- `bun run --cwd apps/desktop test src/app/DesktopEnvironment.test.ts src/app/DesktopAppIdentity.test.ts src/app/DesktopConfig.test.ts src/settings/DesktopClientSettings.test.ts src/settings/DesktopSavedEnvironments.test.ts src/ssh/DesktopSshRemoteApi.test.ts`: - passed, 24 tests. -- `bun run --cwd scripts test notify-discord-release.test.ts build-desktop-artifact.test.ts`: - passed, 10 tests. -- `bun fmt`: passed on 1118 files. -- `bun lint`: passed with 9 existing warnings and 0 errors. -- `bun typecheck`: passed, 13 successful tasks. -- `selene freeze-invariants`: passed; TypeScript parser skipped AST signatures - for known files while file-level SHA-256 manifests still covered content. -- `selene verify`: passed manifest, AST-signature, and ADR append-only checks. -- `bun test`: not run. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/002/audit.md b/.selene/bursts/002/audit.md deleted file mode 100644 index cd0b3a83..00000000 --- a/.selene/bursts/002/audit.md +++ /dev/null @@ -1,41 +0,0 @@ -# Burst 002 Manual Recovery Audit - -Verdict: `AUDIT_PASS` - -The approved Burst 002 implementation worker did not emit close artifacts, so -the final audit was completed manually against -`.selene/bursts/002/design/agreed_plan.md`. - -## Checks - -- `.selene/bursts/002/artifacts/IMPLEMENTATION_REPORT.md` exists and maps all - 58 Burst 001 matrix rows. -- `.selene/bursts/002/design/domain-migration-checklist.md` exists and records - the deferred hosted-domain, channel-cookie, pairing, registry, updater, and - release-destination surfaces. -- Remaining `T3 Code`, `T3CODE`, `t3code`, `t3.codes`, and provider `t3-code` - matches are classified as compatibility, migration, hosted-domain, - legal/provenance, or test-lineage surfaces in the implementation report. -- `bun fmt`, `bun lint`, and `bun typecheck` passed after implementation. -- Focused `bun run test` commands passed for env compatibility, storage - migration, well-known endpoint fallback, desktop identity, release metadata, - and CLI command-path expectations. -- `selene freeze-invariants` and `selene verify` passed. TypeScript parser - skips reported by Selene are covered by file-level SHA-256 manifests. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/002/design/adjudication.json b/.selene/bursts/002/design/adjudication.json deleted file mode 100644 index 6c871634..00000000 --- a/.selene/bursts/002/design/adjudication.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "burst": 2, - "decision": "auditor", - "note": "Both reconcilers agree substantively on a matrix-driven full rebrand with compatibility aliases, Nightly preservation, and a deferred domain migration checklist. Adopt Claude's more detailed cluster-ordered reconciliation as the agreed plan, but use Selene's normal implementation report path .selene/bursts/002/artifacts/IMPLEMENTATION_REPORT.md for the burst report.", - "phase": "design", - "recorded_at": "2026-05-21T00:58:52.420664+00:00", - "synthesised_agreed_plan": ".selene/bursts/002/design/agreed_plan.md" -} diff --git a/.selene/bursts/002/design/adjudication_request.md b/.selene/bursts/002/design/adjudication_request.md deleted file mode 100644 index 02c3b148..00000000 --- a/.selene/bursts/002/design/adjudication_request.md +++ /dev/null @@ -1,25 +0,0 @@ -# Adjudication Required — Burst 002 Design Phase - -The two agents disagreed during the design reconciliation. - -## Claude - -- Verdict: agree -- Preferred focus: matrix-clustered-rebrand -- Document: `.selene/bursts/002/design/claude_reconciliation.md` - -## Codex - -- Verdict: agree -- Preferred focus: matrix-driven-full-sweep -- Document: `.selene/bursts/002/design/codex_reconciliation.md` - -## Resolution - -Run one of: - -- `selene adjudicate 2 --decision=auditor [--note="..."]` -- `selene adjudicate 2 --decision=implementer [--note="..."]` -- `selene adjudicate 2 --decision=custom --note="..."` - -Then run `selene approve 2` to proceed. diff --git a/.selene/bursts/002/design/agreed_plan.md b/.selene/bursts/002/design/agreed_plan.md deleted file mode 100644 index 3841b242..00000000 --- a/.selene/bursts/002/design/agreed_plan.md +++ /dev/null @@ -1,72 +0,0 @@ -# Agreed Plan — Burst 002 (synthesised via user adjudication) - -- Decision: auditor -- Source: .selene/bursts/002/design/claude_reconciliation.md -- Status: AGREED via user adjudication - -## User adjudication note - -Both reconcilers agree substantively on a matrix-driven full rebrand with compatibility aliases, Nightly preservation, and a deferred domain migration checklist. Adopt Claude's more detailed cluster-ordered reconciliation as the agreed plan, but use Selene's normal implementation report path .selene/bursts/002/artifacts/IMPLEMENTATION_REPORT.md for the burst report. - ---- - ---- - -verdict: agree -preferred_focus: matrix-clustered-rebrand -synthesis_or_argument: |- -Both proposals converge on a single large implementation burst with the same end-state contract — full repository-owned rebrand with compatibility aliases for persisted state, env vars, CLI bin, scope, well-known endpoint, checkpoint refs, and worktree prefix; Nightly channel identity preserved; legal/provenance/test-lineage left intact; `bun fmt`/`bun lint`/`bun typecheck` as gates; `bun run test` only for focused migration tests (no `bun test`); and a domain migration checklist artifact deferring `t3.codes`/`pingdotgg` switches. The two proposals differ only on the work-breakdown lens: claude proposes the 58-row Burst 001 matrix as the falsification checklist (Direction A); codex proposes ordered surface clusters with an audit ledger (Direction C). These are complementary, not competing — clusters are an execution ordering, the matrix is the row-by-row completion test. Synthesize by treating the matrix as the falsifier and executing the work in surface clusters: package/workspace identity → runtime compatibility (env vars, persisted state, CLI bin) → user-facing UI/docs → desktop/release metadata → cross-cutting shared identifiers → final `rg` audit. The implementation report at `.selene/bursts/002/implementation_report.md` doubles as the audit ledger, keyed by Burst 001 matrix row ID (1–58) with per-row outcome (renamed / aliased / migrated-with-dual-read / provenance-preserved) plus a cluster tag. - ---- - -# Burst 002 Reconciliation — claude - -## Selected Direction - -Matrix-Driven Cluster-Ordered Rebrand — execute the full Cafe Code rebrand in surface-clustered order, with the Burst 001 58-row matrix as the row-by-row completion test and the implementation report serving as the audit ledger. - -## Why this direction - -The Burst 001 matrix is already an exhaustive source-grounded enumeration of every known rename surface, which gives the auditor a deterministic falsifier: every row must resolve to one of the four classes admitted by `.selene/classifications.py`. Codex's cluster ordering is the right execution discipline because it forces cross-cutting consistency (one shared env-var precedence helper, one persisted-state dual-read pattern) instead of scattering related changes across the diff. Combining them preserves both proposals' end state and gates while giving the auditor both a per-row completion map and a per-cluster review structure. The two proposals already agree on the alias policy, Nightly preservation envelope, deferred domain work, gate set, and provenance/legal preservation — there is no substantive disagreement to resolve. - -## Concrete Burst 002 Plan - -- Goal: Land the full repository-owned Cafe Code rebrand in one implementation burst — every rename surface admitted by `.selene/bursts/002/design_seed.md` renamed, with compatibility aliases or dual-reads on surfaces whose deletion would lose data or break automation, and with Nightly channel identity, legal/provenance text, and current `t3.codes`/`pingdotgg` hosted references untouched pending a separate domain migration burst. -- Branches / sub-tasks (executed in cluster order; each cluster lands `bun fmt`/`bun lint`/`bun typecheck` clean before the next cluster begins, as an in-burst soft gate, not a separate close): - - **C1 Package & workspace identity**: `@t3tools/*` → `@cafecode/*` scope rename across `package.json` files, imports, TS project references, scripts; root `name`/`description`; `oxlint-plugin-t3code` rule prefix with `t3code/*` alias retained; lockfile regenerated. - - **C2 Runtime compatibility spine**: single shared env-var precedence helper (`CAFE_CODE_*` preferred, `T3CODE_*` fallback) consumed by every call site, including observability defaults; CLI bin `cafe-code` with `t3` alias bin retained; persisted-state dual-read for `localStorage` keys, `.t3code/` config dirs, `~/.t3` user dir; checkpoint ref alias; worktree branch prefix alias; `/.well-known/.../environment` alias. - - **C3 User-facing copy**: visible strings in `apps/web`, `apps/desktop`, `apps/marketing`, `apps/server` UI output, READMEs, docs, error messages, telemetry display names. - - **C4 Desktop & release metadata**: `productName` and visible build-display rename to "Cafe Code" / "Cafe Code Nightly"; electron app IDs, `userDataDirName`, artifact filename pattern `T3-Code-${version}-${arch}.${ext}`, `latest*.yml`/`nightly*.yml` schema, GitHub publish repo `pingdotgg/t3code`, and updater channel left at current values per the seed envelope. - - **C5 Cross-cutting shared identifiers**: observability service names, telemetry namespaces, log prefixes — through one source helper consistent with C2 precedence. - - **C6 Final audit & artifacts**: targeted `rg` sweeps for `T3 Code`, `T3 Server`, `t3code`, `@t3tools`, `T3CODE`, `t3.tools`, `t3.codes`, `t3`, with every remaining match classified; implementation report and domain migration checklist written. -- Predeclared classifications: cite `.selene/classifications.py`. Every Burst 001 matrix row (1–58) and every surviving `rg` match must map to exactly one of `user_facing_rename_now`, `internal_rename_with_migration`, `compatibility_alias_leave_stable`, or `provenance_reference_leave_as_t3_code`. No other class is admissible. -- Falsifying criteria: - - Any Burst 001 matrix row absent from the implementation report or mapped to a class outside the four predeclared. - - Any repository-owned visible `T3 Code`/`T3 Server` string outside legal/provenance/quoted-testimonial/current-hosted-domain contexts after C3. - - Nightly channel identity drift: any change to version pattern `vX.Y.Z-nightly.YYYYMMDD.N`, npm `nightly` dist-tag, `latest*.yml`/`nightly*.yml` schema, electron app IDs, `userDataDirName`, or artifact filename pattern. - - Persisted-state surface renamed without an in-code dual-read path and a focused `bun run test` covering the legacy-fallback branch. - - Env-var precedence implemented at call sites instead of through one shared helper, or observability defaults not honouring the same precedence. - - `@t3tools/*` scope rename landed without lockfile regeneration or with broken import resolution. - - Active release / pairing / update URLs (`pingdotgg/t3code`, current `t3.codes` domains, updater publish config, `/pair`, `/__t3code/channel`, `t3code_web_channel` cookie) switched to Cafe domains in this burst rather than deferred to the checklist. - - Legal/provenance text modified (`LICENSE` copyright, git author email `t3code@users.noreply.github.com`, staged desktop author `"T3 Tools"`, upstream lineage references, marketing testimonials, historical upstream URLs in tests, `t3code-*`/`t3-*` test fixture prefixes). - - `bun fmt`, `bun lint`, or `bun typecheck` failing on the final tree. - - `bun test` invoked anywhere in the burst (only `bun run test` permitted, and only for focused migration / precedence tests). - - Implementation report or domain migration checklist artifact absent, or not falsifiable-by-completeness against the matrix and the seed's deferred-surface list. -- Required deliverables: - - Rebranded source tree (renames plus alias/migration code). - - `.selene/bursts/002/implementation_report.md`: row-by-row table keyed by Burst 001 matrix row ID (1–58), with columns for matrix row, cluster tag (C1–C5), Burst 002 classification, diff anchor / file paths, and alias-or-migration test reference where applicable. - - `.selene/bursts/002/design/domain-migration-checklist.md`: per-surface deferred work for `app.t3.codes`, `latest.app.t3.codes`, `nightly.app.t3.codes`, `https://app.t3.codes/pair`, `/__t3code/channel`, `t3code_web_channel` cookie, well-known endpoint retirement, install registry IDs (`T3Tools.T3Code`, `t3-code`, `t3code-bin`), `pingdotgg/t3code` publish repo, updater publish config, Discord/release notification destinations — each with DNS, TLS, deploy, redirect, cookie, pairing, and update-channel criteria. - - Final `rg` audit log embedded in the implementation report, with every remaining match classified. -- Hard non-claims: append the canonical block below. - -## Disagreement (if verdict is "disagree") - -N/A — verdict is agree. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, deadlock-free, data-loss-free, or compatible with any upstream provider API outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted unless it is grounded in source review and the repository's required quality gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not make Claude, Codex, Cursor, OpenCode, or any other provider semantically correct. -- This artifact does not claim coverage of credentials, tokens, local files, persisted conversations, WebSocket sessions, or provider subprocess behavior beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/002/design/claude_proposal.md b/.selene/bursts/002/design/claude_proposal.md deleted file mode 100644 index 5559f1d6..00000000 --- a/.selene/bursts/002/design/claude_proposal.md +++ /dev/null @@ -1,88 +0,0 @@ -# Burst 002 Design Proposal — claude - -## Terminology - -This artifact follows `.selene/terminology_lock_v1.md`. `Cafe Code` is the project's new user-facing product brand; `T3 Code` survives only as compatibility alias, legacy fallback, persisted-state migration source, provenance reference, or legal/copyright text. `Nightly` is a channel/stage label whose identity (version pattern `vX.Y.Z-nightly.YYYYMMDD.N`, npm `nightly` dist-tag, updater channel) must remain end-to-end stable. - -## Inputs Reviewed - -- `.selene/bursts/002/design_seed.md` (required reading; supersedes Burst 001 default) -- `.selene/bursts/001/design/rename-classification.md` (58-row source-grounded matrix) -- `.selene/bursts/001/design/agreed_plan.md` -- `.selene/terminology_lock_v1.md` -- `.selene/classifications.py` -- `.selene/nonclaims.md` -- `.selene/adrs/0001-adopt-selene-strict-close-methodology.md` (append-only; not edited) - -## Candidate Directions - -### Direction A: Matrix-Driven Full Sweep with Tiered Compatibility - -- Goal: Execute every rename surface in the Burst 001 matrix as one comprehensive implementation burst, reclassifying compatibility-only rows where the seed now demands action, while preserving aliases/dual-reads/legacy fallbacks where deletion would cause data loss, broken automation, or updater breakage. -- Why useful: The Burst 001 matrix already enumerates the 58 known rename surfaces with file paths, evidence, and migration notes. Reusing it as the work breakdown gives the auditor a one-to-one checklist instead of an open-ended grep sweep, and inherits the Nightly-identity guardrail from the agreed plan. -- What it would falsify: - - Any matrix row whose Burst 002 outcome is not one of: `user_facing_rename_now` (renamed in code), `internal_rename_with_migration` (renamed plus dual-read/migration tested), `compatibility_alias_leave_stable` (alias added or stable), or `provenance_reference_leave_as_t3_code` (left intact with rationale). - - Nightly channel identity drift (any change to version pattern, npm `nightly` dist-tag, or `latest*.yml`/`nightly*.yml` schema). - - Any repository-owned visible `T3 Code`/`T3 Server` string outside legal/provenance/quoted-testimonial contexts. - - Absence of the domain migration checklist artifact at `.selene/bursts/002/design/domain-migration-checklist.md` (or equivalent path under `.selene/bursts/002/`). - - `bun fmt`, `bun lint`, or `bun typecheck` failing on the final tree. -- Estimated burst size: large. -- Tradeoffs: Single comprehensive sweep is the highest blast radius the project can take in one burst, but the matrix bounds it. The reclassification step requires careful judgement for rows the seed wants reclassified out of `compatibility_alias_leave_stable` (e.g., `@t3tools/*` workspace scope, `t3` CLI bin, env vars, well-known endpoint, persisted browser storage keys, VCS config path, checkpoint refs, worktree branch prefix), each of which now needs an alias plan implemented rather than deferred. Adds an implementation-report artifact mapping each matrix row to its Burst 002 disposition. - -### Direction B: Layered Rebrand by Blast Radius - -- Goal: Implement the full Cafe Code rename in one burst but order the work in distinct layers from lowest to highest external-contract risk: (L1) visible product strings; (L2) package/workspace identity with `@cafecode/*` rename and `@t3tools/*` deprecation alias; (L3) CLI/server bin with `t3` alias retained; (L4) env vars with `CAFE_CODE_*`-first/`T3CODE_*`-fallback precedence; (L5) persisted state (`localStorage`, `.t3code/`, `~/.t3`) with dual-read migration; (L6) well-known endpoint, checkpoint refs, worktree prefix, observability defaults — added as aliases without retiring old names; (L7) domain migration checklist artifact. -- Why useful: Orders work so each layer is independently bisectable and revertable. Visible-copy layer can land first and be validated cleanly before touching package identity or persisted state. Forces explicit acknowledgement of which surfaces are alias-only versus rename-now. -- What it would falsify: - - Any layer skipped entirely, or any layer not closing with `bun fmt`/`bun lint`/`bun typecheck` clean before the next layer begins (treated as in-burst soft gate, not separate close). - - Persisted-state layer landing without an in-code dual-read path (e.g., legacy fallback in `apps/desktop/src/app/DesktopEnvironment.ts` `resolveUserDataPath` pattern extended to new surfaces) or without a focused `bun run test` covering the migration. - - Updater-affecting surfaces (electron app IDs, artifact filenames, GitHub publish repo, `latest*.yml` schema) modified without a documented updater choreography in the implementation report. -- Estimated burst size: large. -- Tradeoffs: More structure makes review easier and reduces the chance of a half-applied rename, but layer ordering introduces sequencing overhead and the burst still has to close as a single audit. Cross-cutting surfaces (env vars used by docs and runtime) sit in multiple layers and risk being split or duplicated. - -### Direction C: Owner-Domain Rebrand Organised by Package - -- Goal: Group renames by code owner — `apps/web`, `apps/desktop`, `apps/server`, `apps/marketing`, `packages/contracts`, `packages/shared`, `packages/oxlint-plugin-t3code`, `scripts/`, root metadata, repository docs — and sweep each owner exhaustively before moving on. Cross-cutting surfaces (env vars, well-known endpoint, observability defaults) get a final "shared identifiers" pass. -- Why useful: Maps to how the repo is structured for review and code ownership. Each owner sweep can be verified by `bun --filter typecheck`/`lint` locally before moving on. Easier to assign mental ownership when reviewing the final diff. -- What it would falsify: - - Any owner left with repository-owned `T3 Code`/`T3 Server` visible copy. - - Cross-cutting alias surfaces (env vars, `/.well-known/.../environment`) implemented inconsistently across owners. - - Missing implementation report keyed by owner. - - Domain migration checklist artifact absent. -- Estimated burst size: large. -- Tradeoffs: Owner-by-owner sweeps are easy to reason about per package but make it harder to ensure cross-cutting symbols (e.g., `T3CODE_HOME` referenced from server, docs, scripts, and dev runner) move together. Risks the audit finding "the rename is done in web but inconsistent in server" if the shared pass is rushed. Lockfile regeneration after the package-scope rename concentrates risk at the end. - -## Recommended Direction - -**Direction A — Matrix-Driven Full Sweep with Tiered Compatibility.** - -The Burst 001 matrix is already the explicit, source-grounded enumeration of every rename surface in the repository, and the auditor expectations in `.selene/bursts/002/design_seed.md` line up with running targeted `rg` checks against the same identifier set (`T3 Code`, `T3 Server`, `t3code`, `@t3tools`, `T3CODE`, `t3.tools`, `t3.codes`, `t3`). Using the matrix as the implementation checklist gives Burst 002 a deterministic completion test: every row resolves to one of the four predeclared classes admitted by `.selene/classifications.py`, and the auditor verifies row-by-row rather than chasing a moving grep. Direction B's layering and Direction C's owner-grouping are mostly _execution-order_ refinements that can be applied inside Direction A by working through the matrix in roughly layered/owner order; they do not change the closure criterion. The seed's superseding policy — which reclassifies many former `compatibility_alias_leave_stable` rows into `internal_rename_with_migration` (with aliases) — is most cleanly tracked when the implementation report is itself a matrix delta against Burst 001. - -Concretely, the Burst 002 implementation should produce: - -- Renamed source/docs/config/package metadata wherever the seed admits a rename, with compatibility aliases on persisted state, env vars, CLI bin (`t3` retained), `@t3tools/*` (read seed: scope rename now to `@cafecode/*` with lockfile regeneration), well-known endpoint, observability service names, checkpoint refs, and worktree branch prefix. -- Preserved Nightly identity across version pattern, npm `nightly` dist-tag, `latest*.yml`/`nightly*.yml`, and updater channels; visible Nightly copy renamed to "Cafe Code Nightly". -- A new artifact `.selene/bursts/002/design/domain-migration-checklist.md` listing the precise later work required before `app.t3.codes`, `latest.app.t3.codes`, `nightly.app.t3.codes`, hosted pairing URL `https://app.t3.codes/pair`, channel route `/__t3code/channel`, channel cookie `t3code_web_channel`, well-known T3 endpoint retirement, install registry IDs (`T3Tools.T3Code`, `t3-code`, `t3code-bin`), `pingdotgg/t3code` publish repo, updater publish config, and Discord/release notification destinations can be migrated to Cafe Code domains. The checklist must enumerate DNS, TLS, deploy, redirect, cookie, pairing, and update-channel criteria per surface. -- An implementation report `.selene/bursts/002/implementation_report.md` mapping each Burst 001 matrix row ID (1–58) to its Burst 002 outcome: renamed (with diff anchor), aliased (with alias precedence and test reference), migrated with dual-read (with migration code anchor), or left as provenance (with reason). -- Quality gates: `bun fmt`, `bun lint`, `bun typecheck` pass on the post-rename tree. Use `bun run test` (not `bun test`) only for focused tests covering migration paths (e.g., persisted-key dual-read, env-var precedence, well-known endpoint alias). - -## Concerns / Open Questions - -1. **`@t3tools/*` workspace scope.** Burst 001 row 16 classified scope rename as `compatibility_alias_leave_stable`. Burst 002 seed's preferred scope is `@cafecode`. Renaming the scope forces import migration across the monorepo, lockfile regeneration, and re-resolution of any external consumer (oxlint plugin rule prefix `t3code/*` and root `@t3tools/monorepo` identity). Direction A treats this as `internal_rename_with_migration` with `@t3tools/*` retained only where an external consumer is provable; please confirm. -2. **CLI bin.** Seed says rename to `cafe-code` with `t3` as a temporary alias. Need to confirm whether `t3 serve`/`t3 auth` invocations in docs become `cafe-code serve`/`cafe-code auth` while `t3` bin stays installable, and whether SSH remote-launch (`~/.t3/ssh-launch`, `t3@latest`, `t3@nightly` package specs in row 29) follows or trails. -3. **GitHub repository identity.** Active publish repo `pingdotgg/t3code` (row 39) and seed-stated new URL `https://github.com/cafeai/cafe-code`. Until a real GitHub rename/redirect exists, references to active release URLs must remain `pingdotgg/t3code` to avoid breaking downloads/updates; please confirm the Cafe URL is documented as "future" only, not switched in update manifests or marketing CTAs. -4. **Updater choreography for desktop.** Renaming `productName` (row 19) and visible build display (row 20) without changing electron app IDs (row 25), `userDataDirName` (row 27), artifact filenames (row 21), or `latest*.yml`/`nightly*.yml` schemas (row 38) is the safe envelope. Need confirmation that artifact filenames stay `T3-Code-${version}-${arch}.${ext}` for this burst, with rename deferred to the domain/updater migration checklist. -5. **Asset filenames and icons** (rows 54, 55). If Cafe Code artwork is not yet available, the burst should keep inherited T3 visual assets (with a rename of asset map keys only if no path reference breaks) and explicitly flag art replacement as a follow-up. Please confirm. -6. **Legal metadata.** `LICENSE` copyright (row 56), git author email `t3code@users.noreply.github.com` (row 9), and staged desktop author (`"T3 Tools"`, row 23). These remain `provenance_reference_leave_as_t3_code` unless legal/operator confirms otherwise; the burst should not touch them. -7. **Test fixtures and provenance quotes** (rows 11, 40, 53). Marketing testimonials, historical upstream URLs in tests, and `t3code-*`/`t3-*` test prefixes remain provenance/compat. The auditor's `rg` sweep must accept these matches as justified. -8. **`CAFE_CODE_*` env var precedence.** Seed requires reading Cafe first, T3 second. Confirm the precedence implementation belongs in a single shared helper rather than per-call-site `??` chains, and that observability defaults (row 44) honour the same precedence. -9. **Scope of test changes.** Burst 002 seed forbids `bun test` and only allows `bun run test` for focused tests. Migration/precedence tests likely need to be added (or existing tests updated) for the persisted-state dual-read paths and env-var precedence; this needs explicit acknowledgement so the auditor does not flag added tests as scope creep. -10. **Risk of partial rename.** A burst this large risks landing visible copy without the corresponding migration code on a persisted-state surface. The implementation report's per-row mapping is the auditor's only practical defence against partial completion; the report itself must be falsified-by-completeness, not free-form. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, deadlock-free, data-loss-free, or compatible with any upstream provider API outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted unless it is grounded in source review and the repository's required quality gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not make Claude, Codex, Cursor, OpenCode, or any other provider semantically correct. -- This artifact does not claim coverage of credentials, tokens, local files, persisted conversations, WebSocket sessions, or provider subprocess behavior beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/002/design/claude_reconciliation.md b/.selene/bursts/002/design/claude_reconciliation.md deleted file mode 100644 index 5201d00a..00000000 --- a/.selene/bursts/002/design/claude_reconciliation.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -verdict: agree -preferred_focus: matrix-clustered-rebrand -synthesis_or_argument: Both proposals converge on a single large implementation burst with the same end-state contract — full repository-owned rebrand with compatibility aliases for persisted state, env vars, CLI bin, scope, well-known endpoint, checkpoint refs, and worktree prefix; Nightly channel identity preserved; legal/provenance/test-lineage left intact; `bun fmt`/`bun lint`/`bun typecheck` as gates; `bun run test` only for focused migration tests (no `bun test`); and a domain migration checklist artifact deferring `t3.codes`/`pingdotgg` switches. The two proposals differ only on the work-breakdown lens: claude proposes the 58-row Burst 001 matrix as the falsification checklist (Direction A); codex proposes ordered surface clusters with an audit ledger (Direction C). These are complementary, not competing — clusters are an execution ordering, the matrix is the row-by-row completion test. Synthesize by treating the matrix as the falsifier and executing the work in surface clusters: package/workspace identity → runtime compatibility (env vars, persisted state, CLI bin) → user-facing UI/docs → desktop/release metadata → cross-cutting shared identifiers → final `rg` audit. The implementation report at `.selene/bursts/002/implementation_report.md` doubles as the audit ledger, keyed by Burst 001 matrix row ID (1–58) with per-row outcome (renamed / aliased / migrated-with-dual-read / provenance-preserved) plus a cluster tag. ---- - -# Burst 002 Reconciliation — claude - -## Selected Direction - -Matrix-Driven Cluster-Ordered Rebrand — execute the full Cafe Code rebrand in surface-clustered order, with the Burst 001 58-row matrix as the row-by-row completion test and the implementation report serving as the audit ledger. - -## Why this direction - -The Burst 001 matrix is already an exhaustive source-grounded enumeration of every known rename surface, which gives the auditor a deterministic falsifier: every row must resolve to one of the four classes admitted by `.selene/classifications.py`. Codex's cluster ordering is the right execution discipline because it forces cross-cutting consistency (one shared env-var precedence helper, one persisted-state dual-read pattern) instead of scattering related changes across the diff. Combining them preserves both proposals' end state and gates while giving the auditor both a per-row completion map and a per-cluster review structure. The two proposals already agree on the alias policy, Nightly preservation envelope, deferred domain work, gate set, and provenance/legal preservation — there is no substantive disagreement to resolve. - -## Concrete Burst 002 Plan - -- Goal: Land the full repository-owned Cafe Code rebrand in one implementation burst — every rename surface admitted by `.selene/bursts/002/design_seed.md` renamed, with compatibility aliases or dual-reads on surfaces whose deletion would lose data or break automation, and with Nightly channel identity, legal/provenance text, and current `t3.codes`/`pingdotgg` hosted references untouched pending a separate domain migration burst. -- Branches / sub-tasks (executed in cluster order; each cluster lands `bun fmt`/`bun lint`/`bun typecheck` clean before the next cluster begins, as an in-burst soft gate, not a separate close): - - **C1 Package & workspace identity**: `@t3tools/*` → `@cafecode/*` scope rename across `package.json` files, imports, TS project references, scripts; root `name`/`description`; `oxlint-plugin-t3code` rule prefix with `t3code/*` alias retained; lockfile regenerated. - - **C2 Runtime compatibility spine**: single shared env-var precedence helper (`CAFE_CODE_*` preferred, `T3CODE_*` fallback) consumed by every call site, including observability defaults; CLI bin `cafe-code` with `t3` alias bin retained; persisted-state dual-read for `localStorage` keys, `.t3code/` config dirs, `~/.t3` user dir; checkpoint ref alias; worktree branch prefix alias; `/.well-known/.../environment` alias. - - **C3 User-facing copy**: visible strings in `apps/web`, `apps/desktop`, `apps/marketing`, `apps/server` UI output, READMEs, docs, error messages, telemetry display names. - - **C4 Desktop & release metadata**: `productName` and visible build-display rename to "Cafe Code" / "Cafe Code Nightly"; electron app IDs, `userDataDirName`, artifact filename pattern `T3-Code-${version}-${arch}.${ext}`, `latest*.yml`/`nightly*.yml` schema, GitHub publish repo `pingdotgg/t3code`, and updater channel left at current values per the seed envelope. - - **C5 Cross-cutting shared identifiers**: observability service names, telemetry namespaces, log prefixes — through one source helper consistent with C2 precedence. - - **C6 Final audit & artifacts**: targeted `rg` sweeps for `T3 Code`, `T3 Server`, `t3code`, `@t3tools`, `T3CODE`, `t3.tools`, `t3.codes`, `t3`, with every remaining match classified; implementation report and domain migration checklist written. -- Predeclared classifications: cite `.selene/classifications.py`. Every Burst 001 matrix row (1–58) and every surviving `rg` match must map to exactly one of `user_facing_rename_now`, `internal_rename_with_migration`, `compatibility_alias_leave_stable`, or `provenance_reference_leave_as_t3_code`. No other class is admissible. -- Falsifying criteria: - - Any Burst 001 matrix row absent from the implementation report or mapped to a class outside the four predeclared. - - Any repository-owned visible `T3 Code`/`T3 Server` string outside legal/provenance/quoted-testimonial/current-hosted-domain contexts after C3. - - Nightly channel identity drift: any change to version pattern `vX.Y.Z-nightly.YYYYMMDD.N`, npm `nightly` dist-tag, `latest*.yml`/`nightly*.yml` schema, electron app IDs, `userDataDirName`, or artifact filename pattern. - - Persisted-state surface renamed without an in-code dual-read path and a focused `bun run test` covering the legacy-fallback branch. - - Env-var precedence implemented at call sites instead of through one shared helper, or observability defaults not honouring the same precedence. - - `@t3tools/*` scope rename landed without lockfile regeneration or with broken import resolution. - - Active release / pairing / update URLs (`pingdotgg/t3code`, current `t3.codes` domains, updater publish config, `/pair`, `/__t3code/channel`, `t3code_web_channel` cookie) switched to Cafe domains in this burst rather than deferred to the checklist. - - Legal/provenance text modified (`LICENSE` copyright, git author email `t3code@users.noreply.github.com`, staged desktop author `"T3 Tools"`, upstream lineage references, marketing testimonials, historical upstream URLs in tests, `t3code-*`/`t3-*` test fixture prefixes). - - `bun fmt`, `bun lint`, or `bun typecheck` failing on the final tree. - - `bun test` invoked anywhere in the burst (only `bun run test` permitted, and only for focused migration / precedence tests). - - Implementation report or domain migration checklist artifact absent, or not falsifiable-by-completeness against the matrix and the seed's deferred-surface list. -- Required deliverables: - - Rebranded source tree (renames plus alias/migration code). - - `.selene/bursts/002/implementation_report.md`: row-by-row table keyed by Burst 001 matrix row ID (1–58), with columns for matrix row, cluster tag (C1–C5), Burst 002 classification, diff anchor / file paths, and alias-or-migration test reference where applicable. - - `.selene/bursts/002/design/domain-migration-checklist.md`: per-surface deferred work for `app.t3.codes`, `latest.app.t3.codes`, `nightly.app.t3.codes`, `https://app.t3.codes/pair`, `/__t3code/channel`, `t3code_web_channel` cookie, well-known endpoint retirement, install registry IDs (`T3Tools.T3Code`, `t3-code`, `t3code-bin`), `pingdotgg/t3code` publish repo, updater publish config, Discord/release notification destinations — each with DNS, TLS, deploy, redirect, cookie, pairing, and update-channel criteria. - - Final `rg` audit log embedded in the implementation report, with every remaining match classified. -- Hard non-claims: append the canonical block below. - -## Disagreement (if verdict is "disagree") - -N/A — verdict is agree. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, deadlock-free, data-loss-free, or compatible with any upstream provider API outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted unless it is grounded in source review and the repository's required quality gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not make Claude, Codex, Cursor, OpenCode, or any other provider semantically correct. -- This artifact does not claim coverage of credentials, tokens, local files, persisted conversations, WebSocket sessions, or provider subprocess behavior beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/002/design/codex_proposal.md b/.selene/bursts/002/design/codex_proposal.md deleted file mode 100644 index df6b68a4..00000000 --- a/.selene/bursts/002/design/codex_proposal.md +++ /dev/null @@ -1,60 +0,0 @@ -# Burst 002 Design Proposal — codex - -## Terminology - -This proposal uses the vocabulary defined in `.selene/terminology_lock_v1.md`. `cafe-code` names this fork; `T3 Code` is used only for upstream lineage, legal/provenance text, current hosted-domain references, or explicit legacy compatibility. - -## Candidate Directions - -### Direction A: Single-Pass Repository-Owned Rebrand - -- Goal: Rename all repository-owned `T3` / `T3 Code` / `t3code` / `@t3tools` / `T3CODE` surfaces to the required Cafe Code equivalents in one comprehensive implementation burst. -- Why useful: This most directly satisfies the Burst 002 seed by treating visible copy, package metadata, import specifiers, app IDs, docs, scripts, storage keys, env vars, and release metadata as one coherent rename. -- What it would falsify: It would falsify the assumption that any active repository-owned T3 naming can remain unclassified after the burst; remaining matches must be legacy compatibility, current hosted-domain defaults, legal/provenance, or test lineage. -- Estimated burst size: large -- Tradeoffs: Highest chance of finishing the required outcome in one burst, but also the largest review surface. Lockfile churn, package-scope changes, desktop metadata, and release/update compatibility must be handled carefully. - -### Direction B: Compatibility-Spine First Full Rebrand - -- Goal: First implement deterministic Cafe-first / legacy-second compatibility for env vars, config paths, localStorage keys, git refs, CLI bins, and updater-sensitive identifiers, then complete the source-wide Cafe Code rename. -- Why useful: It puts non-destructive migration behavior before broad textual and package renames, reducing the risk of orphaning existing state or breaking operator automation. -- What it would falsify: It would test whether the project can adopt Cafe Code canonical identifiers while still reading legacy `T3CODE_*`, `t3code:*`, `~/.t3`, `.t3code`, `t3`, and old checkpoint/ref names where required. -- Estimated burst size: large -- Tradeoffs: Better safety posture for security-sensitive data and persisted state, but may add temporary compatibility helpers or naming constants that need disciplined ownership. It still must finish the full rebrand to satisfy Burst 002. - -### Direction C: Surface-Clustered Complete Rebrand With Audit Ledger - -- Goal: Complete the full Cafe Code rebrand in ordered surface clusters: package/workspace identity, runtime compatibility, user-facing UI/docs, desktop/release metadata, and final `rg` audit plus domain migration checklist. -- Why useful: It preserves the required full implementation while making each class of rename reviewable and auditable against the seed’s falsifying criteria. -- What it would falsify: It would show whether every remaining legacy match can be classified against a written audit ledger as compatibility alias, legacy fallback, current hosted-domain reference, legal/provenance, or test fixture lineage. -- Estimated burst size: large -- Tradeoffs: More process overhead than a pure single-pass rename, but lower risk of hiding an unsafe deletion or unreviewed active T3 surface. The burst cannot close until all clusters and gates pass. - -## Recommended Direction - -I recommend Direction C. Burst 002 is explicitly a full implementation burst, so a partial visible-copy rename is not viable. A surface-clustered implementation gives the best balance: it keeps the end state comprehensive, preserves legacy reads where dropping them could lose data or break automation, and gives the auditor a concrete map for evaluating remaining `T3` / `t3code` / `@t3tools` / `T3CODE` matches. The implementation should end with `bun fmt`, `bun lint`, `bun typecheck`, targeted `rg` audits, and a domain migration checklist artifact covering DNS, TLS, deploy, redirects, cookies, pairing URLs, and update-channel criteria before any future move away from current `t3.codes` hosted defaults. - -## Concerns / Open Questions - -- Package scope migration from `@t3tools/*` to `@cafecode/*` may require coordinated package metadata, imports, scripts, TypeScript references, and lockfile updates. -- Desktop app IDs, updater metadata, and release artifacts may have external compatibility constraints that cannot be solved only inside the repository. -- Current `t3.codes` hosted-domain references must not be replaced with invented Cafe domains; they should remain only as current-default or legacy references until the checklist is executed. -- Legacy env vars, paths, storage keys, CLI bins, and refs should be read non-destructively, with Cafe Code names preferred for new writes. -- Focused tests may be useful for migration helpers, but the required completion gates are `bun fmt`, `bun lint`, and `bun typecheck`; do not run `bun test`, use `bun run test` only if needed. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/002/design/codex_reconciliation.md b/.selene/bursts/002/design/codex_reconciliation.md deleted file mode 100644 index 1991aea3..00000000 --- a/.selene/bursts/002/design/codex_reconciliation.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -verdict: agree -preferred_focus: matrix-driven-full-sweep -synthesis_or_argument: I agree with Claude's Matrix-Driven Full Sweep as the preferred close criterion, with my audit-ledger emphasis folded in as the implementation report and final `rg` classification pass. The Burst 001 matrix is the strongest available source-grounded checklist, while clustered/layered execution can still be used internally to reduce risk. Burst 002 should not close on a visible-copy rename alone; it must rename repository-owned Cafe Code surfaces, preserve legacy reads where state or external contracts require it, and classify every remaining legacy match against `.selene/classifications.py`. ---- - -# Burst 002 Reconciliation — codex - -## Selected Direction - -Matrix-Driven Full Sweep with Tiered Compatibility — complete the Cafe Code rebrand against the Burst 001 rename matrix, with migration aliases and an auditable row-by-row close report. - -## Why this direction - -The existing 58-row Burst 001 matrix gives Burst 002 a concrete completion boundary instead of an open-ended textual sweep. It also handles the seed's stricter stance cleanly: rows previously left as compatibility-only must now either be renamed with migration, retained as aliases with precedence, or justified as provenance/legal/current-hosted-domain references. Layered or package-clustered execution remains useful as an implementation order, but the reconciliation focus should be matrix closure plus strict final audit. - -## Concrete Burst 002 Plan - -- Goal: Rename repository-owned `T3` / `T3 Code` / `t3code` / `@t3tools` / `T3CODE` surfaces to Cafe Code equivalents where the seed requires action, while preserving non-destructive compatibility for persisted state, env vars, CLI/package aliases, updater-sensitive identifiers, and current hosted-domain defaults. -- Branches / sub-tasks: execute the Burst 001 matrix in reviewable clusters: package/workspace identity, CLI and runtime aliases, env/config/storage migration, web/server/desktop visible copy, docs/scripts/release metadata, hosted-domain checklist, then final audit/report generation. -- Predeclared classifications: cite `.selene/classifications.py` and classify each remaining or changed row as `user_facing_rename_now`, `internal_rename_with_migration`, `compatibility_alias_leave_stable`, or `provenance_reference_leave_as_t3_code`. -- Falsifying criteria: any matrix row lacks a Burst 002 disposition; any active repository-owned visible T3 naming remains outside admitted classifications; Cafe-first/T3-second migration precedence is missing where required; Nightly channel identity drifts; current `t3.codes` hosted defaults are replaced without the required migration checklist; or `bun fmt`, `bun lint`, or `bun typecheck` fails. -- Required deliverables: source changes, regenerated lockfile/package metadata where needed, focused `bun run test` coverage for migration helpers if added, `.selene/bursts/002/implementation_report.md` mapping matrix rows 1-58 to outcomes and anchors, `.selene/bursts/002/design/domain-migration-checklist.md`, final targeted `rg` audit, and passing `bun fmt`, `bun lint`, `bun typecheck`. -- Hard non-claims: append the canonical block. - -## Disagreement (if verdict is "disagree") - -N/A. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/002/design/design.json b/.selene/bursts/002/design/design.json deleted file mode 100644 index cccc0936..00000000 --- a/.selene/bursts/002/design/design.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "burst": 2, - "claude_proposal": ".selene/bursts/002/design/claude_proposal.md", - "claude_reconciliation": { - "path": ".selene/bursts/002/design/claude_reconciliation.md", - "preferred_focus": "matrix-clustered-rebrand", - "synthesis_or_argument": "Both proposals converge on a single large implementation burst with the same end-state contract \u2014 full repository-owned rebrand with compatibility aliases for persisted state, env vars, CLI bin, scope, well-known endpoint, checkpoint refs, and worktree prefix; Nightly channel identity preserved; legal/provenance/test-lineage left intact; `bun fmt`/`bun lint`/`bun typecheck` as gates; `bun run test` only for focused migration tests (no `bun test`); and a domain migration checklist artifact deferring `t3.codes`/`pingdotgg` switches. The two proposals differ only on the work-breakdown lens: claude proposes the 58-row Burst 001 matrix as the falsification checklist (Direction A); codex proposes ordered surface clusters with an audit ledger (Direction C). These are complementary, not competing \u2014 clusters are an execution ordering, the matrix is the row-by-row completion test. Synthesize by treating the matrix as the falsifier and executing the work in surface clusters: package/workspace identity \u2192 runtime compatibility (env vars, persisted state, CLI bin) \u2192 user-facing UI/docs \u2192 desktop/release metadata \u2192 cross-cutting shared identifiers \u2192 final `rg` audit. The implementation report at `.selene/bursts/002/implementation_report.md` doubles as the audit ledger, keyed by Burst 001 matrix row ID (1\u201358) with per-row outcome (renamed / aliased / migrated-with-dual-read / provenance-preserved) plus a cluster tag.", - "verdict": "agree" - }, - "codex_proposal": ".selene/bursts/002/design/codex_proposal.md", - "codex_reconciliation": { - "path": ".selene/bursts/002/design/codex_reconciliation.md", - "preferred_focus": "matrix-driven-full-sweep", - "synthesis_or_argument": "I agree with Claude's Matrix-Driven Full Sweep as the preferred close criterion, with my audit-ledger emphasis folded in as the implementation report and final `rg` classification pass. The Burst 001 matrix is the strongest available source-grounded checklist, while clustered/layered execution can still be used internally to reduce risk. Burst 002 should not close on a visible-copy rename alone; it must rename repository-owned Cafe Code surfaces, preserve legacy reads where state or external contracts require it, and classify every remaining legacy match against `.selene/classifications.py`.", - "verdict": "agree" - }, - "computed_at": "2026-05-21T00:58:32.971510+00:00", - "status": "disagreed" -} diff --git a/.selene/bursts/002/design/domain-migration-checklist.md b/.selene/bursts/002/design/domain-migration-checklist.md deleted file mode 100644 index d8c52e15..00000000 --- a/.selene/bursts/002/design/domain-migration-checklist.md +++ /dev/null @@ -1,50 +0,0 @@ -# Burst 002 Deferred Domain And External Distribution Checklist - -This checklist records the `t3.codes`, channel-cookie, pairing, registry, and -external release surfaces that are intentionally not moved in Burst 002. No -Cafe-hosted domain is declared live by this artifact. - -## Deferred Surfaces - -| Surface | Current value | Later Cafe-owned replacement needed | Criteria before changing code/defaults | -| ------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Router domain | `https://app.t3.codes` | Chosen Cafe-owned router domain | DNS ownership verified, TLS issued, Vercel/router deployment live, old route redirects tested, pairing URLs tested from desktop and browser. | -| Latest channel domain | `latest.app.t3.codes` | Chosen Cafe-owned latest channel domain | Stable deploy succeeds, cache behavior verified, router rewrite from the new router domain reaches latest, old domain remains available during migration. | -| Nightly channel domain | `nightly.app.t3.codes` | Chosen Cafe-owned nightly channel domain | Nightly deploy succeeds, Nightly version/tag semantics unchanged, router rewrite reaches nightly, old domain remains available during migration. | -| Hosted pairing URL | `https://app.t3.codes/pair` | New Cafe router `/pair` URL | Pair-code parsing, fragment token handling, HTTPS backend links, and mixed-content rejection behavior verified on old and new URLs. | -| Channel route | `/__t3code/channel` | New Cafe route, if desired | Dual route period added, both routes select `latest`/`nightly`, redirects do not drop query params, old route retirement date recorded. | -| Channel cookie | `t3code_web_channel` | New Cafe cookie, if desired | Dual read/write implemented, precedence documented, expiry/domain/path tested, old cookie accepted until retirement. | -| Well-known endpoint | `/.well-known/t3/environment` | `/.well-known/cafe-code/environment` already added as alias | Clients fetch new endpoint successfully, legacy endpoint remains served until all desktop/web/SSH clients have migrated. | -| Web release workflow env vars | `T3CODE_WEB_ROUTER_URL`, `T3CODE_WEB_LATEST_DOMAIN`, `T3CODE_WEB_NIGHTLY_DOMAIN` | `CAFE_CODE_WEB_*` values | New vars configured in CI, legacy vars still accepted, deploy logs prove final values, no fallback to fake Cafe domains. | -| Install registry IDs | `T3Tools.T3Code`, `t3-code`, `t3code-bin` | Cafe-owned Winget, Homebrew, and AUR IDs | Registry submissions accepted, package signatures/checksums verified, README install commands restored only after IDs resolve publicly. | -| Desktop app IDs and OS integration | `com.t3tools.t3code`, `t3code.desktop`, `t3code` WM class | Cafe-owned app IDs / desktop entries | Migration plan covers existing installed apps, notifications, shortcuts, update continuity, and Linux desktop entry replacement. | -| Desktop artifact filenames | `T3-Code-${version}-${arch}.${ext}` | Cafe artifact filename pattern | `latest*.yml`/`nightly*.yml` manifests validated, old assets retained or redirected, updater smoke tests pass from old installed versions. | -| Release repository / updater publish target | Current GitHub release target and `GITHUB_REPOSITORY`/`CAFE_CODE_DESKTOP_UPDATE_REPOSITORY` | Final Cafe release repository | GitHub redirects verified, updater publish config verified, release-download URLs and Discord announcement URLs point at the final repo. | -| Discord/release destinations | Existing webhook and role configuration | Cafe-owned release channels/roles, if changed | Secrets configured, dry-run payload reviewed, Nightly and latest notifications point at live release URLs. | - -## Migration Order - -1. Choose and verify Cafe-owned domains and registry IDs outside the codebase. -2. Add dual-route and dual-cookie support before changing defaults. -3. Configure CI with `CAFE_CODE_WEB_*` and desktop update repository variables. -4. Deploy router, latest, and Nightly targets and verify end-to-end pairing. -5. Publish a release smoke build that can update from an old installed app. -6. Update docs and remove old defaults only after telemetry/support confirms the - old surfaces are no longer required. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/002/design_seed.md b/.selene/bursts/002/design_seed.md deleted file mode 100644 index 3597b7cf..00000000 --- a/.selene/bursts/002/design_seed.md +++ /dev/null @@ -1,127 +0,0 @@ -# Burst 002 Design Seed — Complete Cafe Code Rename - -## Required Outcome - -Burst 002 must be an implementation burst, not another planning-only burst. -The user explicitly rejected a planning-only close and clarified that this is a -full rebrand, not just visible copy. A successful burst must modify project -source/docs/config/package metadata so the active product, code identifiers, and -distribution metadata use `Cafe Code` naming wherever the repository can control -the surface. - -## Scope - -Use Burst 001's matrix as historical source review, but supersede its -compatibility-only default for this burst. Rename the project from T3/T3 Code to -Cafe Code across visible and internal surfaces, while preserving old names as -explicit aliases/migration inputs when changing them would otherwise orphan -state or break existing automation. - -Preferred new identifiers: - -- human brand: `Cafe Code` -- slug: `cafe-code` -- compact identifier: `cafecode` -- npm/workspace scope: `@cafecode` -- CLI package/bin: `cafe-code`, with a temporary `t3` bin alias during - migration -- env prefix: `CAFE_CODE_*`, with legacy `T3CODE_*` fallback where operators may - already depend on it -- home/config paths: `~/.cafecode`, `.cafecode`, with legacy `~/.t3` and - `.t3code` reads where state already exists -- localStorage keys: `cafecode:*`, with dual-read migration from `t3code:*` -- Git refs and branch/temp prefixes: `refs/cafe/checkpoints`, `cafecode` -- Electron/Linux IDs: Cafe Code IDs/names, with legacy user-data lookup where - applicable -- GitHub repository URL: `https://github.com/cafeai/cafe-code` -- hosted domains: do not invent or pretend Cafe domains are live in this burst. - We are not hosting domains yet. The burst must create a domain migration - checklist artifact listing future required domain, DNS, TLS, deploy, redirect, - cookie, pairing URL, and update-channel changes. Existing `t3.codes` domain - references may remain only as legacy compatibility/current-default references - until that checklist is executed. - -Rename product strings from `T3 Code` / `T3 Server` to `Cafe Code` / `Cafe Code -Server` across: - -- web branding, boot shell, splash, settings, update prompts, connection - labels, tests, and accessible text; -- desktop app display names, launcher names, package `productName`, About/menu - copy, startup/error text, tests, and desktop build product descriptions; -- server and provider user-facing messages, command descriptions, release - notification copy, release smoke expected display names, and tests; -- marketing pages/layout copy and visible command-demo copy; -- README, REMOTE, KEYBINDINGS, docs, AGENTS/CLAUDE instructions, and GitHub - workflow release display names; -- package names, workspace import specifiers, root scripts/filters, repository - metadata, plugin names/rule IDs, release scripts, desktop build metadata, - provider integration IDs/titles, observability defaults, source-control/env - docs, checkpoint refs, temporary prefixes, local config paths, browser storage - keys, and tests. - -## Compatibility Constraints - -Rebrand everything the repository owns, but preserve safety by implementing -aliases/migrations rather than silently dropping old names: - -- If renaming package imports from `@t3tools/*` to `@cafecode/*`, update all - package names, imports, tsconfig/package references, scripts, lockfile, and - tests together. -- If renaming CLI/package metadata from `t3` to `cafe-code`, keep `t3` as a bin - alias for now and update docs to prefer `cafe-code`. -- If renaming env vars from `T3CODE_*` to `CAFE_CODE_*`, code must read the Cafe - var first and the old var second, and docs should mark old names as legacy. -- If renaming persisted directories/paths/keys, add deterministic dual-read or - legacy fallback so existing users are not forced into data loss. -- If renaming routes/endpoints/cookies/domains, keep old routes/cookies as - aliases unless the old hosted surface is only documentation. Do not switch - hosted-domain defaults to a made-up Cafe domain in this burst. -- If renaming update/app IDs/artifact names, preserve updater compatibility or - document any irreducible external release/infrastructure step in the - implementation report. -- Legal/provenance-only text, upstream history, and copyright ownership may - remain T3/T3 Tools where changing it would be a false legal claim. - -## Falsifying Criteria - -Burst 002 fails if any of the following are true: - -1. It closes as planning-only or only writes Selene artifacts. -2. Any repository-owned `T3 Code` or `T3 Server` product copy remains outside - explicit legal/provenance contexts. -3. Nightly loses its channel identity. Visible Nightly copy must become Cafe - branded while version patterns, tags, dist-tags, and updater channels remain - stable. -4. Old compatibility names are simply deleted where data loss, broken existing - automation, or updater breakage would result. They must remain as aliases or - legacy fallbacks. -5. No domain migration checklist artifact exists. It must identify the exact - later work needed before replacing `app.t3.codes`, `latest.app.t3.codes`, - `nightly.app.t3.codes`, hosted pairing URLs, channel cookies/routes, TLS/DNS, - redirects, and release/update channel references. -6. `bun fmt`, `bun lint`, and `bun typecheck` do not pass. Do not run - `bun test`; use `bun run test` only for focused tests if needed. - -## Audit Expectations - -The auditor should run targeted `rg` checks for remaining `T3 Code`, `T3 -Server`, `t3code`, `@t3tools`, `T3CODE`, `t3.tools`, `t3.codes`, and active -`t3` identifiers. Remaining matches must be justified as compatibility alias, -legacy fallback, test fixture lineage, direct quote/provenance, or legal text. - -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/bursts/002/status.json b/.selene/bursts/002/status.json deleted file mode 100644 index b21becf8..00000000 --- a/.selene/bursts/002/status.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "adjudication_decision": "auditor", - "adjudication_note": "Both reconcilers agree substantively on a matrix-driven full rebrand with compatibility aliases, Nightly preservation, and a deferred domain migration checklist. Adopt Claude's more detailed cluster-ordered reconciliation as the agreed plan, but use Selene's normal implementation report path .selene/bursts/002/artifacts/IMPLEMENTATION_REPORT.md for the burst report.", - "adjudication_phase": "design", - "finished_at": "2026-05-21T00:58:32.972273+00:00", - "finished_burst_at": "2026-05-21T01:54:00+00:00", - "audit_report": ".selene/bursts/002/audit.md", - "audit_verdict": "AUDIT_PASS", - "implementation_report": ".selene/bursts/002/artifacts/IMPLEMENTATION_REPORT.md", - "phase": "complete-manual-recovery", - "plan_path": ".selene/bursts/002/design/adjudication_request.md", - "started_at": "2026-05-21T00:59:06.846391+00:00", - "started_burst_at": "2026-05-21T00:59:06.845790+00:00", - "updated_at": "2026-05-21T01:54:00+00:00", - "verification": { - "bun_fmt": "passed", - "bun_lint": "passed_with_warnings", - "bun_typecheck": "passed", - "selene_verify": "passed" - } -} diff --git a/.selene/classifications.py b/.selene/classifications.py deleted file mode 100644 index d7ebc2a6..00000000 --- a/.selene/classifications.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Project-local strict-close classifications for cafe-code. - -This project currently inherits Selene's baseline executable classifier for -numeric distance-series checks. Engineering burst scope is predeclared in ADRs -and agreed plans, then audited against the repository quality gates. Add -project-specific executable classifiers here only through a new accepted ADR. -""" - -from __future__ import annotations - -from selene.discipline.classifications import ( - DEFAULT_TOLERANCE, - Classification, - classify_pair, -) - -__all__ = ["Classification", "DEFAULT_TOLERANCE", "classify_pair"] diff --git a/.selene/contexts/.redaction.lock b/.selene/contexts/.redaction.lock deleted file mode 100644 index def697ab..00000000 --- a/.selene/contexts/.redaction.lock +++ /dev/null @@ -1 +0,0 @@ -agent=codex pid=10568 diff --git a/.selene/contexts/codex.jsonl b/.selene/contexts/codex.jsonl deleted file mode 100644 index 094f7906..00000000 --- a/.selene/contexts/codex.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"artifacts":[".selene/bursts/001/design_seed.md"],"burst":1,"role":"project-policy","summary":"REQUIRED READING for Burst 001 design: plan the comprehensive rename from T3 Code to Cafe Code while retaining the Nightly channel/version identity where appropriate. Classify every rename target as user-facing rename now, internal rename with migration, compatibility alias/leave stable, or provenance reference/leave as T3 Code. Include verification via bun fmt, bun lint, bun typecheck, and use bun run test only if tests are needed.","timestamp":"2026-05-21T00:06:58+00:00","verdict":"required-reading"} -{"artifacts": [".selene/bursts/001/design/adjudication.json"], "burst": 1, "role": "user-adjudication-design", "summary": "User adjudicated (design): auditor \u2014 Both reconciliations agreed substantively on a source-grounded tiered rename classification plan. Adopt Claude's reconciliation for the agreed-plan skeleton and treat .selene/bursts/001/design/rename-classification.md as the detailed Burst 001 planning artifact for the Cafe Code rename. No implementation burst is approved yet.", "timestamp": "2026-05-21T00:16:27.315455+00:00", "verdict": "auditor"} -{"artifacts": [".selene/bursts/001/artifacts/IMPLEMENTATION_REPORT.md", ".selene/bursts/001/implement_codex.log"], "burst": 1, "role": "implementer", "summary": "IMPLEMENTATION_OK: Burst 001 planning artifacts complete and gates passed", "timestamp": "2026-05-21T00:35:08.380856+00:00", "verdict": "ok"} -{"artifacts":[".selene/bursts/002/design_seed.md"],"burst":2,"role":"project-policy","summary":"REQUIRED READING for Burst 002 design: implement a full Cafe Code rebrand, not only visible copy. Planning-only closure is explicitly falsifying. Rename repository-owned T3/T3 Code/t3code/@t3tools/T3CODE surfaces to Cafe Code/cafe-code/cafecode/@cafecode/CAFE_CODE equivalents, while preserving old names as aliases or legacy fallbacks where deletion would cause data loss, broken automation, or updater breakage. Do not invent live Cafe domains; create a later-domain-migration checklist artifact covering DNS/TLS/deploy/redirect/cookie/pairing/update-channel criteria.","timestamp":"2026-05-21T01:01:00+00:00","verdict":"required-reading"} -{"artifacts": [".selene/bursts/002/design/adjudication.json"], "burst": 2, "role": "user-adjudication-design", "summary": "User adjudicated (design): auditor \u2014 Both reconcilers agree substantively on a matrix-driven full rebrand with compatibility aliases, Nightly preservation, and a deferred domain migration checklist. Adopt Claude's more detailed cluster-ordered reconciliation as the agreed plan, but use Selene's normal implementation report path .selene/bursts/002/artifacts/IMPLEMENTATION_REPORT.md for the burst report.", "timestamp": "2026-05-21T00:58:52.452863+00:00", "verdict": "auditor"} diff --git a/.selene/invariants/adrs.json b/.selene/invariants/adrs.json deleted file mode 100644 index 61680e31..00000000 --- a/.selene/invariants/adrs.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "adrs": ["0001-adopt-selene-strict-close-methodology.md"] -} diff --git a/.selene/invariants/manifest.json b/.selene/invariants/manifest.json deleted file mode 100644 index ac1e663a..00000000 --- a/.selene/invariants/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "files": { - ".selene/adrs/0001-adopt-selene-strict-close-methodology.md": "26de842af08ace392b0f32ff4aa6156247d7a767b9d45850956dacccfc08b2b9", - ".selene/classifications.py": "a04460af881d4d0632c840aaf506f8ae0d3615c98c5c5b6bc7d0c652727deb9d", - ".selene/nonclaims.md": "a455fe90640d5768ce73c252631c09c085ec2eb8548565e1490d38fca5bc0a98", - ".selene/terminology_lock_v1.md": "6f0dc19acd70119df7a4b346817ecd501d3da710c8cf534cd46f7f31d955bca8" - } -} diff --git a/.selene/invariants/python_signatures.json b/.selene/invariants/python_signatures.json deleted file mode 100644 index 99fe9546..00000000 --- a/.selene/invariants/python_signatures.json +++ /dev/null @@ -1,1846 +0,0 @@ -{ - "signatures": { - "apps/desktop/scripts/electron-launcher.mjs::resolveElectronPath": "0441d9df59249132e24e251198788d88488417ddffd8c076a39121902ca86830", - "apps/desktop/scripts/wait-for-resources.mjs::waitForResources": "ec9d74864305c734289073a393cac075a57e39ca5773793f957826746a0bd825", - "apps/desktop/src/app/DesktopApp.ts::DesktopBackendPortUnavailableError.message": "48b65ede6d215f9f4d92f05d057ef18d121bd0a6b9e9c337e37e0dcb9c30425e", - "apps/desktop/src/app/DesktopApp.ts::DesktopDevelopmentBackendPortRequiredError.message": "5a345e9e511542555d8f0f39c178a44a1b7af80846864ab5f4f27bc817352a20", - "apps/desktop/src/app/DesktopConfig.ts::layerTest": "1048da35c8c6010d397955b56beb7be3daf08f025d1426f1eaeed41f64162f97", - "apps/desktop/src/app/DesktopEnvironment.ts::layer": "a18e3d54b1aa2ddae6c99dbaf1bb4a49afcaed1d5c1621119e958cb29cda3481", - "apps/desktop/src/app/DesktopObservability.ts::DesktopLogFileWriterConfigurationError.message": "6b2e8e1a0d5b5bd03135e0d0ae496b036053857f146107b20c7b9ac048d918b7", - "apps/desktop/src/app/DesktopObservability.ts::makeComponentLogger": "6462bfa3d4ae263c02efcee29b2896a3ebf73cc4928a6cfe9a2572e2401217d1", - "apps/desktop/src/backend/DesktopBackendManager.ts::BackendProcessBootstrapEncodeError.message": "418ad8d09aa6f4868e2c8a7aa9336af9766a28996c61b0cfc3f593645fa5347d", - "apps/desktop/src/backend/DesktopBackendManager.ts::BackendProcessSpawnError.message": "35a52ef08f461ff20857eb1024b791a4bcc376e813d0be3d851e00d08213451b", - "apps/desktop/src/backend/DesktopBackendManager.ts::BackendTimeoutError.message": "5ca1c193d88b8ee4339e7c8c4e0825e1f4844ff5979dcf8265b3e1c8301bf423", - "apps/desktop/src/backend/DesktopServerExposure.ts::DesktopServerExposureNoNetworkAddressError.message": "2b8335950ef9f24a9f888d93cccab417c5422334a53326b2e5006d2c332bd2bc", - "apps/desktop/src/backend/DesktopServerExposure.ts::DesktopServerExposurePersistenceError.message": "b00517c870ab5aaa2b829a8155d8a8ae0dca68c74d4d51a2f9a5cfe2ae4b7abe", - "apps/desktop/src/electron/ElectronMenu.ts::buildTemplate": "57bd0c09dc0d75487cadde9ec603e8721dac0f52557fab513a94fe4812761a27", - "apps/desktop/src/electron/ElectronMenu.ts::complete": "379ea1643e99ed51a79613a93ae734f708c00f25482ec3d6df2f673a3f0db0fa", - "apps/desktop/src/electron/ElectronMenu.ts::getDestructiveMenuIcon": "9a0863f02602e5ef1171780122e829170b07ecd2ab3b8afe9dc4bb05359ef5a9", - "apps/desktop/src/electron/ElectronProtocol.ts::ElectronProtocolRegistrationError.message": "3dcd8811169f64095ab47196052591e3e9fc2ab975d48bf3d2354ac57c5397ee", - "apps/desktop/src/electron/ElectronProtocol.ts::ElectronProtocolStaticBundleMissingError.message": "10a5e1fada8dc4227e2b0d8bcbf1b71f5acfa99dfecf191254e9ec97131bfe13", - "apps/desktop/src/electron/ElectronProtocol.ts::normalizeDesktopProtocolPathname": "991ef52c017f3257d8b566a3816869355eb81bb6ee7518ec8d9119fce7794aab", - "apps/desktop/src/electron/ElectronSafeStorage.ts::ElectronSafeStorageAvailabilityError.message": "58bd507ecf5a14952a0b9b998cceea82a376f9b8f5ae06c61b9bdb7f90b29912", - "apps/desktop/src/electron/ElectronSafeStorage.ts::ElectronSafeStorageDecryptError.message": "3bbc55ec6619ad412ea38f1336f82f5555ed9467d2badfa52cf8965b34743257", - "apps/desktop/src/electron/ElectronSafeStorage.ts::ElectronSafeStorageEncryptError.message": "008ef87d0bd1aefbea407798c7dde494e69bf1265c33e18efbd2fb58a9b1c2cb", - "apps/desktop/src/electron/ElectronShell.ts::parseSafeExternalUrl": "108e8bbb638febde1c64bdf0d743e27d72c055b1813e9ec86510db796aa9a8e1", - "apps/desktop/src/electron/ElectronUpdater.ts::ElectronUpdaterCheckForUpdatesError.message": "f6dfef65071dda30594b067142148861548d40ce1b84599833e4a1f8fe776466", - "apps/desktop/src/electron/ElectronUpdater.ts::ElectronUpdaterDownloadUpdateError.message": "b3135c11de0d96bf3f9b1da0486d903e8e4b79b9703d3bf08d972b8e1393aadb", - "apps/desktop/src/electron/ElectronUpdater.ts::ElectronUpdaterQuitAndInstallError.message": "98351525a67816d485872289a52cd2fde667fa485e9c855df175d5836e4dcaca", - "apps/desktop/src/electron/ElectronWindow.ts::ElectronWindowCreateError.message": "4cd10ed7bb4046fae7a17826b8a4cf34e6c2e2ef600cee690f572c65e26bde61", - "apps/desktop/src/ipc/DesktopIpc.ts::DesktopIpcMain.handle": "58b5dc166ba764b69ce62c265c3fa466ed995859d1e730feceea29d1be4d051a", - "apps/desktop/src/ipc/DesktopIpc.ts::DesktopIpcMain.on": "8f84f95349d357652ed00cc959bdc1193250de4457300d5298d4897c0be9a8f7", - "apps/desktop/src/ipc/DesktopIpc.ts::DesktopIpcMain.removeAllListeners": "224acb3a68860186c749f01c3752f5e3babe01ae11db29e683b3d31671bdb03e", - "apps/desktop/src/ipc/DesktopIpc.ts::DesktopIpcMain.removeHandler": "f445a6d44e5024f6c960a123ae7ee68a4af79a66c5139b1337616a8165f8ce11", - "apps/desktop/src/ipc/DesktopIpc.ts::make": "1efd786e64c4fcbb8ad5469f3108c71fa5837904bf4b5ed7723883293d06423f", - "apps/desktop/src/ipc/DesktopIpc.ts::makeIpcMethod": "c0906f90d640ae2e724d9c552091e1fd6defacc6826d541cac56a009c38386ec", - "apps/desktop/src/ipc/DesktopIpc.ts::makeSyncIpcMethod": "57d9b01e398710f32e10694b02caf9927229b2cc662c3012d29986cdc358f313", - "apps/desktop/src/settings/DesktopAppSettings.ts::DesktopSettingsWriteError.message": "7850185a7eab5396d5661752769ce992cb4a5bb33991c8c2f9a3f4d5f79eefd8", - "apps/desktop/src/settings/DesktopAppSettings.ts::layerTest": "d14cf6d1d7324beedd40b6fedb8e4c140ca2d1e614ae9e33bfffb4fcbf29cf6e", - "apps/desktop/src/settings/DesktopAppSettings.ts::persist": "ab57cb7bdcfb3b76576d331766211eec3716f833ee6443b4ad3d73848afb9ca0", - "apps/desktop/src/settings/DesktopAppSettings.ts::resolveDefaultDesktopSettings": "50791b6abb537ab7dfb598b916a126122099aa66e34a8780704b60205f1b02b7", - "apps/desktop/src/settings/DesktopAppSettings.ts::update": "466c8a83ac1ab8b60e65bccd37eaa9d010d53e71366edf980c5a7cee36b6e7ab", - "apps/desktop/src/settings/DesktopClientSettings.ts::DesktopClientSettingsWriteError.message": "f0ac69d196dcd94ad25d52db1a858ef40c7c1e9838e62feb573c8a8696c9d7b8", - "apps/desktop/src/settings/DesktopClientSettings.ts::layerTest": "692f754a5565117a0ef3f7adb67e9ec0bb830a0a8e692fe287701ad9db4e9ea1", - "apps/desktop/src/settings/DesktopSavedEnvironments.ts::DesktopSavedEnvironmentSecretDecodeError.message": "f24546f36d3b777d67ef7d5f3b6adeb1af94933339b486b7b7ae29e43744380d", - "apps/desktop/src/settings/DesktopSavedEnvironments.ts::DesktopSavedEnvironmentsWriteError.message": "62205046e507c0f6666b4135885b9220105ef4defddd482e02ae8d155436fe27", - "apps/desktop/src/settings/DesktopSavedEnvironments.ts::layerTest": "bb8ae3215d6ce870fe159f9b290962b34f525aa71cf638e7c857f510146b002a", - "apps/desktop/src/settings/DesktopSavedEnvironments.ts::writeDocument": "e1fa5c4dc2daebcf9e6707d7a8bc3ab42fb2981681e4f0997228fa3055122101", - "apps/desktop/src/ssh/DesktopSshEnvironment.ts::isDesktopSshPasswordPromptCancellation": "7fb61c66e97fa558684ed68f0fb971ca774445c68077056e288df712c4c53763", - "apps/desktop/src/ssh/DesktopSshEnvironment.ts::layer": "60930e67d01b829cf57bef4456e1f7daa447192c04db0f201922268fbe8aa7bb", - "apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts::DesktopSshPromptCancelledError.message": "e4253e96cb4e94e0864970314bc3af5fd3946280f4c053be231436a0d51a4ca5", - "apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts::DesktopSshPromptExpiredError.message": "a96c774817d094f282e877f7ef09a731ccae729ef3bcaecc88c61bd777848a8b", - "apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts::DesktopSshPromptInvalidRequestIdError.message": "64ddb8b4ed2bcd48ec895480e960429c05530c8e89485f56934af9273b0b3f32", - "apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts::DesktopSshPromptSendError.message": "9e1b7436dc3bfb5e2525ce055c0da2570c11dd24cb42dff5e5c7b60d5c70923e", - "apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts::DesktopSshPromptTimedOutError.message": "9130670c71f3c1b472c30424b8be03f2bf919920128fb97a6d75b69536dfe40e", - "apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts::DesktopSshPromptUnavailableError.message": "7006bd54e0533235573548809d9ba095cef303e931753387ba00f41b15269ea5", - "apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts::DesktopSshPromptWindowUnavailableError.message": "d25dd7d43aacc955ed4411ee2c9f3ca7022944fe3c19756714f538cabd69789e", - "apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts::isDesktopSshPasswordPromptCancellation": "e00c9287806c552febbce69e90268f9542fe6d7953a52568c4d20d18f5957794", - "apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts::layer": "7eeaeb5a96bc1769809c5e0b27217786b237b93a0332179465a911648b7d231b", - "apps/desktop/src/ssh/DesktopSshRemoteApi.ts::DesktopSshRemoteApiError.message": "1dac19523d603a5b7953fbcd00878fb79afd7d5732798b75f19d0090b3ea2e0a", - "apps/desktop/src/updates/DesktopUpdates.ts::DesktopUpdateActionInProgressError.message": "297681c680f0f123c9da35dd0270b9fab13fda8144e93b63d13b4bc0cc72d184", - "apps/desktop/src/updates/DesktopUpdates.ts::DesktopUpdatePersistenceError.message": "97659036dbfa90ea85a7b2171a7b62c2aef1473da501f342d9925f8367fc3cd0", - "apps/desktop/src/updates/updateChannels.ts::isNightlyDesktopVersion": "cbdd0ea0d946aed57ce2f15a67d63875267da0da82ba1a70898095dbeef3550b", - "apps/desktop/src/updates/updateChannels.ts::resolveDefaultDesktopUpdateChannel": "1c5013ef5efffb6716ffee65e22e135d4743d3b46c8f43a60af03b70de2a5193", - "apps/desktop/src/updates/updateMachine.ts::createInitialDesktopUpdateState": "3c80adb8d2a8e54909776cacd5b0ed248ecb941b3aa139e369a6af7e1b76e8b8", - "apps/desktop/src/updates/updateMachine.ts::getCanRetryAfterDownloadFailure": "89becb0feb308696aa4d6f485491e4b70d70aa299dfe321f8834fbbd555ebee3", - "apps/desktop/src/updates/updateMachine.ts::nextStatusAfterDownloadFailure": "c07fce5d0b69f9cee639d9c0759a9256870d141e50b8afc3614e0808a7617df2", - "apps/desktop/src/updates/updateMachine.ts::reduceDesktopUpdateStateOnCheckFailure": "9c0001fea589fbc2cff943742e1604b922fa563b3c1638606309bcf9deca3c3a", - "apps/desktop/src/updates/updateMachine.ts::reduceDesktopUpdateStateOnCheckStart": "721d8b8b0b781e03b928f18067014d828a0e7acf96515a9236776a916429616b", - "apps/desktop/src/updates/updateMachine.ts::reduceDesktopUpdateStateOnDownloadComplete": "e61ef9986e5b9fe82124a1b06a90236d7effb750b0a15a6d4385ece8d2dfd81a", - "apps/desktop/src/updates/updateMachine.ts::reduceDesktopUpdateStateOnDownloadFailure": "ceb2311004eb80f96061a3e335d6849e3e5a8ea7f832f48a2edd97f3a863a3a9", - "apps/desktop/src/updates/updateMachine.ts::reduceDesktopUpdateStateOnDownloadProgress": "143737c58f5370ba9ddc97eecb602d6f83b08d78c148081a7053958e33c517aa", - "apps/desktop/src/updates/updateMachine.ts::reduceDesktopUpdateStateOnDownloadStart": "0b7be190ce5f92fa76856f33f20cdca2d78a0310c92bb853ccd7bc24e3062079", - "apps/desktop/src/updates/updateMachine.ts::reduceDesktopUpdateStateOnInstallFailure": "875fcd0ddc21569547ffa73cfb30ad99607f5937ec70df48d7ec7fa339257345", - "apps/desktop/src/updates/updateMachine.ts::reduceDesktopUpdateStateOnNoUpdate": "3d184fa47bbdd55b06b017337ee58761967701c7d5312f128fb0675846fb2661", - "apps/desktop/src/updates/updateMachine.ts::reduceDesktopUpdateStateOnUpdateAvailable": "b9b130acfbea8bb77e8764fc8c789a75c599245977fd2ac12bbe8d3c7b780c65", - "apps/desktop/src/window/DesktopWindow.ts::DesktopWindowDevServerUrlMissingError.message": "914e99fb099df9b6317655c3bed56b1406361e9a23ba9f36476b3ac73d23391d", - "apps/marketing/src/lib/releases.ts::fetchLatestRelease": "8c484e2a3ac795bf3e0a59472365ef4dbc0740d46a3fe76a61f0950c2b897c90", - "apps/server/integration/OrchestrationEngineHarness.integration.ts::gitRefExists": "34dbd2fcae50e113b9ec2a7d7b1fc046ac39568149eca440d461fbf1a9655df7", - "apps/server/integration/OrchestrationEngineHarness.integration.ts::gitShowFileAtRef": "cda872f966e9d5035d95e500d7c6dd9c8c78500718df0cfdfd213ece7c41032f", - "apps/server/integration/OrchestrationEngineHarness.integration.ts::makeOrchestrationIntegrationHarness": "3d52fa1e6fb92008d0374884a74a679e784f2405ef2202f54d568fdb99a11bde", - "apps/server/integration/OrchestrationEngineHarness.integration.ts::waitForDomainEvent": "bdea7489381e9ae5f6a2485368c5acad985d70e8d74250232a2b2f8f3ae198d6", - "apps/server/integration/OrchestrationEngineHarness.integration.ts::waitForPendingApproval": "1f639464bec0589cd4e91012a4326495d0941a90f729d96ce2f4d1ccce2e00c4", - "apps/server/integration/OrchestrationEngineHarness.integration.ts::waitForThread": "dc04a80b1f138cc523817ca8ba213c40eeea05154e0f494ea161f6222d9dafe1", - "apps/server/integration/TestProviderAdapter.integration.ts::emit": "f7973c0c390cd286d07ea9adac0b3c43ef7cb2463a3928f66da5f407bb6575c9", - "apps/server/integration/TestProviderAdapter.integration.ts::getApprovalResponses": "0bdd25aeb61f089fce373a8c47962f998a3a3813f4263a636fe7c948895bc6c4", - "apps/server/integration/TestProviderAdapter.integration.ts::getInterruptCalls": "57601613d1d3223171315562115a61d0b05a1bd41ed5aeabcbc2a490333997f1", - "apps/server/integration/TestProviderAdapter.integration.ts::getRollbackCalls": "99117dbeb3b72c01504db19f4ca48990f80bd210769c4b439ad0eb813de20d36", - "apps/server/integration/TestProviderAdapter.integration.ts::getStartCount": "563e0b497e2f501b3465894f6853fd91398487433c3f1af76d85cc2c9a716617", - "apps/server/integration/TestProviderAdapter.integration.ts::hasSession": "572747d1f80414adeea209cee77fdad4be1bfb5cf0ca20196b12df25ce512dab", - "apps/server/integration/TestProviderAdapter.integration.ts::interruptTurn": "30847835c4df70000280b6b6415dad031d2fe16a6cbd86083e9e644531f94fc3", - "apps/server/integration/TestProviderAdapter.integration.ts::listActiveSessionIds": "b7689cdfd18b76deaf7e9b75ff251e492cf23825d35d1c2901727cd309b74179", - "apps/server/integration/TestProviderAdapter.integration.ts::listSessions": "5d6af4140013a75ed4bf09d25e39d0e6610d8ed17b61951149b9e1cdc654ced5", - "apps/server/integration/TestProviderAdapter.integration.ts::makeTestProviderAdapterHarness": "21168ce8073e0ba9cfd7afd940824c3b4df6d714a23b6b24a5115712aa6618b6", - "apps/server/integration/TestProviderAdapter.integration.ts::queueTurnResponse": "cdadcf0dae5574a712085cd9324925c77023819ad506b547df70268c77298610", - "apps/server/integration/TestProviderAdapter.integration.ts::queueTurnResponseForNextSession": "c381415ac55be4d799e4a030ebfa1f377e2ccd51dcdb32e06b694c004597c486", - "apps/server/integration/TestProviderAdapter.integration.ts::readThread": "64d40ec1fd96cc2cd65ac22a7292ffcc5b4d3133b1e282130331a2f44ff92cc5", - "apps/server/integration/TestProviderAdapter.integration.ts::respondToRequest": "7765e1748abbd9c726fe61162895f392e00a73ae3f00d69be6b96d6f54575925", - "apps/server/integration/TestProviderAdapter.integration.ts::respondToUserInput": "3a4fc0425c9c0d831916301493d0ef33988fe48824fe4d2da72b00c7690ff241", - "apps/server/integration/TestProviderAdapter.integration.ts::rollbackThread": "c942a0abf5f1770bbc0c38eb36bd40c5b8fda5addd91129e373bc9458971a259", - "apps/server/integration/TestProviderAdapter.integration.ts::sendTurn": "bf98c2c0b0f82defc394e5e82033a72630cc17de6b5b58876ad6e429b878da32", - "apps/server/integration/TestProviderAdapter.integration.ts::startSession": "47ad11b1d48afb7fd458967243b599aa7d52d3c4478795c6b5a08a3750806e09", - "apps/server/integration/TestProviderAdapter.integration.ts::stopAll": "ffa62a03873d55ba2ca345d61164b40380e539a49a98c262911fb933c3fb4bd2", - "apps/server/integration/TestProviderAdapter.integration.ts::stopSession": "00bab8202718f9b2c0bfa9b4f16c8e67e486acce56cdec7ca2339840ba3ddeee", - "apps/server/scripts/cursor-acp-model-mismatch-probe.ts::JsonRpcChild.close": "4a224ff0dd0f2b238a2988ac91da72fee9263d13390824c1e8b40cf69d29edc6", - "apps/server/scripts/cursor-acp-model-mismatch-probe.ts::JsonRpcChild.constructor": "19ea870d8c2149633a73c4fc3223043cd4c2dda905d5017e10a2d6b29180cccb", - "apps/server/scripts/cursor-acp-model-mismatch-probe.ts::JsonRpcChild.handleStdoutLine": "f48f1f356b7e5ef7f44de0facab1981112010ef3f65adfe5c6749799fa6fb692", - "apps/server/scripts/cursor-acp-model-mismatch-probe.ts::JsonRpcChild.notify": "6e5eab33de7ff7075e068fadf1a5f8467541e2bd540c3a1b8b791cf75e77b10d", - "apps/server/scripts/cursor-acp-model-mismatch-probe.ts::JsonRpcChild.request": "e5e6d89c520715c42835e8fefdc55cc860602eb15342e59a1cc0464cf371b1af", - "apps/server/scripts/cursor-acp-model-mismatch-probe.ts::JsonRpcChild.respond": "7a75735057aca05b8c5d9839590b5db56246584d2dbbc2e96b5dc914bbcb8c94", - "apps/server/scripts/cursor-acp-model-mismatch-probe.ts::JsonRpcChild.respondError": "77578aa7666a72095a22960c48ccebe9f8b98eefe3b562649155ca6926c04149", - "apps/server/scripts/cursor-acp-model-mismatch-probe.ts::JsonRpcChild.write": "b8cf32e888dae23de183242585525b472c982b2fcd9d3c0414c4a42eb74d67c3", - "apps/server/src/atomicWrite.ts::writeFileStringAtomically": "ece04ddaadd66c3ecf20f06c7b8f1cd742a0383ea8fe7bcce7778203db309158", - "apps/server/src/attachmentPaths.ts::normalizeAttachmentRelativePath": "9a6399bd5640c82a8550c034a7e90ccd9c3d39f38d4121bab15f9376b0624825", - "apps/server/src/attachmentPaths.ts::resolveAttachmentRelativePath": "6ff33a7f9a2bdc81ce43c183ef84fd3e93e9f325329d54cda74228ef8d689631", - "apps/server/src/attachmentStore.ts::attachmentRelativePath": "ce1ffa4d98fc7f6c2e9ac600a18e8fe7d4d60a80e45e744c4f0871fb81be8f11", - "apps/server/src/attachmentStore.ts::createAttachmentId": "70bd6d43d4d39649c554233e6d04d73b842fe734848c15ceb8a6a6072390a8bd", - "apps/server/src/attachmentStore.ts::parseAttachmentIdFromRelativePath": "237aea60a5d79bdb02478884556260439288e42ec73cdcfc7bcf54ea22c599eb", - "apps/server/src/attachmentStore.ts::parseThreadSegmentFromAttachmentId": "f49abf55b51232415c1bdd9bbc691b354f23df205e9b69828104e930ddcde5dd", - "apps/server/src/attachmentStore.ts::resolveAttachmentPath": "d634ee375977b530ab080dd1205f9773edf038cdf9076e537ed955e81f235cb7", - "apps/server/src/attachmentStore.ts::resolveAttachmentPathById": "607be55b141ba24fdd934b44c8f3b3dab58739d7de4edf7fa70f132cff6af68e", - "apps/server/src/attachmentStore.ts::toSafeThreadAttachmentSegment": "6b3b62c18fa661c9f839e936571a2d5340247c95c8e3ef550f76241b447b6110", - "apps/server/src/auth/Layers/AuthControlPlane.ts::createPairingLink": "2c329da2ad6f4f1f9a0f89a01fcbb6dec6b26006c2c9de723a533e68b0642e93", - "apps/server/src/auth/Layers/AuthControlPlane.ts::issueSession": "7cd7209ff584cf0684dad5dad509c49b71b842aa03ec66fa066eb56edea2bdbe", - "apps/server/src/auth/Layers/AuthControlPlane.ts::listPairingLinks": "b04a27b69398eec5af72d738823a6ae96a48a1e952d0154bf039b626dde301b2", - "apps/server/src/auth/Layers/AuthControlPlane.ts::listSessions": "5d6af4140013a75ed4bf09d25e39d0e6610d8ed17b61951149b9e1cdc654ced5", - "apps/server/src/auth/Layers/AuthControlPlane.ts::revokeOtherSessionsExcept": "a1617cf35dc588cb0a1872a5889dbf6fdfc5ffdb436acc630487c7280fabc698", - "apps/server/src/auth/Layers/AuthControlPlane.ts::revokePairingLink": "ea76887928349df6688d22ab788b2f20d403c1b8d4ee0fd6e4329dbae8800fe9", - "apps/server/src/auth/Layers/AuthControlPlane.ts::revokeSession": "24d86efbc0c086cb83863d8142850b309bc6c5b74158ad009cf15845bc6631f2", - "apps/server/src/auth/Layers/BootstrapCredentialService.ts::consume": "e07f48998e4ed8f7b4dea56969fa2a8b792ef952e1d384f6c58c19f677f887b1", - "apps/server/src/auth/Layers/BootstrapCredentialService.ts::emitRemoved": "30799f3d0c8f9ff1d414ee26b8fe4ac428fb66e880f5f9d43fd06a36528fca57", - "apps/server/src/auth/Layers/BootstrapCredentialService.ts::emitUpsert": "5a7874a98c16f083d0c6b191a396b01b7049d92f81a904dd73c1f02619e18541", - "apps/server/src/auth/Layers/BootstrapCredentialService.ts::internalBootstrapCredentialError": "1bcb4597ddec777514976400b40059979057a72f2b71ff0ad301516a9eea32b2", - "apps/server/src/auth/Layers/BootstrapCredentialService.ts::invalidBootstrapCredentialError": "858c4f5aaa3238957de4156fb1feeb05412c6f981ea1737f9ce9ff609edd11c7", - "apps/server/src/auth/Layers/BootstrapCredentialService.ts::issueOneTimeToken": "6d667813d7c73e40602b5f717d1e174b9749e054d154670da0054f61b8fa8f80", - "apps/server/src/auth/Layers/BootstrapCredentialService.ts::listActive": "e266e211a2b89c426c37f231dba6434a80e94afd191dae9f2a43712ee93c33b6", - "apps/server/src/auth/Layers/BootstrapCredentialService.ts::revoke": "e3002abea810d2ac59f21875ea1208acef16184acb791d3a71e5d5095c35cf49", - "apps/server/src/auth/Layers/BootstrapCredentialService.ts::seedGrant": "ea57a2c94547725e430289c61c1753ddd9334c2c033dcc035a1c64e0325f1eac", - "apps/server/src/auth/Layers/BootstrapCredentialService.ts::toBootstrapCredentialError": "46ccebeb15b6948ba5a3257831a24b4eb53640000ac6a3e847377f028b8ed5c8", - "apps/server/src/auth/Layers/ServerAuth.ts::authenticateRequest": "6853bd83f7247164810502cb7edfed29f1959a3dcc1bfccdd1dc6c7549e3ce9f", - "apps/server/src/auth/Layers/ServerAuth.ts::authenticateToken": "f81a8e7db0b3cc84afa95cee13ed687d7144a39bea6cc5a5e4f52d612ea29706", - "apps/server/src/auth/Layers/ServerAuth.ts::authenticateWebSocketUpgrade": "54b7cd36422dac84b2725b7228407522c2a29073d63129b4d0598b0f9b7c728f", - "apps/server/src/auth/Layers/ServerAuth.ts::exchangeBootstrapCredential": "e271dc7d660d7c0cb27c512b082f7244cac3ebc3eec3af5d831a70abb9fa2068", - "apps/server/src/auth/Layers/ServerAuth.ts::exchangeBootstrapCredentialForBearerSession": "428959bdde613d3b013c8c84462173c4bd3eee04ecebba20ebb944ef93f4dccb", - "apps/server/src/auth/Layers/ServerAuth.ts::getSessionState": "15957dd15c401068685b7ba341f0ef5c8fc9731b89b231aafaa140905172fe43", - "apps/server/src/auth/Layers/ServerAuth.ts::issuePairingCredential": "3b6405427664df6b24aa54042ae787529255939fbb2b0db38c03a107403c47dc", - "apps/server/src/auth/Layers/ServerAuth.ts::issueStartupPairingUrl": "8248bc348155f3b2ee1ea4b8d8d8de63313d5b4dd15b102677bd76e8ee8e96e9", - "apps/server/src/auth/Layers/ServerAuth.ts::issueWebSocketToken": "d3a3e1056f24f3faf64f070dcc225a630016fa8751f536d0a194ca7f73a2ae13", - "apps/server/src/auth/Layers/ServerAuth.ts::listClientSessions": "4ed0dc4ee29b904f38deadacfa21f8d060b9ffab6bd86daa4ff23eeb140a0318", - "apps/server/src/auth/Layers/ServerAuth.ts::listPairingLinks": "280119e782554696e96e3680d44fb6523f4d3afbae3250de1ecf9dae8207eee0", - "apps/server/src/auth/Layers/ServerAuth.ts::revokeClientSession": "74a57c039dec226c83a9b5e70d26c821fc433bd56d7e0729fa388bf5c9966aab", - "apps/server/src/auth/Layers/ServerAuth.ts::revokeOtherClientSessions": "38a673b545d42e2d9259f38d34c2ffbe65923ea88d81b5e27747cdf9617ab20b", - "apps/server/src/auth/Layers/ServerAuth.ts::revokePairingLink": "ea76887928349df6688d22ab788b2f20d403c1b8d4ee0fd6e4329dbae8800fe9", - "apps/server/src/auth/Layers/ServerAuth.ts::toBootstrapExchangeAuthError": "2bc73beb0ea45d6b1928f4719cb000318219d8e2b0c5985dc43dba377a8ad6db", - "apps/server/src/auth/Layers/ServerSecretStore.ts::create": "e641d231fd317678cb6ca291447233d3f92a2c46c9f42374b331ad1f78d1d461", - "apps/server/src/auth/Layers/ServerSecretStore.ts::get": "4d0895f1f9042dde637eabbe212910095057e822ac3f554cda1d8857d618ba7e", - "apps/server/src/auth/Layers/ServerSecretStore.ts::getOrCreateRandom": "2e32453e50b8561c187a712c7c1f84db1a888840d41b829c731c6cd819a26848", - "apps/server/src/auth/Layers/ServerSecretStore.ts::isPlatformError": "ae737f21042f3358422e98a1c8507dafe033cf17db4de0667b6d297ce0bd29b1", - "apps/server/src/auth/Layers/ServerSecretStore.ts::remove": "494288f8e486716191630d755409fdb1eb499917416376fc30799bf482a88be4", - "apps/server/src/auth/Layers/ServerSecretStore.ts::resolveSecretPath": "00398d2875a4af679b8f0ffb59d3161df10edbf74055bbddf7ac7365b3df310b", - "apps/server/src/auth/Layers/ServerSecretStore.ts::set": "c3fd4b6c432b4845a645e50ac24ff0f029e284e16a0dd11c758cf412ae0743f7", - "apps/server/src/auth/Layers/SessionCredentialService.ts::emitRemoved": "fc3e4d8334ca9def4c8a68eea9709ec67c47d3d3ea315507a7a49f2be21b3d2b", - "apps/server/src/auth/Layers/SessionCredentialService.ts::emitUpsert": "8041e6bc51c1f67a57b8755aee2ea82bdff4981df801b1bf1f870b576ea406b1", - "apps/server/src/auth/Layers/SessionCredentialService.ts::issue": "96c9f418f091130a36a7d233cf9b023806fb9136e402478fe74600689c16b33d", - "apps/server/src/auth/Layers/SessionCredentialService.ts::issueWebSocketToken": "9c2cce192bb7fe1f138d41fe679d0e97f1fe3ee48e71179c048addd66afbf109", - "apps/server/src/auth/Layers/SessionCredentialService.ts::listActive": "e266e211a2b89c426c37f231dba6434a80e94afd191dae9f2a43712ee93c33b6", - "apps/server/src/auth/Layers/SessionCredentialService.ts::loadActiveSession": "1af095e051de55aa7aced761025e937c745788475be2c298e59c7af2dad3b3a8", - "apps/server/src/auth/Layers/SessionCredentialService.ts::markConnected": "8710a85e53c7ec4ec9bbb1bb2c20f772930bfb5eae7dd4534bf73139124ffc36", - "apps/server/src/auth/Layers/SessionCredentialService.ts::markDisconnected": "9ab5268dbdfce627100fbb46f81bc76373e3d5663d7c17031117205908a9facc", - "apps/server/src/auth/Layers/SessionCredentialService.ts::revoke": "32b2912a5a2c9b93005530c2f5d33caaf3d23b31bb9fd40eb694b2fa1a5d24a2", - "apps/server/src/auth/Layers/SessionCredentialService.ts::revokeAllExcept": "74ddc88ea6ff8e4685ef92680f14c1d9dc5174f757d51239d3cf83461d6a8c61", - "apps/server/src/auth/Layers/SessionCredentialService.ts::toSessionCredentialError": "2fee80c6a833c1ac54d4f8e97d8f6128945c5c9eaaf3a00842fe387a3289c600", - "apps/server/src/auth/Layers/SessionCredentialService.ts::verify": "f9ee372026236804b2100c1f415bcc68ffcfa4ed81c9406d971071d66557f42d", - "apps/server/src/auth/Layers/SessionCredentialService.ts::verifyWebSocketToken": "3e1a355f7431c34de6924cf976915ef2a04db54ae9f9bf59017b2c4feee1b208", - "apps/server/src/auth/http.ts::respondToAuthError": "5844b0cfac7820ed67a82a593e34d7cf813e26ba6bac5ee35b3da63d4542c499", - "apps/server/src/auth/utils.ts::base64UrlDecodeUtf8": "c1e7a32cfc3f638728e7b972d030112c929685e3e8bea055d05fcab2a6d99145", - "apps/server/src/auth/utils.ts::base64UrlEncode": "68df2972a6135bb77a5114424688b368b27a0b08fe3bd658aa10573261ce6369", - "apps/server/src/auth/utils.ts::deriveAuthClientMetadata": "1ca53416d48e401def3d5b8df2d4e65b244acbb244831a4108b4d42a233ec11a", - "apps/server/src/auth/utils.ts::resolveSessionCookieName": "70db822bfa599ecc3892863c56ccaf8a086b4c2dadb859f162580b2d19832464", - "apps/server/src/auth/utils.ts::signPayload": "4180619ce705133556f5dc78164c4a70a67840ad26219f6e8d940a723b42a4e6", - "apps/server/src/auth/utils.ts::timingSafeEqualBase64Url": "b6b65821af0904f83f4ad80e0f3118f54c33d0d059b8572309ba41d67d08b870", - "apps/server/src/bootstrap.test.ts::__file_sha256__": "fa7df34499dee0edba0fbeb11c9ac56e6679ac81cdb1cf42878629306393c8dd", - "apps/server/src/bootstrap.ts::cleanup": "c476fdb0821fb5d2fa924a11b3f630358b9498f1067b692a7eb3ed6265e6291d", - "apps/server/src/bootstrap.ts::handleClose": "7e1497e73a673314a9d49e7264f604c968d58ad438a3ac944381875b70a5f98a", - "apps/server/src/bootstrap.ts::handleError": "62cf3c72930b424e4c6d4e560cd52d31ea22836f6c0cd43532719aa150649fb2", - "apps/server/src/bootstrap.ts::handleLine": "2c742dd82c26d7fd2b3c8f1bf6fea1d5c42cd4572089a5c6cab6f33cff2f8c14", - "apps/server/src/bootstrap.ts::resolveFdPath": "306dfcbbdc47da43bdd9405a0b5c8b34619deb2d9164ea9fee0e931794f88aef", - "apps/server/src/checkpointing/Diffs.ts::parseTurnDiffFilesFromUnifiedDiff": "7f42c63db1a4ac3679c71b16f199c57655e32be076cfaf9517a485e285ec1874", - "apps/server/src/checkpointing/Errors.ts::CheckpointInvariantError.message": "9a2253581ed30c8ef6da284344438827932bbeceec7d0b5d9350de6d1d865086", - "apps/server/src/checkpointing/Errors.ts::CheckpointUnavailableError.message": "7e40c4dd73dc06c3031dfce0534d258c58d36429ad820a72e08cb9b49e8c05c5", - "apps/server/src/checkpointing/Utils.ts::checkpointRefForThreadTurn": "8126c4dca25a7cbe9fd5b414cb1aa7e35728dbd7ad8207067912b245458d6044", - "apps/server/src/checkpointing/Utils.ts::legacyCheckpointRefAlias": "f6a4c0ac0d96fa2c5e77bb5733e55082dea18c81a61cf04b3d66949ff1ee9cde", - "apps/server/src/checkpointing/Utils.ts::resolveThreadWorkspaceCwd": "24b7e683704cd9c0c085a4671f6645f66485ef2abb7c33cbd67198f3387c24eb", - "apps/server/src/cli/config.ts::resolveCliAuthConfig": "ab5b538f8df6610050c917690c57293c630ed1993d744059cbdb0c185c13929a", - "apps/server/src/cli/config.ts::resolveServerConfig": "b8481026b88137c6cc2a334dfb9ccca7f831dc5288128a6162fb85c73d47493a", - "apps/server/src/cli/server.ts::runServerCommand": "3d117d811464223343d7cb7bac49d059c2c3a459a6697a1d7d003010c65b1dcf", - "apps/server/src/cliAuthFormat.ts::formatIssuedPairingCredential": "efb8fb21df0d1083c4da9bb4e29f6f3a9a7e9e4110f78009901ea2e88f01ac79", - "apps/server/src/cliAuthFormat.ts::formatIssuedSession": "7b96620881af68f43d68d47d66663d5a02af5871988fa5ca38e57b9bf84c99b7", - "apps/server/src/cliAuthFormat.ts::formatPairingCredentialList": "65864c2d5fe884d4e106bf1a302f5ad3d22d12282cf6e91a585e4f8449522586", - "apps/server/src/cliAuthFormat.ts::formatSessionList": "cf28ccda67d7e62083c37d322ce94f63b30dd8c450a84123c1a0e6d106e3dda7", - "apps/server/src/diagnostics/ProcessDiagnostics.ts::aggregateProcessDiagnostics": "b31cbfb536a9d68136c9a960519a0166f4c39815b9704355c466464cf89940bd", - "apps/server/src/diagnostics/ProcessDiagnostics.ts::buildDescendantEntries": "f129b9ffd1010ffdefda45db4a046a74e7f354e42a6bef7e6758107db5a892a4", - "apps/server/src/diagnostics/ProcessDiagnostics.ts::isDiagnosticsQueryProcess": "4ba0512808aa86f8623ce8fa706aedc6b5d962859222c03db655519cc5d0e7a6", - "apps/server/src/diagnostics/ProcessDiagnostics.ts::parsePosixProcessRows": "1069399339c764db2ee476a0240d21942a0108539f42e87143482fbbf36e1ec7", - "apps/server/src/diagnostics/ProcessDiagnostics.ts::readProcessRows": "9099bbd65232bccff388e62f3c716888ef519d838e864322197a7f33d240cfcc", - "apps/server/src/diagnostics/ProcessResourceMonitor.ts::aggregateProcessResourceHistory": "a652bc97e8fdb53cae05b29b6253d6b85b05e39ef0fe27daf4b34e7e355dacd5", - "apps/server/src/diagnostics/ProcessResourceMonitor.ts::collectMonitoredSamples": "79adc7a53673db1f24f4f7a3b23215f674e7acae0104da10e9ac3e800e478f15", - "apps/server/src/diagnostics/ProcessResourceMonitor.ts::readHistory": "1531f4c0fe0639d31a81a303ac7e7d93f5384bd0c3e2ebd20f9300bbfb58967e", - "apps/server/src/diagnostics/TraceDiagnostics.ts::aggregateTraceDiagnostics": "70113a37a4218ac4c00f9dd63d5b9b7afd97ac4b2831f259d647695ceceff27f", - "apps/server/src/diagnostics/TraceDiagnostics.ts::readTraceDiagnostics": "2cd41a73aeb0ccfe3744ef5bc19fff663cd181f5b8a8865f150e53764cb4bfc4", - "apps/server/src/environment/Layers/ServerEnvironment.ts::persistEnvironmentId": "425c0f6b4650f46422b409bb7c745417d3081f1c8ffcad6d4df9feb05ad1903d", - "apps/server/src/git/GitManager.ts::canonicalizeExistingPath": "42f588bc14d78ef6e9d70e48d490419a2e5dabb17a710f03d99043ba33fe9dc7", - "apps/server/src/git/GitManager.ts::configurePullRequestHeadUpstream": "665843a3571fd082e197b8eeda1c366370b0fe5ae18e7dd4526b14eb15275dc2", - "apps/server/src/git/GitManager.ts::createProgressEmitter": "e64373bc7c0c870ed84c60de652c5ca32f71c70353c4e654d1a7451635c13f96", - "apps/server/src/git/GitManager.ts::emit": "e6a522828311a2cd4fca7cb5f9b504f1e4710af01510ff3314576d42d5bcf7fc", - "apps/server/src/git/GitManager.ts::invalidateLocalStatusResultCache": "31dd255b1e213ab413b2a6d1c81686a80130214cfbb268ac6d00c220c89efbae", - "apps/server/src/git/GitManager.ts::invalidateRemoteStatusResultCache": "8769450b246139f796622854b775fcde19015c8eb90d48d431e0979fd6ba8af3", - "apps/server/src/git/GitManager.ts::materializePullRequestHeadBranch": "cd20fbf0c228bab7e7ba3e215be8b6a88dc74eae117bb61ebc620fb0f50126c1", - "apps/server/src/git/GitManager.ts::maybeRunSetupScript": "5e9b735695e99023c651765c758963c647cd85807add6babb0a604fcb73b2a47", - "apps/server/src/git/GitManager.ts::readConfigValueNullable": "d98d6c81b75abe7eae7c572a22adbfd6f4cef505b379593ce6b68a975606f919", - "apps/server/src/git/GitManager.ts::sourceControlProvider": "9e8f485fb90510e6af05ded53471df6ca6f1b7d1edacd9d05047de547b9babec", - "apps/server/src/git/GitWorkflowService.ts::routeGitManager": "f02912fbc803f3f2231fc40fc8cd9c56093bf9e1df01746ea25b679909c3963b", - "apps/server/src/git/Utils.ts::isGitRepository": "f12199956b50cbe975d46e216e05ff9e619d9719006d56cf803b482770170dbb", - "apps/server/src/git/remoteRefs.ts::extractBranchNameFromRemoteRef": "1fd99aa83748e2b7504e4ec07e3e635a09dbd5a67f6ea225d83c35132a0054aa", - "apps/server/src/git/remoteRefs.ts::parseRemoteNames": "59ec3172d0a205296df72bed439839e51426e69e8191414ea99a7ad24f2c39d5", - "apps/server/src/git/remoteRefs.ts::parseRemoteNamesInGitOrder": "8b3a71c020794453eb201f4cfce3166ed1d0f183989dfb42e8f58160ee4ddaac", - "apps/server/src/git/remoteRefs.ts::parseRemoteRefWithRemoteNames": "0e9beba5f3b99e3bde3f349d9c1a126d46d8572e659bc594f365b22ace214b35", - "apps/server/src/http.ts::isLoopbackHostname": "a4568c2b53b204304f20a060cabbeb216cec1288f3385014de801ee766f47291", - "apps/server/src/http.ts::isWithinStaticRoot": "97f073239b7edb7d6e6df16ec3ac895887eab0546eb33c64aaf4dfe06858ca80", - "apps/server/src/http.ts::resolveDevRedirectUrl": "805ef1758c96c94c43cea80ff4bd184f3744dccd631d3f383d3926d57eefb67b", - "apps/server/src/imageMime.ts::inferImageExtension": "c4a19c7501162291492da1277a4973afee682b821f3e0bfbb0791f788687f7e5", - "apps/server/src/imageMime.ts::parseBase64DataUrl": "3b5c762ecaa3ed2937dbbdae9ba03369db8690457aa9c4551ef29821594c0261", - "apps/server/src/observability/Attributes.ts::compactMetricAttributes": "471b39c8fb40ff42eb713a85040274982dea3d6bff4180e0c209e1875d26ebb7", - "apps/server/src/observability/Attributes.ts::normalizeModelMetricLabel": "cadc14937158e87e5c1e5bc2c94c622384beaa7c779d6301b0a98c028052177a", - "apps/server/src/observability/Attributes.ts::outcomeFromExit": "21384a659c03e3b1275004c6949cb1537dff48288f38aff0bbccfc1be1acf4d7", - "apps/server/src/observability/Metrics.ts::increment": "9fd3ae91302a140259894c2829f0947b5ed526a72894d91d220f1dde46eebb26", - "apps/server/src/observability/Metrics.ts::metricAttributes": "dd17b21839e5154346123620b4cece009e5e13aa87dbea9b91fff9f912731176", - "apps/server/src/observability/Metrics.ts::providerMetricAttributes": "7a835195264f9e25a6721db47d0e2514e3aaf7bb34acde5ff643e9b33015874a", - "apps/server/src/observability/Metrics.ts::providerTurnMetricAttributes": "797c8e6500efd41cd13de2e998901374c50cd66ae60999f5a57b1564f42185b0", - "apps/server/src/observability/RpcInstrumentation.ts::observeRpcEffect": "9c59a5506b652a43fe87254d8b953c8978be9045f01a92d02326b635254976de", - "apps/server/src/observability/RpcInstrumentation.ts::observeRpcStream": "0be6400a2c54ccaff3c3aa2a64fbc4a40b156c3dc608deb416f40c455646c051", - "apps/server/src/observability/RpcInstrumentation.ts::observeRpcStreamEffect": "475ebf15bcde0913298aee90e0eed6851dceb63a6c220194399914ad4e66276d", - "apps/server/src/orchestration/Errors.ts::OrchestrationCommandDecodeError.message": "717e3d8f7ee4bb21ba6ef5c97669815fead2670e59468d09efdac6fb3b89303e", - "apps/server/src/orchestration/Errors.ts::OrchestrationCommandInvariantError.message": "3ecb9194d40a2619b2dfb0a0b34b9a555282dfdb83d670a064941c90ca236589", - "apps/server/src/orchestration/Errors.ts::OrchestrationCommandJsonParseError.message": "362042ec1e45a87ac52db53b1e11eb2c5a2acd1c802687b8c881651c617318d1", - "apps/server/src/orchestration/Errors.ts::OrchestrationCommandPreviouslyRejectedError.message": "bb871f7c55e99636bc3907cb9aca149db620f33ddf2229211234d5dc6c892957", - "apps/server/src/orchestration/Errors.ts::OrchestrationListenerCallbackError.message": "dba0f796405741030f5580d48aeaec4acab2af6116850c4035ba71d5d24e4fd9", - "apps/server/src/orchestration/Errors.ts::OrchestrationProjectorDecodeError.message": "fd479de0ef90fcd657d03c844ca62bf915788cb0bf38ce6764b900d17311e951", - "apps/server/src/orchestration/Errors.ts::toListenerCallbackError": "61d0a2dcf878acf025133fe589edba6db0ac5706bf193b42d464054c875ddae5", - "apps/server/src/orchestration/Errors.ts::toOrchestrationCommandDecodeError": "01fcd0e07ae3757bc5119c3b774db456c740d5fdd7635e886ce9afc19f16e24f", - "apps/server/src/orchestration/Errors.ts::toOrchestrationJsonParseError": "4ea34a271b90644fa4f9bb058544fca0927b4eec48c30f030d2098a531cf1ee9", - "apps/server/src/orchestration/Errors.ts::toProjectorDecodeError": "08a266533742c52a123f0140c5ffc9c6fcac10ef4e4f641af1bf4c0aff891d8f", - "apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts::__file_sha256__": "c51f103c59744bf2421461df7d386984918be024055c13e8f96da5be8074177c", - "apps/server/src/orchestration/Layers/ProviderCommandReactor.ts::providerErrorLabel": "48ea9e9e48c3e693ef74b7d74c57a34b35e973bc1202abe13b4213c8cd0b7ce3", - "apps/server/src/orchestration/Layers/ProviderCommandReactor.ts::providerErrorLabelFromInstanceHint": "31d734286f691a480ac410aeabb30c367e416baee8d388260974791907150cf8", - "apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts::logCleanupCauseUnlessInterrupted": "161098e44affd7b2af038103a4b432b30e4ab8b45a8847a6eef0c6ab090a412c", - "apps/server/src/orchestration/Normalizer.ts::normalizeDispatchCommand": "0fbfc3377d72aca58a2f2ee14014e67a5a5c4133118ec969d66b4ef6cb8e969e", - "apps/server/src/orchestration/Normalizer.ts::normalizeProjectWorkspaceRoot": "6d4b671c919bc52c146f7c7f0c4d185b9a013a82020583d3ea8c35287495d0c3", - "apps/server/src/orchestration/Normalizer.ts::normalizeProjectWorkspaceRootForCreate": "dc24e3632e80bb4f722b9de2fd415df5e5b27fa8a0c38baeeb7c9400c25f6dd2", - "apps/server/src/orchestration/commandInvariants.ts::findProjectById": "a591639b945d18c2f8affd4cc5c25fbf257fb05dbdb2213cf4661d9684042c07", - "apps/server/src/orchestration/commandInvariants.ts::findThreadById": "77f5f3238688368fb95a0a9e5e635bb8ac383ef0c49753cd62b16e2fd51482e1", - "apps/server/src/orchestration/commandInvariants.ts::listThreadsByProjectId": "1ea51c66625f0dc5d302e21b067b3acdcdff9d362d453dd21442ca9fb6c379b3", - "apps/server/src/orchestration/commandInvariants.ts::requireNonNegativeInteger": "32a7e91a2c8683de10cf204be0997775e2698af322ed1bcb9f1ebeeb0c741e8f", - "apps/server/src/orchestration/commandInvariants.ts::requireProject": "4f38b936adfa6e072a8379e56826475b336f2c00825ef60a61b9a1704836c9b2", - "apps/server/src/orchestration/commandInvariants.ts::requireProjectAbsent": "8f2a9e2c0096da8c3c648c85acc880addd4835988e6fe4961fc4c44470dde6a8", - "apps/server/src/orchestration/commandInvariants.ts::requireThread": "8e7d181af70d3d36a430937dd51814f39d39f128658956d030a0cf18184d39c5", - "apps/server/src/orchestration/commandInvariants.ts::requireThreadAbsent": "26c490a76d927240cead6e08228ee81b06cb892bb8b93322815a13d1efceaf63", - "apps/server/src/orchestration/commandInvariants.ts::requireThreadArchived": "4194d7fc5b561e35422bcc06aaf3d34929e6b3ee4786a9caff0642e2b976164d", - "apps/server/src/orchestration/commandInvariants.ts::requireThreadNotArchived": "100faf45872a4f88c4d3bac7f02eaf844ad317b6de8db0b0a5742dbd9453dd5c", - "apps/server/src/orchestration/projector.ts::createEmptyReadModel": "5ebe067010b459e4f1e8c9a10390bd965bb3f94fdd9547129cb8132d066ac36d", - "apps/server/src/orchestration/projector.ts::projectEvent": "3fa12fad0718f2f7c44cd34158eca699bd99dd0c8c2e17ff16d036e6d6ef3256", - "apps/server/src/os-jank.ts::fixPath": "239699e845c80a2a073fac623d6d6e668c73e779c7f52f47659c92df4a956970", - "apps/server/src/pathExpansion.ts::expandHomePath": "13faa7d0fdc84038c5da184ab10523bcea5b595e7f2d5eab978608cf55213463", - "apps/server/src/persistence/Errors.ts::PersistenceDecodeError.message": "7743118ac39a964348a5abd6b0d1e632138bb5fa024cb53a36864e6d167865e3", - "apps/server/src/persistence/Errors.ts::PersistenceSqlError.message": "bacf0439682c6f8977350210cc767d3f31d4b1530c4acc557965c24fc3e3d0db", - "apps/server/src/persistence/Errors.ts::ProviderSessionRepositoryPersistenceError.message": "55df7f307010b068ddd70045b52145fbbbcd23246a5af26ac42e38d3bfcba72f", - "apps/server/src/persistence/Errors.ts::ProviderSessionRepositoryValidationError.message": "6be5701504004421952eb356aa858eb71f4a3303b0836b5a6b07ad478293eda3", - "apps/server/src/persistence/Errors.ts::isPersistenceError": "f783c33c8001f86dfca52d909fcedd0aa83b04a6e3b79279966da1e97ec0866a", - "apps/server/src/persistence/Errors.ts::toPersistenceDecodeCauseError": "81b38deccb4fa2a682b8ca625d19f80966db5087bc9a5707bbed3ec49f58f034", - "apps/server/src/persistence/Errors.ts::toPersistenceDecodeError": "6e980f60bdeb610e17f77c29f6f24bab01c78ee5e5c63bd28a0899c68dd6d081", - "apps/server/src/persistence/Errors.ts::toPersistenceSqlError": "ed8210845e30e7c3c6edbbcad2daed1c5f62de24ba54a9f8d311880fbf675562", - "apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts::__file_sha256__": "0add432e64992b0f8dac2cfe5564f034414813df93039c5a7d59890d5c874b0e", - "apps/server/src/persistence/Layers/ProjectionRepositories.test.ts::__file_sha256__": "dbff2ede8817aabcec3a5f4359e9fcdad206c51c58981a07fcaebb48789d873c", - "apps/server/src/persistence/Migrations.ts::makeMigrationLoader": "da51a06311ce63c5e05e480b96fcd5bfeba3891faec255c8dbec7fd2da7d274b", - "apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts::__file_sha256__": "ae117252b54518b7967df5e73f614eee407cf5bc90302aa8818975b559ea05e4", - "apps/server/src/persistence/Migrations/017_ProjectionThreadsArchivedAt.ts::__file_sha256__": "a3d9b050499f5fd7653e6cad4e3e8f0080585cb2d2df904ec4616d4e857b34c4", - "apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts::__file_sha256__": "cfe7ba51513d335c77aceca3e37467f4514963217493378d66fda352f01adccd", - "apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts::__file_sha256__": "9c6fc3c660652997ef063f93856d9582480bb860079ef63008e6792cf9778e71", - "apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts::__file_sha256__": "a01600daa87f23de131eb8ee9b966af4dfe4687f1b598534344368f88fedac70", - "apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts::__file_sha256__": "7a32e1ec254f96120510169fcd2f342576bcfedf79e349fb92980efbae782869", - "apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts::__file_sha256__": "d5077745207a5bb3ea8b939df59f356437e45d853793fc0d0ea99523918dd246", - "apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts::__file_sha256__": "fa5d1601f4ccd3718bd011f7d6d76b305e93523ae25776980296a4b91a779e53", - "apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts::__file_sha256__": "b30a492cccdbcb20fa0665ee486318f5183a399b9ac62ea9e8dd0c12de32ffc9", - "apps/server/src/persistence/Migrations/027_ProviderSessionRuntimeInstanceId.ts::__file_sha256__": "4a399e0130c795433e038880030a6b36822afc3aabf8b0cf873df06861576856", - "apps/server/src/persistence/Migrations/028_ProjectionThreadSessionInstanceId.ts::__file_sha256__": "d4affeb4813ccc8b9ee67a24aa26a8bb78074148fb4375704fd9388e00a221e8", - "apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts::__file_sha256__": "af8b732b86f1722d180dfaa09999b36ed38de1e095ae385d2a28f62c90bc7a3f", - "apps/server/src/persistence/NodeSqliteClient.test.ts::__file_sha256__": "b2f2515a974c3a7c98d70ae1845aa37e04d049f8487218008af38f8f41caf638", - "apps/server/src/persistence/NodeSqliteClient.ts::layer": "f09c52c232a0a40476bd0193a3eedfbf45b146b985f27f482fdf586e98ac3e6b", - "apps/server/src/persistence/NodeSqliteClient.ts::layerConfig": "eb7aa1ef99e19b397be6e07ace49b418be64deea0ec2c9fbb0aa4292af821152", - "apps/server/src/persistence/NodeSqliteClient.ts::layerMemory": "e2f33b654033b884025d0aa10613163a5cb001b7d899ad65ba06d010db9097e1", - "apps/server/src/process/externalLauncher.ts::resolveAvailableEditors": "f2e61a7b3233623e658830ec94fb118288d61e91dcb01a25812de457b990b076", - "apps/server/src/process/externalLauncher.ts::resolveBrowserLaunch": "36b9087f19ac4345b5b908c9a8c1b2562f0d7d5221c8ca2f867553f8c18a775d", - "apps/server/src/processRunner.ts::isWindowsCommandNotFound": "1c494dac6c7c8b1957e6055c5ac1c8d12d35a33ddd2eb2a39f4dfee95093f00f", - "apps/server/src/processRunner.ts::run": "38f934f1b5b9579996833c35e17cd64752226f8fd2c93bb8a547b2c9ba268b33", - "apps/server/src/project/Layers/ProjectFaviconResolver.ts::isPathWithinProject": "108328f83760431e6cac9be5f5ea345b19126acb7cfbba5318a5055242667938", - "apps/server/src/project/Layers/ProjectFaviconResolver.ts::resolveIconHref": "2e328389be457e23575fec01a6d4cbe49e6ab86d9af221c120b5151411577d96", - "apps/server/src/provider/Drivers/CodexHomeLayout.ts::CodexShadowHomeError.message": "b3a7d298f24ca0d40593441d57a9b622918b026aac29da874421afdb5816386d", - "apps/server/src/provider/Drivers/CodexHomeLayout.ts::codexContinuationIdentity": "26d818a72387f714ad72ae92a5ef5c7434c794c71c72206bb52dbc3add4a2d23", - "apps/server/src/provider/Errors.ts::ProviderAdapterProcessError.message": "4cdd1d09ab7a6ed1488684c5ec3d6c3065c5612db87f498c52907d91de62890e", - "apps/server/src/provider/Errors.ts::ProviderAdapterRequestError.message": "b3e52359a469518d1b6ce9bc98ca36d8409b2a7e99a56fd634a967ad3a6fa1a6", - "apps/server/src/provider/Errors.ts::ProviderAdapterSessionClosedError.message": "4277911282b33fd25b347a7d9f8b44169ad09a7a80c8aefedc2ca5612cb2170e", - "apps/server/src/provider/Errors.ts::ProviderAdapterSessionNotFoundError.message": "332f0796d54fcf65b04c6ba7d944c04a21b62d1007f1ee05df3055793c898aae", - "apps/server/src/provider/Errors.ts::ProviderAdapterValidationError.message": "6651ef5d2b647d1cf7acfbe8b4ba3687b142f1bbbeb1c2c317cd514fc2439a28", - "apps/server/src/provider/Errors.ts::ProviderDriverError.message": "a1b443914829aec7cac80355adc8b068d99ff25ae73c49318554cc974d8ac826", - "apps/server/src/provider/Errors.ts::ProviderInstanceNotFoundError.message": "e3288c432a4818b94493c4ec1e2dec836460b204805cbf9acb31048b0bc49e4b", - "apps/server/src/provider/Errors.ts::ProviderSessionDirectoryPersistenceError.message": "2c0c6e32a295db3c264923d7dfcdeebf19986d56447cccbe86089c3ae32a0f88", - "apps/server/src/provider/Errors.ts::ProviderSessionNotFoundError.message": "b4a9207fd279d985d20037552df043c1f026259d0b96b3dbfd9d335f41d7ac6f", - "apps/server/src/provider/Errors.ts::ProviderUnsupportedError.message": "377b24f866c1d764c237e4deba47b90528cd6819da4bb3b42f358b9df2fd7d2d", - "apps/server/src/provider/Errors.ts::ProviderValidationError.message": "12d76f1f04697e9e69a40ec6e5626618f43a3818c9476442943a4f023729858d", - "apps/server/src/provider/Layers/ClaudeAdapter.test.ts::FakeClaudeQuery.[Symbol.asyncIterator]": "f61f870ea93c8da4913437dc2a1fee752306348264364c08c34a3c69f1474a2a", - "apps/server/src/provider/Layers/ClaudeAdapter.test.ts::FakeClaudeQuery.emit": "7a2929c63c99eb22ebdbb44097c5e8c5cfec9c063de6f5d6edcb48a16807584e", - "apps/server/src/provider/Layers/ClaudeAdapter.test.ts::FakeClaudeQuery.fail": "b906865d2ed600e7da096b03a5f01513dcd958a8eed4b3bd9e758a83f97d218e", - "apps/server/src/provider/Layers/ClaudeAdapter.test.ts::FakeClaudeQuery.finish": "30d12b38496ae9c1c99555c2d1bd0909e96f496be0985ce67894e85785f5bf9f", - "apps/server/src/provider/Layers/ClaudeAdapter.ts::canUseTool": "34a3a1e40b711f539ee193cf527d0229b803aeccd3c89ae245415ad11919f26f", - "apps/server/src/provider/Layers/ClaudeAdapter.ts::hasSession": "572747d1f80414adeea209cee77fdad4be1bfb5cf0ca20196b12df25ce512dab", - "apps/server/src/provider/Layers/ClaudeAdapter.ts::listSessions": "5d6af4140013a75ed4bf09d25e39d0e6610d8ed17b61951149b9e1cdc654ced5", - "apps/server/src/provider/Layers/ClaudeAdapter.ts::makeEventStamp": "bb50e8951d2bd3b8bc6f90eeb0f9511af30887f59c20df1a31d69f0ad1de36ad", - "apps/server/src/provider/Layers/ClaudeAdapter.ts::offerRuntimeEvent": "ea74233d21f91edcb15cae275e5609d08d96009aea390c1f14620d0cb52db6e3", - "apps/server/src/provider/Layers/ClaudeAdapter.ts::onAbort": "63cb79f4281c6c8077d6658d099b878be873cd25659de582bee08c1ca60b90a8", - "apps/server/src/provider/Layers/ClaudeAdapter.ts::requireSession": "16d347933b30d069565ec6d545047da7a4122227fe5b2a7f6b00c3ded9111f5a", - "apps/server/src/provider/Layers/ClaudeAdapter.ts::runSdkStream": "061a1bf76bf239f317efdd32228c214339e07a569e05fa3e7cf93dbcfe91cfc6", - "apps/server/src/provider/Layers/ClaudeAdapter.ts::stopAll": "ffa62a03873d55ba2ca345d61164b40380e539a49a98c262911fb933c3fb4bd2", - "apps/server/src/provider/Layers/ClaudeProvider.ts::getClaudeModelCapabilities": "6c8c5f521121617cb6b2c34fb958f0ab3147b74b3f67c3f8c1a7fa6495e756b2", - "apps/server/src/provider/Layers/ClaudeProvider.ts::makePendingClaudeProvider": "8c5afb1967dd21a93b1ef1e32173757a16182703ada7baf9ef7ae49cb11c6994", - "apps/server/src/provider/Layers/ClaudeProvider.ts::normalizeClaudeCliEffort": "70a85eabf33897f345eb0cf477df27cd3316e1f913ff48024dbfd7a1dbc813a4", - "apps/server/src/provider/Layers/ClaudeProvider.ts::resolveClaudeApiModelId": "614dd742df991c0c95a1409782337bd0d0e6acc25855a7dddaff3f0f77d98b1f", - "apps/server/src/provider/Layers/ClaudeProvider.ts::resolveClaudeEffort": "5a8f39d24242c961bba4371bcf84c0900ee8e2eb072e755814ec2eaec6b7fee9", - "apps/server/src/provider/Layers/CodexAdapter.test.ts::FakeCodexRuntime.constructor": "519b9c9188fb8bb407ebe37a0049fc92068dee03324eedc7994d17cd0337020e", - "apps/server/src/provider/Layers/CodexAdapter.test.ts::FakeCodexRuntime.emit": "0353c17b74b6663e8c04cd47fd010dba08384bae58db7ce13dc1e25c061ee350", - "apps/server/src/provider/Layers/CodexAdapter.test.ts::FakeCodexRuntime.events": "a8ab9d38f7e721782fb7547d38b1c2ee858ce80164e0afa48866e3dc00a48d40", - "apps/server/src/provider/Layers/CodexAdapter.test.ts::FakeCodexRuntime.interruptTurn": "084b920c4c9d281cae04fb4592a08e4e555401fddd618b4ec8eeb9f2a1af27a9", - "apps/server/src/provider/Layers/CodexAdapter.test.ts::FakeCodexRuntime.respondToRequest": "ada717540d210de4a42d8be838dc4fef5db456197f786df5a95531cda6e123be", - "apps/server/src/provider/Layers/CodexAdapter.test.ts::FakeCodexRuntime.respondToUserInput": "d9f86d87b8e1e695e01f7d4ac595f6eace96add412443ce415abeab193a989a9", - "apps/server/src/provider/Layers/CodexAdapter.test.ts::FakeCodexRuntime.rollbackThread": "b9e89e45c628c2bf7dec2144d18203441e4b27a88aad8b6d066108544aa1de71", - "apps/server/src/provider/Layers/CodexAdapter.test.ts::FakeCodexRuntime.sendTurn": "e3d6484c3730fec4f672dedb6188e0023eea1d50f5ad0b75f690479d9081c636", - "apps/server/src/provider/Layers/CodexAdapter.test.ts::FakeCodexRuntime.start": "82a07f0b7f2383d8481a8b096002693f6a154b66865315d3b1c78af738f2eacd", - "apps/server/src/provider/Layers/CodexAdapter.ts::hasSession": "572747d1f80414adeea209cee77fdad4be1bfb5cf0ca20196b12df25ce512dab", - "apps/server/src/provider/Layers/CodexAdapter.ts::interruptTurn": "30847835c4df70000280b6b6415dad031d2fe16a6cbd86083e9e644531f94fc3", - "apps/server/src/provider/Layers/CodexAdapter.ts::listSessions": "5d6af4140013a75ed4bf09d25e39d0e6610d8ed17b61951149b9e1cdc654ced5", - "apps/server/src/provider/Layers/CodexAdapter.ts::readThread": "64d40ec1fd96cc2cd65ac22a7292ffcc5b4d3133b1e282130331a2f44ff92cc5", - "apps/server/src/provider/Layers/CodexAdapter.ts::respondToRequest": "7765e1748abbd9c726fe61162895f392e00a73ae3f00d69be6b96d6f54575925", - "apps/server/src/provider/Layers/CodexAdapter.ts::respondToUserInput": "bdd50833ed2087a75ceb3b91b5fe9a4d94e3a40cbc17431bcad4c81ce13c3c9d", - "apps/server/src/provider/Layers/CodexAdapter.ts::rollbackThread": "c942a0abf5f1770bbc0c38eb36bd40c5b8fda5addd91129e373bc9458971a259", - "apps/server/src/provider/Layers/CodexAdapter.ts::startSession": "47ad11b1d48afb7fd458967243b599aa7d52d3c4478795c6b5a08a3750806e09", - "apps/server/src/provider/Layers/CodexAdapter.ts::stopAll": "ffa62a03873d55ba2ca345d61164b40380e539a49a98c262911fb933c3fb4bd2", - "apps/server/src/provider/Layers/CodexAdapter.ts::stopSession": "00bab8202718f9b2c0bfa9b4f16c8e67e486acce56cdec7ca2339840ba3ddeee", - "apps/server/src/provider/Layers/CodexProvider.ts::buildCodexInitializeParams": "621177cba1d7ce587fb7964e1e893aad10942c6562b20133b041c590d68c5387", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::CodexSessionRuntimeInvalidUserInputAnswersError.message": "4a9b07ac365ce6b3a0cbec8c7756dbadb26647a87fdc33e99b7bf640192cce1d", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::CodexSessionRuntimePendingApprovalNotFoundError.message": "cfe476accc090223cae1c440d55a2eab72da745bba29a20ce289204d06a6baa2", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::CodexSessionRuntimePendingUserInputNotFoundError.message": "4238fa3f007569db118e0b8cb00f4fb3b5b7c6420580e79aadd83aaf87218fcb", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::CodexSessionRuntimeThreadIdMissingError.message": "7b86b193a4d9734d7c202bdc196666182d9b4631033b45fd7b143d27f849ce24", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::buildTurnStartParams": "f3fc7911cf67f6740d3d3c53252406809ea3d7b33572849e4872d2c96d12feb2", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::emitEvent": "ed65a3f233ccbd403c6d6315acd56686ef459981403f58ec082c04537b3421a2", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::emitSessionEvent": "eb348753cc3a01a69967b51c4f6621ab2cd1d5290c713e9fd5f21528d70b6a15", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::handleRawNotification": "acf4d38d2e532aabfb4e1f0fc9198f7f5b9ceebc9a5fdc7f24996aa15665efbb", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::isRecoverableThreadResumeError": "a3699798a61bb49ef59ee2d643f0b750e16be42985570a117442950a46484926", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::makeCodexSessionRuntime": "c4efef9b83d1e4ad3b5c3640807aa3063c4f060b14a9006a125c33e0dd52d1ca", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::offerEvent": "e6b15f65d0ef4d1e06bc109f1008b408edd2fc70fe6b93b454ce8fb4bc565d58", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::openCodexThread": "1d53a82338f273035f627ad63024a957728717ed30ec7b1f0f5b9963f7e34168", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::registerServerNotification": "fe463201b83ef3b3caf54d6241a7192cedca4c59b5b4f43c149e3b0a25ff89e6", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::settlePendingApprovals": "405f04ac787db66bdc4c5bcfa1b48a92ecdf60b37fa1ee76f67d792445bb5988", - "apps/server/src/provider/Layers/CodexSessionRuntime.ts::settlePendingUserInputs": "e4ea115282e8058dad25f4ff7d14d2d19ac54ed412da21b61bef1bd4379e6da8", - "apps/server/src/provider/Layers/CursorAdapter.ts::makeCursorAdapter": "8016d0d62b13cb4b248c6191172baaa2da3e464f3642ef99b5425700419fb851", - "apps/server/src/provider/Layers/CursorProvider.ts::buildCursorCapabilitiesFromConfigOptions": "8adf5f35c5137040f63f04eca4a6472d8efb8157909dfad2bc3a03eafdff2eac", - "apps/server/src/provider/Layers/CursorProvider.ts::buildCursorDiscoveredModelsFromConfigOptions": "3e582414a98b3717da7aa02613350eec9e4597b19e308a05cf8ea6f057bb654c", - "apps/server/src/provider/Layers/CursorProvider.ts::buildCursorProviderSnapshot": "cdeb6c7ff4ce31d2005055cdeeb7c5502dd8914a668a054e676051f8e5875f13", - "apps/server/src/provider/Layers/CursorProvider.ts::buildInitialCursorProviderSnapshot": "7610b5892552fff2306205af4adc2ccca922e64d36f03bea5db3e125fe19e9d1", - "apps/server/src/provider/Layers/CursorProvider.ts::discoverCursorModelCapabilitiesViaAcp": "0000ca27a040162457bebf79071d1a3f2eadea762449fefebd56ef28439bb2c2", - "apps/server/src/provider/Layers/CursorProvider.ts::discoverCursorModelsViaAcp": "f27aaa1d39c8934a6448fb6dd13ca9c27695c05702fc1c79c541ffcb31019eb8", - "apps/server/src/provider/Layers/CursorProvider.ts::enrichCursorSnapshot": "f6c89029204eac958076a51032e8a2d8a99eb8e9285d1846502d83fe27b03261", - "apps/server/src/provider/Layers/CursorProvider.ts::getCursorFallbackModels": "073908f94ef4bc9b39294962bd26ce6c007eafb3d4eea3a1d6957d802d2a9630", - "apps/server/src/provider/Layers/CursorProvider.ts::getCursorParameterizedModelPickerUnsupportedMessage": "40f96fbe32ca75c032f23f21fedfd886ccd4843e501431919492c816d3c2c74f", - "apps/server/src/provider/Layers/CursorProvider.ts::hasUncapturedCursorModels": "cdd6518fa18f0e2dc83292c3f5b928cca4a0c0d788c743a43e5a2751aee2a350", - "apps/server/src/provider/Layers/CursorProvider.ts::parseCursorAboutOutput": "512318b2f9e9cbe909359d516d66887ebde0d69692d14b9e56c6fd1f21e9bf91", - "apps/server/src/provider/Layers/CursorProvider.ts::parseCursorCliConfigChannel": "bf5e86dcb6892acc39b32e7f53364083def80b972703a96048bae1672a0a3be9", - "apps/server/src/provider/Layers/CursorProvider.ts::parseCursorVersionDate": "79df2e832b6103f2860d3cc3a75bc53ca47a2d9700d7f0c3aa6a720e9e28d3a8", - "apps/server/src/provider/Layers/CursorProvider.ts::resolveCursorAcpBaseModelId": "5175caf0f07a9ecc1f1a9ad0538aac39e763c09266f9e5e3cdf0c031cec3a77c", - "apps/server/src/provider/Layers/CursorProvider.ts::resolveCursorAcpConfigUpdates": "be4131b6647dbe646b6b0a0ec727d9be06ef3fce964589c3028887746a253d9b", - "apps/server/src/provider/Layers/OpenCodeAdapter.ts::appendOpenCodeAssistantTextDelta": "d18ceadf8a237cacdf81e0a0bc1a889da95810b12a4ff459c6f3cfa3ebe53158", - "apps/server/src/provider/Layers/OpenCodeAdapter.ts::makeOpenCodeAdapter": "79ec6255211d5b34034976d7e1b986e67ee419628f30a4cc5dce34a2e4492795", - "apps/server/src/provider/Layers/OpenCodeAdapter.ts::mergeOpenCodeAssistantText": "7e61bfcf3682e720adf9a30841f185e3e1e0f0ec67a3998fc00c55074f9cd3a9", - "apps/server/src/provider/Layers/OpenCodeProvider.ts::fallback": "e7fb3236962ffcaf99edc52ae8df3cf63e984c0a75a571639797e1baa5db2f51", - "apps/server/src/provider/Layers/OpenCodeProvider.ts::makePendingOpenCodeProvider": "f30c80593d2abfa902593a5e6596952cca867627d00fc18424687d6b40b82044", - "apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts::deriveProviderInstanceConfigMap": "99b65cfa591f9fb949f72bc42615f2978f76c596692a61c1637c3e96f56f3847", - "apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts::ProviderInstanceRegistryLayer": "bcccfc17dba738d2b9a85f43cd211a928da8d34572e0c3c6d61beefeb5651855", - "apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts::ProviderInstanceRegistryMutableLayer": "649f8a1164223d98fea60a2bbd08acb092454bf96d5e1bff6739d0a2519f6fc5", - "apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts::makeProviderInstanceRegistry": "3e57eb7d04f7ada7656c3daee469a56827c4bcd3e0621768e7a82de0268a70c7", - "apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts::reconcile": "a2eab6002417fb4c2e9725a026dca446876bc8061949bfb4db465305a6217869", - "apps/server/src/provider/Layers/ProviderRegistry.ts::haveProvidersChanged": "1261a814810d6a92a5594522db7678182c3992b4d2d40766700158c595701e65", - "apps/server/src/provider/Layers/ProviderRegistry.ts::mergeProviderSnapshot": "1c2060fcb743dbd24a074bc9e8199b552b5d0684fc85ac89ceab1b6b4a5a63c5", - "apps/server/src/provider/Layers/ProviderRegistry.ts::mergeProviderSnapshots": "fa0fe7a8126ff880dd11d59b7678e7095d9817ca8d8e673a933f4be7cbe856ee", - "apps/server/src/provider/Layers/ProviderRegistry.ts::persistProvider": "fb4720a996610c5cf35b7c089bd1fd33662291fa01f80ec9de4e01414f0428b1", - "apps/server/src/provider/Layers/ProviderRegistry.ts::selectProvidersByKind": "4eafd8c1943edbf5253932e0ecf6a2c66daadd35133a5d2727835a96bb85ffdd", - "apps/server/src/provider/Layers/ProviderService.test.ts::__file_sha256__": "34ad7d83bef29c17163fed2319b3968cce476b0d509661090e14a68a1c76c767", - "apps/server/src/provider/Layers/ProviderService.ts::makeProviderServiceLive": "4fce8f3fa0f7e96b7adc05b9d3f8365ea69aa3e867d7ccb491fcec89092a8009", - "apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts::__file_sha256__": "6d4c3995e5e22f9f8a3168d20088c5400b752e05f4d79ac8f7ca9e369fd8dff5", - "apps/server/src/provider/Layers/ProviderSessionDirectory.ts::makeProviderSessionDirectoryLive": "6bb76ca611badbfff86a7cada5d5df2f0d560f7d3f232eaa13785801b68e2b89", - "apps/server/src/provider/Layers/ProviderSessionReaper.ts::makeProviderSessionReaperLive": "58f696573d1d5b3ce0dfe2cbe03efb7b9c368e22e7d51e9585cd20b4e2b06a5e", - "apps/server/src/provider/ProviderDriver.ts::defaultProviderContinuationIdentity": "0331400463c91082ac93f598314f12aa298f6ce62b96188630a1d5baa80343bc", - "apps/server/src/provider/ProviderInstanceEnvironment.ts::mergeProviderInstanceEnvironment": "994527e40569497a29b8f38830e84ee3edfcb4deece3f512245d47046965555e", - "apps/server/src/provider/acp/AcpAdapterSupport.ts::acpPermissionOutcome": "9abba6c367177a69ac1730f0a725be00f5b83212e43586b1cc609f6ed12b318f", - "apps/server/src/provider/acp/AcpAdapterSupport.ts::mapAcpToAdapterError": "37ea32f61979498f3423eb012847c5325c8f1244f2e420dd0c8a1d05bb146941", - "apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts::makeAcpAssistantItemEvent": "67ce68d85a2022bed554a6949c350a5b51c8a95d6fa16b7d743900b75804f872", - "apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts::makeAcpContentDeltaEvent": "baff8af6b1d4246fc347e1a627a53558267dff317971ad8a8685bf5fa75857d7", - "apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts::makeAcpPlanUpdatedEvent": "3c7e64183038db1995b5b56eb2e767f32355ed3aba6b06cc121b8eade9240f90", - "apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts::makeAcpRequestOpenedEvent": "eea9926baa59909d4f2d5249fc71e4e63bcf6258c8b28a2ac1b334f75e5b84d5", - "apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts::makeAcpRequestResolvedEvent": "a5a3d8abaae45355a7ba800bc4ed417fddcde045e53c82f2af7a98c0825f2410", - "apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts::makeAcpToolCallEvent": "0104c9563333c8887bced8883c61dc9c05dcb96f52a5858ef80aff3a9441a6ea", - "apps/server/src/provider/acp/AcpNativeLogging.ts::makeAcpNativeLoggers": "b5be4f9e4e0b4e90873eab5a31590fd64232b2b72879b0ed90d50462a2526b56", - "apps/server/src/provider/acp/AcpRuntimeModel.ts::collectSessionConfigOptionValues": "28d6f8325ba35df60f2059ae6293bcbca7366ee29d8c325200b0914982ea7072", - "apps/server/src/provider/acp/AcpRuntimeModel.ts::extractModelConfigId": "9e375761b619119517f682b3e23fc581bb41b6a3a238ac4feb22166d8839885e", - "apps/server/src/provider/acp/AcpRuntimeModel.ts::findSessionConfigOption": "8cfedfa6f9f108c532e954be1851f5751a6383bc3d42e433b051dc25a7aa8d4d", - "apps/server/src/provider/acp/AcpRuntimeModel.ts::mergeToolCallState": "94fd59ba92c59867620934ebbff92e8444925c7b702080f72d11e963292ab337", - "apps/server/src/provider/acp/AcpRuntimeModel.ts::parsePermissionRequest": "6f0fcd3c31c8e02141a218dd5f2b81f5ba1eccfcc6fbbdf676413fef5b23be54", - "apps/server/src/provider/acp/AcpRuntimeModel.ts::parseSessionModeState": "863668af85f7234d5373d914003b31c0056a0dde00dd58680641c6e64afeefe7", - "apps/server/src/provider/acp/AcpRuntimeModel.ts::parseSessionUpdateEvent": "cbfcbd486c81d3ba79a25c2dc9f2011843850f656195ff098e705d4b1c0b875f", - "apps/server/src/provider/acp/AcpSessionRuntime.ts::AcpSessionRuntime.layer": "c921cac57ab9273ab86f02f6c85d73c4d352c768a68a9e4f1591d2c49a260683", - "apps/server/src/provider/acp/CursorAcpExtension.ts::extractAskQuestions": "0f393a719842a14dc9dec28549ca55ef3fe98930379fe2738d57c941ce2a3f1f", - "apps/server/src/provider/acp/CursorAcpExtension.ts::extractPlanMarkdown": "e83781d0e4c1076f602637acc8b509b9953650b3d7ddf8af81e0e7324112b778", - "apps/server/src/provider/acp/CursorAcpExtension.ts::extractTodosAsPlan": "fb784f7439c213a1cbd4c00c6698f44fd0e73025c309bd6465d4e45f01adeaba", - "apps/server/src/provider/acp/CursorAcpSupport.ts::applyCursorAcpModelSelection": "c7a91eb1e1395415de101608c43045251931b05205727f16d3f8460846690d3a", - "apps/server/src/provider/acp/CursorAcpSupport.ts::buildCursorAcpSpawnInput": "f00a91fb023a951c373a49cd8a436f39cbc61e4d53de89925b08d97e84c8ef54", - "apps/server/src/provider/acp/CursorAcpSupport.ts::makeCursorAcpRuntime": "adab838385833abdaa211ab7a1d3f8e4c2b1b9ff968da694bdfb24fcff605f5d", - "apps/server/src/provider/makeManagedServerProvider.ts::applySnapshot": "57a96546d7a7590bf13b7fc1b8bcf01d73d641f01483147ab0c0e6f41b0ecb56", - "apps/server/src/provider/opencodeRuntime.ts::buildOpenCodePermissionRules": "7a674b933403de2745959669b3bc9781daac9ccbed180e9b7d2c521e5c008a8b", - "apps/server/src/provider/opencodeRuntime.ts::openCodeQuestionId": "38641a1035b737f950a5310775a7672c7a7fa668c1586a8fa25e33e7995dbe74", - "apps/server/src/provider/opencodeRuntime.ts::openCodeRuntimeErrorDetail": "40351ab877ecc0a9736338d779bbb7ebec02b75f0fd370505888440b430a17fc", - "apps/server/src/provider/opencodeRuntime.ts::parseOpenCodeModelSlug": "6423291fbc11c5fe848eb4b34b44bee4c282f86549d125b3698ea5c5b5c86f40", - "apps/server/src/provider/opencodeRuntime.ts::runOpenCodeSdk": "5da2e5bcb975a286dee36891b3534b0238c5218f179a339a999b9bbd463ed350", - "apps/server/src/provider/opencodeRuntime.ts::toOpenCodeFileParts": "0a19e5f2e033f2344dc2265d2c7f52c7c7d4a148d719e2a4c600a18399ea7313", - "apps/server/src/provider/opencodeRuntime.ts::toOpenCodePermissionReply": "5e64fe6874fd21574a02ecf1e36f8876d852dcc935e011e03142234552591c37", - "apps/server/src/provider/opencodeRuntime.ts::toOpenCodeQuestionAnswers": "656a9ee087f750aeca64529b30fbfa161451e5f29fa0d0c4aa160f5cb363461f", - "apps/server/src/provider/providerMaintenance.ts::clearLatestProviderVersionCacheForTests": "7ac40f8b0ad0754d65c456156b464ce49dec00ee04914d08e1f9d1a2dd0a9fb3", - "apps/server/src/provider/providerMaintenance.ts::createProviderVersionAdvisory": "33ead9832de3bfe5eb6109c3ae818f4cf51aca9719218ba6924c85e5e73711e0", - "apps/server/src/provider/providerMaintenance.ts::hasPathSeparator": "11603a3b68d4a9c7917b93292422ab3ab6b0ff96cddbd10019ef5f2dc44202bf", - "apps/server/src/provider/providerMaintenance.ts::makeManualOnlyProviderMaintenanceCapabilities": "9c298a6aea2cfc91685e217da7a3ad03703eb22f613850d8ed8da8c14a808903", - "apps/server/src/provider/providerMaintenance.ts::makePackageManagedProviderMaintenanceResolver": "d9348f43e8f994ccf42df18fdc40809798f5e89a43fd0eacb2301cc2eeb36d0a", - "apps/server/src/provider/providerMaintenance.ts::makeProviderMaintenanceCapabilities": "559f988ccfe329bfcc258ac12af094e58a78f20a38e6f6cd3eef3b8faacf056d", - "apps/server/src/provider/providerMaintenance.ts::makeStaticProviderMaintenanceResolver": "6b31ff08969dba13f59005eb1b491ab81ee57183ed5a3263cfa71d142d37feb3", - "apps/server/src/provider/providerMaintenance.ts::normalizeCommandPath": "a738de92bf7f5b7f54bbadcbaf9663c704f40133e7e6291cba041761469d4132", - "apps/server/src/provider/providerMaintenance.ts::resolvePackageManagedProviderMaintenance": "06b029a0c7c8107e2f21f4fceea73738545facfc60b4277d9041a046fb1c688c", - "apps/server/src/provider/providerMaintenanceCommandCoordinator.ts::releaseTarget": "9bb014cebd62025f42140148af47fb319c0b37bf8e386e909104e742bc7befff", - "apps/server/src/provider/providerMaintenanceCommandCoordinator.ts::withCommandLock": "7d1f94e4dd80fed7656d533d504feaa275be54ecea480ffe02c58a50f3ba2714", - "apps/server/src/provider/providerMaintenanceRunner.ts::finish": "dac6805f5237a03557c2488dcb214226db478c8ef66b24e6257121543b83e889", - "apps/server/src/provider/providerMaintenanceRunner.ts::runMaintenanceCommand": "17b9050389576cfd8027a248a26afdb2a31a45660ce78e4987bc75172be3a059", - "apps/server/src/provider/providerMaintenanceRunner.ts::setUpdateState": "02bfe03db2ecbadbfced1ed99e02ebf6dcf4800352d5826a0bcee4d8181e4e83", - "apps/server/src/provider/providerMaintenanceRunner.ts::verifyRefreshedProvider": "0c09d7b42a9d2cc67458254dd71a6e3c5500e94f2d4824c303fd3468811a96fb", - "apps/server/src/provider/providerSnapshot.ts::buildBooleanOptionDescriptor": "d6d82b9fb5b303cc2e7221e982eb8214ba9a07e93493b349b028a8d86f1f4c03", - "apps/server/src/provider/providerSnapshot.ts::buildSelectOptionDescriptor": "abeaf53f9c7092bd6474e4b2870fe86c24e25aafa3e8444771dc7d3c102895dd", - "apps/server/src/provider/providerSnapshot.ts::buildServerProvider": "3875e63ab483cacc6424988f724fa2f5d04788aff9f5e151ad8d481ff2c70f39", - "apps/server/src/provider/providerSnapshot.ts::collectStreamAsString": "f71c2dc7192690ab8640a0fb74a289c1f9118d823df04e3546b8776410644db9", - "apps/server/src/provider/providerSnapshot.ts::detailFromResult": "dffb90e504a0044fdaeccb0e00a34288c2d906b2abaea5324297700647465d7b", - "apps/server/src/provider/providerSnapshot.ts::extractAuthBoolean": "14cf779424f68dc83db8e7a220d7909bf936c670d7d9a9ccd06557131c2c5a7b", - "apps/server/src/provider/providerSnapshot.ts::isCommandMissingCause": "35ff3966a73c349e3370da115e9032c4d74627f4f37ffefd58a3620e191c34b1", - "apps/server/src/provider/providerSnapshot.ts::nonEmptyTrimmed": "b8471963542b7d491e6371a8f951ebd4492d55bd36cbe37967e7092196c462e1", - "apps/server/src/provider/providerSnapshot.ts::parseGenericCliVersion": "6f69dee1142c65aa98ae113664967cfde800b1811c7d7d00be7a571e44b8a19d", - "apps/server/src/provider/providerSnapshot.ts::providerModelsFromSettings": "09f6c950fec788882385d4e6ef3f11b9d9e39088005c598419820391b0e48163", - "apps/server/src/provider/providerSnapshot.ts::spawnAndCollect": "8420624a63980d73bd8ce629ddd33c5fa9e96f76f9c7c110377a0bdeee7cde02", - "apps/server/src/provider/providerStatusCache.ts::hydrateCachedProvider": "3636c15fabb1cf85f3b3c7754a581925b37503fa2ceaa9258356f25a408e313a", - "apps/server/src/provider/providerStatusCache.ts::isCachedProviderCorrelated": "bcf89bd698392b6658d0380e6ee6b4e740d2d6e4f0fbfe85d2a82a27620cd706", - "apps/server/src/provider/providerStatusCache.ts::orderProviderSnapshots": "f33ce7db32b0c612ae3b4454296840ad7f6ea9b81ee4fdbe1e39440d7afc4cf4", - "apps/server/src/provider/providerStatusCache.ts::readProviderStatusCache": "a50d66fc60049ecbe08a834b11b4f641838f0fbac191501260d25d514bd31bc4", - "apps/server/src/provider/providerStatusCache.ts::writeProviderStatusCache": "da69154ebd3b67431c3b363b0d0876a1c2c68ee71260f83cd98ac5e5032b6237", - "apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts::getByInstance": "380ed01cfc503d962a4397e8a3b7af39a97489571a8c84fe866e4bd245d7d49f", - "apps/server/src/provider/testUtils/providerAdapterRegistryMock.ts::makeAdapterRegistryMock": "da24e6fcb331508750a3dc9c641795bddb3b3c7b086d7ab88dc3bd9e064c490f", - "apps/server/src/provider/unavailableProviderSnapshot.ts::buildUnavailableProviderSnapshot": "4bd3bd2eebb835c3b2f5c24d40aa361b033bd65c41510408f62dad3d99be0e4d", - "apps/server/src/serverRuntimeStartup.ts::getAutoBootstrapDefaultModelSelection": "eda446022de01473313d426babf23fa1c83807bc46b5e06aa8254bd9575a954c", - "apps/server/src/serverRuntimeState.ts::clearPersistedServerRuntimeState": "fd1ce659e32ce226781ad21cf6c7b8bee3b4327c87fdfe52d077127b135613b8", - "apps/server/src/serverRuntimeState.ts::makePersistedServerRuntimeState": "a53a232baa582f74baa55b37f47d6ec865f629ca75d463ba91f4cc7e32600fbc", - "apps/server/src/serverRuntimeState.ts::persistServerRuntimeState": "b2acf9d5e3f2d8e94d27724d706dd84ff90e73b31b8b84fda9a70710c4c07e54", - "apps/server/src/serverRuntimeState.ts::readPersistedServerRuntimeState": "a301cd096c3c132ef76115bb4f07c6218d51473ec9748ff1ab0b90b74eb96e00", - "apps/server/src/serverSettings.ts::redactServerSettingsForClient": "22d5d5bd73ea7a406312ac0a06b32fd9774145b419f8114cfaba7730686eaf88", - "apps/server/src/sourceControl/AzureDevOpsCli.ts::AzureDevOpsCliError.message": "3fd508d398b62ba5af4df4d6c01d7df4124608e67e62ff1a8d947bb337411d9d", - "apps/server/src/sourceControl/AzureDevOpsCli.ts::execute": "7bd936d0bed0c44bacd0c20bf6fc503356b416bead907ad089f30c5cce9b6543", - "apps/server/src/sourceControl/AzureDevOpsCli.ts::executeJson": "65cd05eeb01ff1b613371184f8076f73758830577e1cac7fff57bc409dceec0f", - "apps/server/src/sourceControl/BitbucketApi.ts::BitbucketApiError.message": "0d11303eb24d7d049996c28c1f96779e0720e4c51e2b84c23025054041799ad7", - "apps/server/src/sourceControl/BitbucketApi.ts::apiUrl": "69431141ec6077ed6b0f932e55a531be8dc8483c0b8f28d2f3910681a8ac55c3", - "apps/server/src/sourceControl/BitbucketApi.ts::decodeResponse": "9451839c463489428517ed6a26ad3f27cd28ac09b22c013aa70fa2d44ea28d27", - "apps/server/src/sourceControl/BitbucketApi.ts::executeJson": "5c26887226eb60d1dd44d06c12661ae7f1d83ccc615b6a5df1f8232e7f6ae25d", - "apps/server/src/sourceControl/BitbucketApi.ts::getBranchingModelFromLocator": "24917085a83269423469ee9466b6d28c4ca8316bc4d62b404b436483703e9311", - "apps/server/src/sourceControl/BitbucketApi.ts::getRawPullRequest": "7bcdab43a367746efee4bdd2bff123163addf987c541dbef816d14e86d859f21", - "apps/server/src/sourceControl/BitbucketApi.ts::getRawPullRequestFromRepository": "14ec1ed3611ce4cc1ec4ced792b18ad3d5b768d3a84f7b918460f07c85183766", - "apps/server/src/sourceControl/BitbucketApi.ts::getRepository": "425c3f679cf3fd251d133cfda0d0f8ee0ee2c914deda072e9bbb80bea82c2cd4", - "apps/server/src/sourceControl/BitbucketApi.ts::getRepositoryFromLocator": "91e58a9ce9a91425e5c23d4cad520104aab37f4087f60b5c2f6c99c524ae618b", - "apps/server/src/sourceControl/BitbucketApi.ts::readConfigValueNullable": "d98d6c81b75abe7eae7c572a22adbfd6f4cef505b379593ce6b68a975606f919", - "apps/server/src/sourceControl/BitbucketApi.ts::withAuth": "4dc5ff6a2ec4753395e0d7333201d1445aa59e3dcafdd4e9dd170f797c6ba45b", - "apps/server/src/sourceControl/GitHubCli.ts::GitHubCliError.message": "592bebc13565da2147b3daf250342048320e93e849fbce99d423bde42a5d7028", - "apps/server/src/sourceControl/GitHubCli.ts::execute": "7bd936d0bed0c44bacd0c20bf6fc503356b416bead907ad089f30c5cce9b6543", - "apps/server/src/sourceControl/GitHubSourceControlProvider.ts::listChangeRequests": "415053459a9496cb026c865c9fea15153dd234472fa5486f930686427d6cfa4e", - "apps/server/src/sourceControl/GitLabCli.ts::GitLabCliError.message": "dba1065c913c0a9d706da087ef0995417c8f645c321605858f169ac1afce31a7", - "apps/server/src/sourceControl/GitLabCli.ts::execute": "7bd936d0bed0c44bacd0c20bf6fc503356b416bead907ad089f30c5cce9b6543", - "apps/server/src/sourceControl/SourceControlDiscovery.ts::probe": "027b191ddbc61eef0b6acaaf7bed5479b4969e41839206dc78d9df67e9f1a7a4", - "apps/server/src/sourceControl/SourceControlProvider.ts::normalizeSourceBranch": "2d6661de0430b08e1c64bd3850673091317886855705ce13bfd18e2c683e3645", - "apps/server/src/sourceControl/SourceControlProvider.ts::parseSourceControlOwnerRef": "edfeb56e3bbd5ba64cadbb6ce28bd4d68735ebed22e4f03137e263c024715386", - "apps/server/src/sourceControl/SourceControlProvider.ts::sourceBranch": "25186248e23272b09a0976ab46c5f367891ec7f13a469cfb278fe41591d7d18e", - "apps/server/src/sourceControl/SourceControlProvider.ts::sourceControlRefFromInput": "9424e3a2679afb16447f5eceb80f5f335749ce943ebde398d76a5d3703a88f75", - "apps/server/src/sourceControl/SourceControlProviderDiscovery.ts::combinedAuthOutput": "467c9476d260233c697c6ede3b901eee0de10ea739a189f943fb8ff89cc886ce", - "apps/server/src/sourceControl/SourceControlProviderDiscovery.ts::detailFromCause": "cdb861218d04cd3515364467a85e9e950f5219399377fd1db0ec966117b86ca4", - "apps/server/src/sourceControl/SourceControlProviderDiscovery.ts::firstNonEmptyLine": "90a11ce7a1f86fe83d6257faf213784454a3f4b0c61744999b1d7b478ba6fd86", - "apps/server/src/sourceControl/SourceControlProviderDiscovery.ts::firstSafeAuthLine": "4dca3cae555ed2674bdc917e6dbacc9771de59cda4e9ecbb4f90a4e0e3587e76", - "apps/server/src/sourceControl/SourceControlProviderDiscovery.ts::matchFirst": "5eaf97e0675d5e42adf6f762232bbc4fd2242795e9bfca44e02a7fbac6102ae8", - "apps/server/src/sourceControl/SourceControlProviderDiscovery.ts::parseCliHost": "d9f5b24d6c8bd3dde47ead93b65d1510a477707cf6e0db2a3463838a81754553", - "apps/server/src/sourceControl/SourceControlProviderDiscovery.ts::probeSourceControlProvider": "03b9dbc7130986ab8b0f8aa5e7437b4bf9c716a5fb958d050ea5e178e24301bf", - "apps/server/src/sourceControl/SourceControlProviderDiscovery.ts::providerAuth": "42b0f9b513aa0a28244182aac01030c9fae1cfb4de68bbbc89e42ac7782dc23e", - "apps/server/src/sourceControl/SourceControlProviderDiscovery.ts::unknownAuth": "29eb3f12697b0c3dc81b10d0456b1cf4e6cd5dedfbf9ffdd0d654e45db8cf9ed", - "apps/server/src/sourceControl/SourceControlProviderRegistry.ts::get": "32ffc8c95b2e67b691064c8e3170e2c32628d11b04089a7769e878dbfa7d3253", - "apps/server/src/sourceControl/SourceControlProviderRegistry.ts::resolveHandle": "13d97a528e3308a922d64064e79efd89762203ff5400a637a1df6d50f4fe2587", - "apps/server/src/sourceControl/SourceControlRepositoryService.ts::ensureConcreteProvider": "2a029ca11030ac92d4ec9f516137115a27060aeeffbecb074e596960b6d2e4a9", - "apps/server/src/sourceControl/azureDevOpsPullRequests.ts::decodeAzureDevOpsPullRequestJson": "4fe4c08624c20cef6ea50cfd4ab68c9ed28bbf81f64952e6185751357635c7f2", - "apps/server/src/sourceControl/azureDevOpsPullRequests.ts::decodeAzureDevOpsPullRequestListJson": "c5e811c46657afc38d640b27e32a0a5a6fd8d26216a9e65280fb0fdca1b6a0df", - "apps/server/src/sourceControl/bitbucketPullRequests.ts::normalizeBitbucketPullRequestRecord": "35e610036dc9a16d3ee84bfefe0579d1f87e8ed5a5751e6421f7166ced9860ea", - "apps/server/src/sourceControl/gitHubPullRequests.ts::decodeGitHubPullRequestJson": "f8ffe3312db15d932491caa638166cdbfbd5430b49b1e7db6c7308c8053b6ebb", - "apps/server/src/sourceControl/gitHubPullRequests.ts::decodeGitHubPullRequestListJson": "eed0457d236478721148dc4a2db7c9c28579b50fc44a79320766dff286d379f0", - "apps/server/src/sourceControl/gitLabMergeRequests.ts::decodeGitLabMergeRequestJson": "0b46cb49effe9e1b39e3876748f75a3bd45b6f6f742cf66c723fdf4b4fd5fe55", - "apps/server/src/sourceControl/gitLabMergeRequests.ts::decodeGitLabMergeRequestListJson": "00db0917bec90bbe7c5c840a72b3a0f09d8e3d1ae56a33cca486ef026825d2c3", - "apps/server/src/startupAccess.ts::buildPairingUrl": "9fe4678ea174651162cc999134ad8c95069b7336f9b03c8ae829168fdbbf984e", - "apps/server/src/startupAccess.ts::formatHeadlessServeOutput": "3ce5e3747210bbaa6f24c74597d47fd54d06fbce5cefc4f554199c238c8c4ec0", - "apps/server/src/startupAccess.ts::formatHostForUrl": "88f3484d8a84e75c18513f12015108fb8923c9dc5ff222bc6ef8b834fed6e9f7", - "apps/server/src/startupAccess.ts::isDark": "54468e1585d68eb11eb4d013204115f9d4250b3558b8f5777ba744e4f65fdce5", - "apps/server/src/startupAccess.ts::isLoopbackHost": "2b6d37d8b0f854e6853b04bbd2683bdd52147bed55f1304d282deb3465ddcf9d", - "apps/server/src/startupAccess.ts::isWildcardHost": "3767d5d03f8b96397f8f03fb442efdffe2e7720eeb16ffd85842bf97f4bd8d05", - "apps/server/src/startupAccess.ts::renderTerminalQrCode": "0eb812f3df4b9fb4c5831c58d2e8b0301bd49e42cdfe4ff3a443144c56aa553b", - "apps/server/src/startupAccess.ts::resolveHeadlessConnectionHost": "7171c034ce36a047235189021e79b3b4c526db0c2e25769880084ad67b7de5be", - "apps/server/src/startupAccess.ts::resolveHeadlessConnectionString": "88f0dbefa3c67104b53988284336d7aa3f6d03dd73a143ce06b322c862bbfb62", - "apps/server/src/startupAccess.ts::resolveListeningPort": "ada42cce0ac1c44274333bee3ce49fa9625e268d7ef15877a46519a1a7af6cc2", - "apps/server/src/stream/collectUint8StreamText.ts::collectUint8StreamText": "72984e7364f5b66417389b2f9b295a781a18996276c383f41236c1d554444f10", - "apps/server/src/terminal/Layers/BunPTY.ts::BunPtyProcess.constructor": "fe5f02681e9985483ca25e3fd0739144aad59c00a822437e173a2519f39955a8", - "apps/server/src/terminal/Layers/BunPTY.ts::BunPtyProcess.emitData": "69e5ea1ec308fd68a741aed1d283e4349bac76b116650e655d063bbedc1dfe87", - "apps/server/src/terminal/Layers/BunPTY.ts::BunPtyProcess.emitExit": "ba6787370d6a0a243ba6072af2bda869ed819022e163fc11e71e6844f0dd81f8", - "apps/server/src/terminal/Layers/BunPTY.ts::BunPtyProcess.kill": "880cfd7e88f2645ab347713d521e9b9ffd28e3a2ed037ee7ffbad81881fbdfce", - "apps/server/src/terminal/Layers/BunPTY.ts::BunPtyProcess.onData": "7154e749b37ab1ec0d5a50d38576f00aab8927d51c8157bc9affe21425e3da2d", - "apps/server/src/terminal/Layers/BunPTY.ts::BunPtyProcess.onExit": "ebe1c015d295e213f1f19f8416cf5014964303dc424eb1765574a687afe6c844", - "apps/server/src/terminal/Layers/BunPTY.ts::BunPtyProcess.pid": "fdf1ecb45a32d3e90cf7dff98f65671f08bfee96f46a13a428d5317a4e057243", - "apps/server/src/terminal/Layers/BunPTY.ts::BunPtyProcess.resize": "69bb58f3def4c0508a7a0ee9126d370253fadd5345c1d51350b7d51550de9125", - "apps/server/src/terminal/Layers/BunPTY.ts::BunPtyProcess.write": "76bf284861cd292ebff9d2040a5aa0f61e5dc1048e4af9f4ac7545bea06b61dd", - "apps/server/src/terminal/Layers/Manager.test.ts::FakePtyAdapter.constructor": "0081ce023760ec44f69fc3cc96cc3bf8dc2008ab188889bf292c0d0f574150da", - "apps/server/src/terminal/Layers/Manager.test.ts::FakePtyAdapter.spawn": "863bccad45e0602024a71e45a5547cbd33e7a093a3cec47834a65de062636fc7", - "apps/server/src/terminal/Layers/Manager.test.ts::FakePtyProcess.constructor": "69dd7467148f700e03a10b808373f9a15801146c4cd1a94f8e3bf94615e860a8", - "apps/server/src/terminal/Layers/Manager.test.ts::FakePtyProcess.emitData": "6c5dbd80614ae953fdcdf0e2297a9711ff4762efde5dc8dedf01db4645e6020c", - "apps/server/src/terminal/Layers/Manager.test.ts::FakePtyProcess.emitExit": "b09b72db304a9d1f6f367c807f7941e6015e06ad0ddb469eed8f6032cf1d70c9", - "apps/server/src/terminal/Layers/Manager.test.ts::FakePtyProcess.kill": "24763c618942fcc25991ef62a690c10fa914bf865164f2c2592908c51f60f580", - "apps/server/src/terminal/Layers/Manager.test.ts::FakePtyProcess.onData": "5b7eb7d7790fb29182410cb1f6f0787bb944380ce55b4c3d8d60ffe85517b14c", - "apps/server/src/terminal/Layers/Manager.test.ts::FakePtyProcess.onExit": "fe85ff7046f22424b7a01fd4424a22fe729b76649746e93ed1029e6945e1239a", - "apps/server/src/terminal/Layers/Manager.test.ts::FakePtyProcess.resize": "bc55dc9fcd29227123ca10e553f3e59b33cd0bda62d46bed7753a17da6275b27", - "apps/server/src/terminal/Layers/Manager.test.ts::FakePtyProcess.write": "4cf94f829375f407e6d98a82332c207ca5bba0eba54a458dd4d9da952ded7d08", - "apps/server/src/terminal/Layers/Manager.ts::clear": "c2b45381adff8de060d9305a890062d75538665882bd2ed7fd90eeb93fcb8792", - "apps/server/src/terminal/Layers/Manager.ts::close": "9b4a62571c6081edd738183fd8b352ee8f1df044680a1c9999d8fdb9ec4cfa34", - "apps/server/src/terminal/Layers/Manager.ts::getThreadSemaphore": "cea0a3d605aef6bb173bb5216fd08143c3325a171da32f1143f76d563e338269", - "apps/server/src/terminal/Layers/Manager.ts::historyPath": "f54624c7e31d71792c63fb8ab336530e96e22f203a239ecb2d81e33951b5892b", - "apps/server/src/terminal/Layers/Manager.ts::legacyHistoryPath": "a7746f273a06eeafdddd5c1a9b76069303a3896be6d5ff1483219ee10b9392cd", - "apps/server/src/terminal/Layers/Manager.ts::modifyManagerState": "644372f271effa274b2c152272639d7a136957012007b8e8a0f18bae68463bd6", - "apps/server/src/terminal/Layers/Manager.ts::open": "1828821cdee40c30e0c2e627b32641c1ebea88a2c4d7da2126969dec2dc355c5", - "apps/server/src/terminal/Layers/Manager.ts::publishEvent": "453f03392bc2f39b047eddfc7d5655ddb25df8a34d76f502b21efa897666b174", - "apps/server/src/terminal/Layers/Manager.ts::restart": "f98b04b1411dfec3a405c37a9daa79aff2877b35da1484337cc394d151a29212", - "apps/server/src/terminal/Layers/Manager.ts::toTerminalHistoryError": "caca3b8cb29a06b7338e54eabd5e069fab4f71b3046323ff1b622253da579f66", - "apps/server/src/terminal/Layers/Manager.ts::withThreadLock": "92957f8530cb8691546fa3e868f9e780a5a82452a6f1d06815bb6325640af634", - "apps/server/src/terminal/Layers/NodePTY.ts::NodePtyProcess.constructor": "85587a6ba30b7b014c552ff4b84ec4915be3007fa108b77dd5c57e007f4a1473", - "apps/server/src/terminal/Layers/NodePTY.ts::NodePtyProcess.kill": "15f88fbc477fce5d77341ce85345fe3bb4e1ad4efd8c47ca43b4cff728af3a80", - "apps/server/src/terminal/Layers/NodePTY.ts::NodePtyProcess.onData": "2e2318bdc54550b666a02048c786f1ec8ac25e657ae981536be2935dbf4ac176", - "apps/server/src/terminal/Layers/NodePTY.ts::NodePtyProcess.onExit": "c0d7614648819f8a45f5b9e4d83ae4110ffadbbd18f81d076b0007f3a89e99f8", - "apps/server/src/terminal/Layers/NodePTY.ts::NodePtyProcess.pid": "a45545891e00f9a52f7ef995547b014c850fb91c58e28b960054c6a6486c0d90", - "apps/server/src/terminal/Layers/NodePTY.ts::NodePtyProcess.resize": "47e8cec82f569e791f094f7bf1fc8f85175d022880b52048775a1bc6a8d57935", - "apps/server/src/terminal/Layers/NodePTY.ts::NodePtyProcess.write": "83b16151825bce7a53267dc8018fc4b46c861e605222710a91103a918e5d4f0e", - "apps/server/src/terminal/Services/PTY.ts::PtyAdapterShape.spawn": "eaa6b5124ecf75b589a0f73c8ba11db6aa28ad99c4d0a069605e636255ca1922", - "apps/server/src/terminal/Services/PTY.ts::PtyProcess.kill": "f806c5a91c4e46a3a8921da9eb84b30939eb5b8fe855e8fa8ce8ea923cede47c", - "apps/server/src/terminal/Services/PTY.ts::PtyProcess.onData": "6832d6313705e202da96d86155cc747a1642ce11b16abcb58857fc35cd76a62b", - "apps/server/src/terminal/Services/PTY.ts::PtyProcess.onExit": "c6cdb44dd6d344b7510862839b9d2375db24909b4ea6eafa9684090aeec58ed3", - "apps/server/src/terminal/Services/PTY.ts::PtyProcess.resize": "904e3924166fa920eadb2f4eb063eeea87b826d7aaa73014c19a285b31b4f4db", - "apps/server/src/terminal/Services/PTY.ts::PtyProcess.write": "27b28eda7740e6695695304957dd088bf10d7667973137ce96fe255c7d5eddb7", - "apps/server/src/textGeneration/ClaudeTextGeneration.ts::encodeJsonForOperation": "6ecb66d82822bf4ae48a825705d3b2c3982387fae93b9603077dd5d13422f0c0", - "apps/server/src/textGeneration/ClaudeTextGeneration.ts::findDescriptor": "a58398a7767a5db56ba3bc9c7dd150b4d1e4c80345df176425b04af20b9f0a13", - "apps/server/src/textGeneration/ClaudeTextGeneration.ts::readStreamAsString": "39316701a6fcaef839a5a37178da17ff907d46dcfeabcbb8d221a337288adbe9", - "apps/server/src/textGeneration/CodexTextGeneration.ts::encodeJsonForOperation": "f8aab41da6ee41a1857a1c6da00e8bcffecb5789b367e8bf70aa016f8af1a315", - "apps/server/src/textGeneration/CodexTextGeneration.ts::readStreamAsString": "39316701a6fcaef839a5a37178da17ff907d46dcfeabcbb8d221a337288adbe9", - "apps/server/src/textGeneration/CodexTextGeneration.ts::safeUnlink": "ca3ffa04136383ae4d43ecba89fd5f850ce6738830ffa474ec5e79cc4870071c", - "apps/server/src/textGeneration/CodexTextGeneration.ts::writeTempFile": "e9d761b64156a09c1723f6222229cd9dfff88af387caf93c04afbbef3a6f6611", - "apps/server/src/textGeneration/CursorTextGeneration.ts::runCursorJson": "b938c1c574ff2000c13bb4e5ffec4ed6b5deb2a546c03f3ef9d687f3097cdc7b", - "apps/server/src/textGeneration/OpenCodeTextGeneration.ts::acquireSharedServer": "52ebc36cdc2c6bc373c2261efffb083b2f847eec7eeed87083377679e057a6ae", - "apps/server/src/textGeneration/OpenCodeTextGeneration.ts::releaseSharedServer": "a2d155d42e38a09aae8eaf169e806462e990f846a117e0543eafafe911f3eeb6", - "apps/server/src/textGeneration/OpenCodeTextGeneration.ts::runAgainstServer": "e772e8563ce5fdfe23d81770e5e97b972b6387dd20510862161403800cc82bd8", - "apps/server/src/textGeneration/TextGeneration.ts::TextGenerationService.generateBranchName": "fbd53d174e6d30d6c45b33af1a4b07ce6ba776c881c2c0ebd8ed98d48139af79", - "apps/server/src/textGeneration/TextGeneration.ts::TextGenerationService.generateCommitMessage": "75bb257cc16519b9bed72209edbe7cb642bd3f57ace9635099542e9385b96520", - "apps/server/src/textGeneration/TextGeneration.ts::TextGenerationService.generatePrContent": "e782d702f17ba0f91209dca23dacb788f00e7d45d0556b90dfc0315c88853a39", - "apps/server/src/textGeneration/TextGeneration.ts::TextGenerationService.generateThreadTitle": "e6f147da6ac685247e299298004a3fd5ee195805f5b7ddeb7ae8fe6ad5664cab", - "apps/server/src/textGeneration/TextGeneration.ts::makeTextGenerationFromRegistry": "a31cbd90bbe5aeae3a983b52f982dc7d82d9242f284cbac3bfa1b4fdf21569c9", - "apps/server/src/textGeneration/TextGenerationPresets.ts::customTextGenerationPolicy": "0d796403d5acedfe2f616170266575ebbc2d9d839922c92b0a254247b86dc965", - "apps/server/src/textGeneration/TextGenerationPrompts.ts::buildBranchNamePrompt": "6deb6cbc289cb35c2d4b9a6de5f7582a6aff6e352ffaf9d00ca3cb7ab0593ab8", - "apps/server/src/textGeneration/TextGenerationPrompts.ts::buildCommitMessagePrompt": "ab299f2f03d73127c0cc7cc838e32746d6ca4c1a3c83502e265d889938b18ff6", - "apps/server/src/textGeneration/TextGenerationPrompts.ts::buildPrContentPrompt": "d5117f846addf1e3afa33b4e3a785c6493e8ad4f0584a0ef791529fd1ad84698", - "apps/server/src/textGeneration/TextGenerationPrompts.ts::buildThreadTitlePrompt": "f5267af85bde93df91d63cdd028af59b4eff3637725e16f7765f4553738dba63", - "apps/server/src/textGeneration/TextGenerationUtils.ts::limitSection": "17c69116646acaecdbd509f63a2ce5868616416280a8cbe13b990fa2d8f0c2ec", - "apps/server/src/textGeneration/TextGenerationUtils.ts::normalizeCliError": "aacd061c06e3dc1d331d063147df5772142ddb41b7b2175b821efb39a2243b33", - "apps/server/src/textGeneration/TextGenerationUtils.ts::sanitizeCommitSubject": "c54d215cc1aa4c43dcf62e87ea32a87d704595952f414610ceada1e961c9cc2f", - "apps/server/src/textGeneration/TextGenerationUtils.ts::sanitizePrTitle": "d4a757923f1a94580385da932cade043fd55e106a276c95d4e0cca4cb15c787a", - "apps/server/src/textGeneration/TextGenerationUtils.ts::sanitizeThreadTitle": "c541aa7801d4bfeda39d499a46c02b0538c8e0deee777d490a90d6d9fa7881db", - "apps/server/src/textGeneration/TextGenerationUtils.ts::toJsonSchemaObject": "05d90b66321cc8deccf4dd2b083b6ef71b4b78f129f38d24a9d5f5c58d1fac58", - "apps/server/src/vcs/GitVcsDriver.ts::execute": "7bd936d0bed0c44bacd0c20bf6fc503356b416bead907ad089f30c5cce9b6543", - "apps/server/src/vcs/GitVcsDriver.ts::hasHeadCommit": "35a7e0de2affc07f40d969160f4983a86e4d5c8f63e3c32e8b0db61b5f5b17dc", - "apps/server/src/vcs/GitVcsDriver.ts::initRepository": "6f58c7e45c16c48fd18d21a8e5879687553238274fa1f788aa2cd11a1849de82", - "apps/server/src/vcs/GitVcsDriver.ts::isInsideWorkTree": "efd6839723cfc6e33317417341f02d1bd4b9896baa94559dfddc3f6c057a0334", - "apps/server/src/vcs/GitVcsDriver.ts::listWorkspaceFiles": "bbf5233b111a4fdd51ef864c7d54d17000d724c0b77c0d666ce501f8c4b986fa", - "apps/server/src/vcs/GitVcsDriver.ts::resolveCheckpointCommit": "8a0f990578ca63a6a7e5873eee67c0b1512bce41ea681491474b1839fdca089c", - "apps/server/src/vcs/GitVcsDriver.ts::resolveGitCommonDir": "2f924b4da6d26109175784580de728275a61192d4f1c704532bdf77c81628210", - "apps/server/src/vcs/GitVcsDriver.ts::resolveHeadCommit": "5f08a329fff9a4d93ae943dd88c3a79988b1b32b36772f8766fc8ec5bc5920d7", - "apps/server/src/vcs/GitVcsDriverCore.ts::branchExists": "52816a0182347514309d3884a1ea140df1412d1b08c7c68a994346f29e03ce47", - "apps/server/src/vcs/GitVcsDriverCore.ts::execute": "7bd936d0bed0c44bacd0c20bf6fc503356b416bead907ad089f30c5cce9b6543", - "apps/server/src/vcs/GitVcsDriverCore.ts::executeGit": "21321a62e800e348154d52768d5aaf2a258769fc80fb495f310e2643b885cfe0", - "apps/server/src/vcs/GitVcsDriverCore.ts::fetchRemoteForStatus": "65c5131004fac9eea84af349db478976daf612c9ab32bc084e72a84c5e953b58", - "apps/server/src/vcs/GitVcsDriverCore.ts::initRepo": "73eea9ca1c1dc57864a25c04651fe5abed796dbd8323feaebb61397a02bfc128", - "apps/server/src/vcs/GitVcsDriverCore.ts::listLocalBranchNames": "996864d0ff825e7e19545e4e5e1206c14cc172675c80b409aebef3b467ed9712", - "apps/server/src/vcs/GitVcsDriverCore.ts::listRemoteNames": "b077996bcd403402f482b740d5c698d46654b501e6f89c2e3d02d0ab90da3cc8", - "apps/server/src/vcs/GitVcsDriverCore.ts::originRemoteExists": "62d1dff9da2c670bb63b5d6299818a58de819aebbb1b518f62f1eebb96075d2f", - "apps/server/src/vcs/GitVcsDriverCore.ts::readConfigValue": "2d2beb6a05b94645df7149470a3bbbc74f6002a6d43b4dc5e0493364b71f5d90", - "apps/server/src/vcs/GitVcsDriverCore.ts::remoteBranchExists": "37d77ee599a0bb0150abaaa62a1bf7af9014ea92e448e2317141e34501314648", - "apps/server/src/vcs/GitVcsDriverCore.ts::resolveDefaultBranchName": "ea468c2cb9e74b5448011a6ee6089fb84a76ee4109a8a6ee5c059ae331653ec5", - "apps/server/src/vcs/GitVcsDriverCore.ts::runGit": "e2a1c67599430c33e0ff3eb994edc1da0f4c9cdf16609c9dfd77cbe302dc47bc", - "apps/server/src/vcs/GitVcsDriverCore.ts::runGitStdout": "0487d35e87ba86608764ea0da37d5e4a59773d829523a8f18989b76edf61d829", - "apps/server/src/vcs/GitVcsDriverCore.ts::runGitStdoutWithOptions": "a8c5dc88c354e8fefea70903102f2b6d36d24659721f984d0152d5642f7a3cd2", - "apps/server/src/vcs/GitVcsDriverCore.ts::setBranchUpstream": "e95211805f354951d7d550555fff75dee545d0f590c56a65f98d3af98e1dcdc8", - "apps/server/src/vcs/GitVcsDriverCore.ts::status": "c78376a63efa6438ee6e119924e8e244e8b7b639ef236a97bdd3d4f09eeb991c", - "apps/server/src/vcs/VcsDriverRegistry.ts::get": "32ffc8c95b2e67b691064c8e3170e2c32628d11b04089a7769e878dbfa7d3253", - "apps/server/src/vcs/VcsStatusBroadcaster.ts::makeRemoteRefreshLoop": "e1ab005c0b89ff06e425c3e59ff07aaa76e796b2a8873fa6560d4bff9f5b44bd", - "apps/server/src/vcs/VcsStatusBroadcaster.ts::remoteRefreshFailureDelay": "2c1e2dddc78f99a4af4c64850be2dc27447638451ff474c397fee35dff819742", - "apps/server/src/vcs/VcsStatusBroadcaster.ts::streamStatus": "221d85a87ccc71c403f4a07677599b06a41499b5b8506b8cd37d594c2e09c0a8", - "apps/server/src/vcs/testing/VcsDriverContractHarness.ts::runVcsDriverContractSuite": "7b8f783dab062e76c20c026180872de7edcb5e4123006b29a8477dcdb0dba995", - "apps/server/src/workspace/Layers/WorkspaceEntries.ts::filterVcsIgnoredPaths": "e10057d7989e54d05cd143120e469b61b50c8cf50744fbf4eed8591f47e4a5ab", - "apps/server/src/workspace/Layers/WorkspaceEntries.ts::isInsideVcsWorkTree": "a82f0268eecef9870381d60182f27f667d1f868f061558afd1476ff1f1d1f4a1", - "apps/server/src/workspace/Services/WorkspacePaths.ts::WorkspacePathOutsideRootError.message": "1df24d480f3ffaf7131435515308c0a923487f59ccfa9c9015271d30816b8fdc", - "apps/server/src/workspace/Services/WorkspacePaths.ts::WorkspaceRootCreateFailedError.message": "f36a51a0abf8f835eef174716e9e7036841e0ceff13b5fc52393242217c6fd7e", - "apps/server/src/workspace/Services/WorkspacePaths.ts::WorkspaceRootNotDirectoryError.message": "345375e929b937380f5d8fb209e6943ae45765d1c06841e4895f2d60d56c7038", - "apps/server/src/workspace/Services/WorkspacePaths.ts::WorkspaceRootNotExistsError.message": "be09a586e446a1ee4c3e5b78006e8c3d6b810a761a0e84a79c90b3a47122753e", - "apps/web/src/clientPersistenceStorage.ts::readBrowserClientSettings": "eadf486bc32df714f969f076d3e2b6a262461dea13301b90b6b1e31922a373f7", - "apps/web/src/clientPersistenceStorage.ts::readBrowserSavedEnvironmentRegistry": "b1a3816d06ff237e11bf69ecf5092d94dc53c489cdf30853a2fd9c34edc23e6d", - "apps/web/src/clientPersistenceStorage.ts::readBrowserSavedEnvironmentSecret": "1bacf02032a8e5b187dc36923173856642b1967adb17b99ce81388187cd3f784", - "apps/web/src/clientPersistenceStorage.ts::removeBrowserSavedEnvironmentSecret": "509bbe793ee5b2e970d71185368ef1d3a7b4e76fc8643370b34c32e19d709df5", - "apps/web/src/clientPersistenceStorage.ts::writeBrowserClientSettings": "5da28fe467946e3c7ec50f456f00d8b21197890c16863720310c05c9d8305767", - "apps/web/src/clientPersistenceStorage.ts::writeBrowserSavedEnvironmentRegistry": "33443798bd1e7e71091b3e02021e3306ebf68eb2d3049dccbc6bcbaf496fabe5", - "apps/web/src/clientPersistenceStorage.ts::writeBrowserSavedEnvironmentSecret": "d090fc6388699a9b7b8d8a1986f8538a165377c23920ca8f50b819749650a536", - "apps/web/src/components/AnimatedHeight.tsx::AnimatedHeight": "558e5116edbfb04b2d68954075f90eb0d08abc1a86c04ae38842f0a248e589ac", - "apps/web/src/components/AppSidebarLayout.tsx::AppSidebarLayout": "2db08fc7880cf330af5a5151030ad2d13e164e48f6d0dfae55c87f7ffc7951a0", - "apps/web/src/components/BranchToolbar.logic.ts::resolveBranchSelectionTarget": "70bffa84a8e1e8e069296cc91bc9e13f1202d6f5431de9ec2f74d8cd6a87cb31", - "apps/web/src/components/BranchToolbar.logic.ts::resolveBranchToolbarValue": "2d84032b04810e31f91b5199c845031f95339799fead0000d72e9bf83fc4ac90", - "apps/web/src/components/BranchToolbar.logic.ts::resolveCurrentWorkspaceLabel": "2f07d0e078a1f36afdc8213352639c2663e6c63d85a267b4416821abd28e68a4", - "apps/web/src/components/BranchToolbar.logic.ts::resolveDraftEnvModeAfterBranchChange": "d2433defd1e7a21060b014b0fe5c60934d1ec770fb03d7fefe0ccada1e772526", - "apps/web/src/components/BranchToolbar.logic.ts::resolveEffectiveEnvMode": "a60e3091b04539cd9db65ec0c89fd66614fd6625006df60d9597c83a474c3a8f", - "apps/web/src/components/BranchToolbar.logic.ts::resolveEnvModeLabel": "122b0e6fdde13f5400b8ffccc253d44af58811b42444bca0d5358a7046c85852", - "apps/web/src/components/BranchToolbar.logic.ts::resolveEnvironmentOptionLabel": "b8744b96e591eebfed112d700248eed6969e25b55cb99cb3e00ca509457d66aa", - "apps/web/src/components/BranchToolbar.logic.ts::resolveLockedWorkspaceLabel": "71e44d67e6a17bab2847c521d2485d1e9421483de9ac44cd1a4bb316df650e88", - "apps/web/src/components/BranchToolbar.logic.ts::shouldIncludeBranchPickerItem": "5cce5e4a0f64a6e08d1d7dc7d8bd5ceeea12d53e979fa85ab1956f62bf7b70e9", - "apps/web/src/components/BranchToolbarBranchSelector.tsx::BranchToolbarBranchSelector": "78b58eee9781251104462ac0222053850ee19db549b61d0e474ba6a441fc8d70", - "apps/web/src/components/ChatMarkdown.tsx::CodeHighlightErrorBoundary.constructor": "1d85a9dc46e9df3bf752d4add8218c23dafb7d8be2735bd282a4cdf1c57b2ff0", - "apps/web/src/components/ChatMarkdown.tsx::CodeHighlightErrorBoundary.getDerivedStateFromError": "f18419f378276596f33c8845771c598fe820584aa7f101d17405970592a65db6", - "apps/web/src/components/ChatMarkdown.tsx::CodeHighlightErrorBoundary.render": "128d45c7af6e4bc936ceb350d0633bbcda755c26fff1d4d94c840a0f999cd30c", - "apps/web/src/components/ChatView.logic.ts::buildExpiredTerminalContextToastCopy": "d8ce00bcd58664759c6cc7781ce9d0b2b52368853f67f9103962b1dea9f73abd", - "apps/web/src/components/ChatView.logic.ts::buildLocalDraftThread": "cee1716405ea5ab7a4432ed95df2258506a4a8282da09fedb3e0b0a51541d20a", - "apps/web/src/components/ChatView.logic.ts::cloneComposerImageForRetry": "2c4a2cb4c94cb4fbc424569944985640fec1fc76f27e12f0eb73547946c1408d", - "apps/web/src/components/ChatView.logic.ts::collectUserMessageBlobPreviewUrls": "a0c7a36bc9ea0c424daf9a0367a184a58e4529cd97cb1736638364bc022fb414", - "apps/web/src/components/ChatView.logic.ts::createLocalDispatchSnapshot": "3c74630f9e87b1456804ce9caba474a42373419ae65444437eaeb5f780a9b0b2", - "apps/web/src/components/ChatView.logic.ts::deriveComposerSendState": "3c1bb08b335fe48b41f9869f9af71998a04cd56973e14248d8f66f9f24de58b8", - "apps/web/src/components/ChatView.logic.ts::deriveLockedProvider": "d27bbac4de0258076d92269bb50aab8d953caaf746a18d0b4786fc7a94132768", - "apps/web/src/components/ChatView.logic.ts::hasServerAcknowledgedLocalDispatch": "3d8d78919b7b3b905f0a3a3508665f4d87e776bae2d2a4c4a39e0b398b400dd8", - "apps/web/src/components/ChatView.logic.ts::readFileAsDataUrl": "de731ce129207fb7e203c5ab7c99be059a01a1ed8ba71a7931e018b7cb95cfca", - "apps/web/src/components/ChatView.logic.ts::reconcileMountedTerminalThreadIds": "30323e32e61f56117ecc386b7463e05513ac19263f938a980cd01c0a0e4979bc", - "apps/web/src/components/ChatView.logic.ts::resolveSendEnvMode": "8247ec1852edeef9e97b369e5b191aec1321c61361cf5e76f80651806776efcc", - "apps/web/src/components/ChatView.logic.ts::revokeBlobPreviewUrl": "25d533432db82564cd6540c28ed458a7ddacb476def2bbdb3c47bcb341b96a98", - "apps/web/src/components/ChatView.logic.ts::revokeUserMessagePreviewUrls": "4273f0def9ed05907ae39094c3095c6f3c47b49efe34527f3c61d103aa25e98b", - "apps/web/src/components/ChatView.logic.ts::shouldWriteThreadErrorToCurrentServerThread": "9017cd4a49d605e75b97c30a3bd57cd0f1986c19e51129685654880413beb629", - "apps/web/src/components/ChatView.logic.ts::threadHasStarted": "3962315119fd44d87d9fe68948dcfa68d15237a1b2d43f4bffe03c7ad7a7df93", - "apps/web/src/components/ChatView.logic.ts::waitForStartedServerThread": "96d6d7c692107dd1c964ff179911df0eda0ee20f94ec075f34499171b46296d2", - "apps/web/src/components/ChatView.tsx::ChatView": "3eeae845c6a7cacf81e16ea28e72d5542cbc3ff71c01384266d9cef3114102e9", - "apps/web/src/components/CommandPalette.logic.ts::buildBrowseGroups": "75bea7f846a31a33b63e48ebab020b063b5f15937d98b8d3d64ba54d87ff7f1c", - "apps/web/src/components/CommandPalette.logic.ts::buildProjectActionItems": "737bb9f0ef374fe96631925da33c6a1f7fea193301060febef699835fe8e4c3e", - "apps/web/src/components/CommandPalette.logic.ts::buildRootGroups": "39243f243c0b06c569fed4fe5c496d850235bd689f3dbb0eaffd74f28648562a", - "apps/web/src/components/CommandPalette.logic.ts::buildThreadActionItems": "15f6cb8bb15626989407bf363215cdb0ccf3ea741b07b86b6e2a1266a0a5bd7f", - "apps/web/src/components/CommandPalette.logic.ts::filterBrowseEntries": "7b07b9ab23ce7334e4daf3619679f402d16423240779b7ab902ac36a4dc21dec", - "apps/web/src/components/CommandPalette.logic.ts::filterCommandPaletteGroups": "d966029b89decb01d1f99de23a1c8b50fdeb9966c1d3c17426e76ce22080145a", - "apps/web/src/components/CommandPalette.logic.ts::getCommandPaletteInputPlaceholder": "82399834c56ef40465b26a6f58d089b9a7a0210c5645846ce271a874455c07d9", - "apps/web/src/components/CommandPalette.logic.ts::getCommandPaletteMode": "8aafb4ca874008ffb349aca4c29b27dfe361da00e4310a7fefda1fd7ec4b9efc", - "apps/web/src/components/CommandPalette.logic.ts::normalizeSearchText": "1d09b2be47e842f0e5bcf0800c9ff3fc4194d71e56fe56f35684bf984d9f6da6", - "apps/web/src/components/CommandPalette.tsx::CommandPalette": "f3726d34e88411bb780e6c4eba061c26cae381b4dabee0449c43bdb2b2cb2a6f", - "apps/web/src/components/CommandPaletteResults.tsx::CommandPaletteResults": "ad8f206ae3001579a8cf887d33016b253fc3ae521ac582e949aa46d43b911a17", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerMentionNode.clone": "1b736c5414c0c119128f153faca02d912929aaff6139cb3cd706a622af92dd4f", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerMentionNode.constructor": "7d4f4553bf97dd8fd349a29831d0d6dbbb3ade4d594260314493b50dac272458", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerMentionNode.createDOM": "75f96e2eb80c1df05f15949ee15c6f514bcee99e6cc79fa447b02de44c0f2a82", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerMentionNode.decorate": "0ead3563dfc16d23b840d3da97bbd8291ce48f585cc99cf8fb160714127f806b", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerMentionNode.exportJSON": "cc4d50a7bab1b585422f8786fc9a435ec4d45cbc13543c21d7169251428837bb", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerMentionNode.getTextContent": "eedea942b33aa17fa9cb6ce258f93cc043d0432031782001c2b534b0cebb7e78", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerMentionNode.getType": "e4d017f8f29c47f27e5205cbbdd1304bd770eaec76073aef64de401091f504ae", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerMentionNode.importJSON": "3693fb7e2b108a2af5f31b2cf2535b95ce230155d894f3df52dcbfd19b6bdf25", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerMentionNode.isInline": "481b41206e1be7e41617f899a50d0cc5ce4fce6ab0e87289e9967b7954102f6b", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerMentionNode.updateDOM": "3263730709ee6ba36f6d7e8f0f23fd691c4316355ed78497e09d61bf0bf95d64", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerPromptEditor": "a2b4ddde465a7c06b6bf06d1fd649a67e974f3835c4654236b46bb7e7ab19b7d", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerSkillNode.clone": "ce25988aa76b2d6ff30656ce721b1481af374fb3f03081bfae22290134d5103a", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerSkillNode.constructor": "450b123e9bb64b926307bd0d16b6b093ab4ddc28ef880f5153e44657822a47de", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerSkillNode.createDOM": "760df11681be03f6c772643ebd29a99b98abb2052ce19aef45379f8208753e7d", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerSkillNode.decorate": "c4dc5a168bde339e9d081286286e0f74c68fcaed65ed53f4853de9d2b8c73c4a", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerSkillNode.exportJSON": "d5f78609997fd27fca9636596eacc1338c590fd9daa237c29c800f14bc14a18c", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerSkillNode.getTextContent": "4d2528af556c9c405f79d34441873f241116d0bba6377538959ed6de52496778", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerSkillNode.getType": "1ed9ba3f1ba0a3345fbbe4d620aa7dabb3dfb4e09ef45229055a296b4f78427c", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerSkillNode.importJSON": "2bb0339fdd6be39bfd0b0f76612fe1dfce064f449ab87089133ef1139351000d", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerSkillNode.isInline": "b715c17fc771141494f3d67101c2a9c269d536d592a89cf3f6d9da350c88722f", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerSkillNode.updateDOM": "5a4109782efa821f32dfa24a3352407f32b6dff8ffe874d8b57316fdc7068602", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerTerminalContextNode.clone": "35dfa4b3de402d706020d112ffcea996d65bda088eb95572c776765dea5791bf", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerTerminalContextNode.constructor": "52c42c9e3e4c8437a459ad4f8b1846e555a2203b14f37b2b234de71cd0915d7a", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerTerminalContextNode.createDOM": "aaa83ede9a8b21f3bf4000cd20bad0964143821ef3514e4d71bfe432fa8a1478", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerTerminalContextNode.decorate": "cf18e092a19f5901c128e0a6cb5c07a0c7a3a581c717cff07386f3ad1828c0c9", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerTerminalContextNode.exportJSON": "247bebc5d929d2ce8e00e3ed8868976eb02b13cb5c300a944b1d9b7770353518", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerTerminalContextNode.getTextContent": "3e0e4e6e4a17d7412e8531299486a616e7d9fe6dbd67468a78a87416b3a625b8", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerTerminalContextNode.getType": "b7fdd8b2bc86ee698b30595d24ae844245af0237574b48bed8e3cbb1e4c12ce7", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerTerminalContextNode.importJSON": "a07792287602114a0ca41d12b832e7d20342a1f8cc47eb15f2b58e8328549de4", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerTerminalContextNode.isInline": "cf5b30d23b1baf42f4c154a571056a5b5a61fa55dc6a4ff7f1debad13aa9a69e", - "apps/web/src/components/ComposerPromptEditor.tsx::ComposerTerminalContextNode.updateDOM": "a9ecdf850efc3946f2b792abb2c945e3342d48ec4d49c59ea8daba0ae48b3f11", - "apps/web/src/components/DiffPanel.tsx::DiffPanel": "99799b40df0c6778f41cde3f63228c3206a633d635c0bb295d706187ce7afd2f", - "apps/web/src/components/DiffPanelShell.tsx::DiffPanelHeaderSkeleton": "dc8a054a92c9d0796f9d2f784e49c413408417766b7312a482156513cfe6299e", - "apps/web/src/components/DiffPanelShell.tsx::DiffPanelLoadingState": "2b3b0d79d9986c5d5088c3691b9e0524a86f021aa4549e68423dd06a227633e1", - "apps/web/src/components/DiffPanelShell.tsx::DiffPanelShell": "cc151ada12c1a01317a483646b2360314269a95c2bcba3286c95e729213e27a6", - "apps/web/src/components/DiffWorkerPoolProvider.tsx::DiffWorkerPoolProvider": "d354f3cc131141659349ff52337cf0a0c9f9e29c63ff10505eb58c3643462ffa", - "apps/web/src/components/GitActionsControl.logic.ts::buildGitActionProgressStages": "66e7e617abdaac0134ce29dd48c2bf6ef8118ab04db51409b1c1f6f2429a3823", - "apps/web/src/components/GitActionsControl.logic.ts::buildMenuItems": "4f210819c4c5509db51c30d0c7267d6470ed50470b7072be4fb5396b7aa0de9b", - "apps/web/src/components/GitActionsControl.logic.ts::requiresDefaultBranchConfirmation": "0cf1c83fb6ee93de3c8859097c831ac8f878b63d55ae25b56cfc9b74370d93f2", - "apps/web/src/components/GitActionsControl.logic.ts::resolveDefaultBranchActionDialogCopy": "8b2ab94da13dc3e1159b9bdefa5d461334193dc7d9a2b8f178e1620c1981888e", - "apps/web/src/components/GitActionsControl.logic.ts::resolveLiveThreadBranchUpdate": "119a6c8c96fa82a7a4e8fe8c0daab002b8da6e5a3a07ea233dd9c104293b5c44", - "apps/web/src/components/GitActionsControl.logic.ts::resolveQuickAction": "5fba6d4d7e908481349fae8ffc6d61c3469d8b8d1392be408e922dfaf1afea70", - "apps/web/src/components/GitActionsControl.logic.ts::resolveThreadBranchUpdate": "fec4d192d3656bd51f9fe6c9dcfd278e15f0b2186e4b7e310172dadf328e6055", - "apps/web/src/components/GitActionsControl.tsx::__file_sha256__": "c75b4935e4165bc8c9f8ced92ccd276e060a0ee37a6b6145d8193032f9d45906", - "apps/web/src/components/Icons.tsx::ACPRegistryIcon": "ac6b341a8ccd12b2412f7648f12075e72dea31e2ae4b64b6ac5c6b7375414aa7", - "apps/web/src/components/Icons.tsx::AntigravityIcon": "2601bddd001cdfa7780fb865aa87d0396cc99c7f1015038984f5abcef6cf7a24", - "apps/web/src/components/Icons.tsx::AzureDevOpsIcon": "86829714aeae301d0ecb8707724975117fefbf62a6fc087ae8efa56bd500cd1a", - "apps/web/src/components/Icons.tsx::BitbucketIcon": "67e986d77f2474a59a463450ef79418c80b29930601a0fd4bd6c358fb20b8986", - "apps/web/src/components/Icons.tsx::ClaudeAI": "7d5002328fe2c32c53c907f5e1cc9c22cde9a0a8dbe1debf4c0cf33362a2c412", - "apps/web/src/components/Icons.tsx::CursorIcon": "76b757419aaa2c5563d1c0c47a67cab3b3a035532589bf60029c7904e8d09dbe", - "apps/web/src/components/Icons.tsx::Gemini": "8ddfd17456dddafb9305960bfd8630787700f52b17da04ba74aa672f90d1f2fe", - "apps/web/src/components/Icons.tsx::GitHubIcon": "9e2691a184a1f4f3a745144d1585d86236f6b49ebbe7098ae592025927559451", - "apps/web/src/components/Icons.tsx::GitIcon": "7c1f3a0f8be140dd521141675c46f649fe4314376922bc9f03dc3fc093d85211", - "apps/web/src/components/Icons.tsx::GitLabIcon": "3735249abc40d79075cd4984276bcfaca1074b6551785d44b5f73fec31827176", - "apps/web/src/components/Icons.tsx::GithubCopilotIcon": "b2c942dca328bad090cb4f4461259dbad259caa142e0740133952b33fc322964", - "apps/web/src/components/Icons.tsx::JujutsuIcon": "8fe42c24bf2427c38ecefc2e276db5836c631b6eb3b9e2c3788aa3206b4cc2a6", - "apps/web/src/components/Icons.tsx::KiroIcon": "98cb4a644a0c44d590d20acaaf94c0e7b00e8d4de39cb3359a28cfe66af2d6d2", - "apps/web/src/components/Icons.tsx::OpenAI": "4266317856de07d22d2c51deb72230f5e808d211b0feb9ea6fef9813fbee601e", - "apps/web/src/components/Icons.tsx::OpenCodeIcon": "572b3db3ed5773239280de4798282388aee061d3256dd8665c2c8dcf358055f9", - "apps/web/src/components/Icons.tsx::PiAgentIcon": "4d8409e99335d765334d3cafe90731f3dfdfbb25e66c5f18d117b177d9814b29", - "apps/web/src/components/Icons.tsx::TraeIcon": "7d9196b09bcf353da4e86ea6028c9f784e7bc2c7d1e0739007741f1b89f3aa5d", - "apps/web/src/components/Icons.tsx::VSCodium": "39268f0b54e0025f986fd9ad4e9581c17e9efecaeab857775e3aee2a528605ce", - "apps/web/src/components/Icons.tsx::VisualStudioCode": "a81ed23ebd5e414eaffd3194700bb80413a13cd4a41a753c2277d44bddd342ab", - "apps/web/src/components/Icons.tsx::VisualStudioCodeInsiders": "6c76bff6ef576d794b6197f2d25b4533d6f7dde17aca9a522898b82b5fcea63a", - "apps/web/src/components/Icons.tsx::Zed": "1fb7c1c4c9a885b80f903179d6962def60275c915a39b4313aaa5421d6c118fd", - "apps/web/src/components/JetBrainsIcons.tsx::AquaIcon": "d29da3d6dd165013375e5b9f1b41d0f707364b9cd7de945681b295930e1163cd", - "apps/web/src/components/JetBrainsIcons.tsx::CLionIcon": "a4758e9480a2a92039a395e62b553220d60abec69f64d9086d17e68a2eb14716", - "apps/web/src/components/JetBrainsIcons.tsx::DataGripIcon": "b73e15b16b22bbe9207e39f1a9515d258cef6fd53f1fc144adf6cba5e9c704eb", - "apps/web/src/components/JetBrainsIcons.tsx::DataSpellIcon": "a89d5c559c67c3fb24516f2e35c25b99fb5fbcb1d3ed6129562da5480eeae817", - "apps/web/src/components/JetBrainsIcons.tsx::GoLandIcon": "2235ce5a2202b29f94f8f6adc25656843a8fa453e3fa048719e346ec5741857a", - "apps/web/src/components/JetBrainsIcons.tsx::IntelliJIdeaIcon": "5e7c066f5512e7ec885992608888a485b82465f3a3e9769bfbc8b92c41b6474e", - "apps/web/src/components/JetBrainsIcons.tsx::PhpStormIcon": "4979310ac65da2652dd4b2bfa89882802e98c177b25a9b192a550e45a0efc6c8", - "apps/web/src/components/JetBrainsIcons.tsx::PyCharmIcon": "d0d82cda309d6ea1f682de0b0ddd931b6dffc62b41c1372a4080410352824981", - "apps/web/src/components/JetBrainsIcons.tsx::RiderIcon": "2208ccc9775c645e45eb1a22d65ed4c5a78fbe24bf30cddad04170afd3a687c2", - "apps/web/src/components/JetBrainsIcons.tsx::RubyMineIcon": "e1d77dfdd3a73f2d7ea27d9e212018609a1f5820305459d82262012edd1838ff", - "apps/web/src/components/JetBrainsIcons.tsx::RustRoverIcon": "1f82076db851aa4efd32f238cc46d82d362ced22408d357e44e8c9486363a335", - "apps/web/src/components/JetBrainsIcons.tsx::WebStormIcon": "93f41e614c913d7e5e6bcf981c8c4d286ac457e64e25a119ce8b764a014bbc8a", - "apps/web/src/components/NoActiveThreadState.tsx::NoActiveThreadState": "089600f7cefa98025fbeb4b986cb8619bb486a1dc044ca8984eb6efa27a73122", - "apps/web/src/components/ProjectFavicon.tsx::ProjectFavicon": "1ea2cae4f495f99f5d7d9c0ca9b345d60ba765404c98c5fd96140fe9a04bb40f", - "apps/web/src/components/ProjectScriptsControl.tsx::ProjectScriptsControl": "fbc6ce7ae4c25a8e2be9383a48a83d1ff9dbd00609930c0935b6d5257f16838b", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::canOneClickUpdateProviderCandidate": "127e5da6a5baf14e02a7562b740b7b1d92f5b3f24812fca4bc7d795ff90e0eea", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::collectProviderUpdateCandidates": "65ea7cddb74f65014eaa472d782dd683d33d19fc5d21e24d04529443bda8c2de", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::collectUpdatedProviderSnapshots": "4c11f7e671a2e9908e65a1fd0170a7ac67894887c6ffa2de65b5472c52983b2f", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::firstRejectedProviderUpdateMessage": "0e5201be3716c3889a07070dc8c2653c557adfe05daa3fd92d6d3863536821d1", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::formatProviderList": "0b34be34ef589c62dfe1423cdb47360214fbd71da52c8938c826c4c2d8ca10dc", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::getProviderUpdateInitialToastView": "2f1e1077b642e2d87e8c8f7775eaf00172a3439685d42f16dfec2246560040fb", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::getProviderUpdateProgressToastView": "775b2c7e6cce17df8f9d961ec76cec6282cb7370eb5479ddff02cca0b28327d1", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::getProviderUpdateRejectedToastView": "c4f0c8c51f3e781233e0f807855b1aeea9cb381275012db04b113f70a8535fd1", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::getProviderUpdateRunningToastView": "6e8158272295c9212ff5ca0ad459d25b14c5c4904ba95d37a52cd4b13f64d477", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::getProviderUpdateSidebarPillView": "c1aa83b6c8aba59566f2c57acadab23274d8f68e1847cb76c83bc0e4775d4472", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::getSingleProviderUpdateProgressToastView": "165c1af68a1b7cae87b924fdbeff9af2315d5612916ecb2aae4e3019d836d923", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::hasOneClickUpdateProviderCandidate": "1fec1ae936f37c72e26b1db4c2f51c59b0406aef3ea12969b3282eb354a0474d", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::isProviderUpdateActive": "28db29effb4c9db55e8f0d1cf90662a04eb50a75cb3f736c1149c7d1761cf466", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::isProviderUpdateCandidate": "c8b34e2c03bf87824eef9425c1ba120b6d3ca33af25f992e73fc8cf3a89b4a10", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::providerUpdateCandidateKey": "b6f1e1d6e6bd69408cbd97c5ec1f819d2362940c166665a06d5a3c74d696a7fa", - "apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts::providerUpdateNotificationKey": "4fad5bb15d913ba71e54ab9355ab515a6e7d4aafa1415351f7424410f13cb191", - "apps/web/src/components/ProviderUpdateLaunchNotification.tsx::ProviderUpdateLaunchNotification": "87279047eb0cff4b41ff132fd585a86cd70128cf27123fa4e2ca5bb888660525", - "apps/web/src/components/PullRequestThreadDialog.tsx::PullRequestThreadDialog": "24aa2b954de5892d2869c134f57447098ae147a2da7f974bc989a5e00813afa2", - "apps/web/src/components/RightPanelSheet.tsx::RightPanelSheet": "4eb4008060bd100e36834c243eb1d4f8fbc16addbc2d8d88a1c9c1aab66ecc52", - "apps/web/src/components/Sidebar.logic.ts::createThreadJumpHintVisibilityController": "188c8216c5d950242d84daacc9234f877ce2bea1f7c80650bcb5652214245d01", - "apps/web/src/components/Sidebar.logic.ts::getFallbackThreadIdAfterDelete": "e4cd2e6e0cdf6ccaa020a219c977dc8fb83fe0eca8d762b38e0b67dc43af8f21", - "apps/web/src/components/Sidebar.logic.ts::getProjectSortTimestamp": "46b652c049ad7f848a9606c39a1b356870ab1014d014199208b052a804d5295f", - "apps/web/src/components/Sidebar.logic.ts::getSidebarThreadIdsToPrewarm": "c47de3c9b1c45b0bb1cd920e36db416b71d985f73b3aba23da2677014bb17237", - "apps/web/src/components/Sidebar.logic.ts::getVisibleSidebarThreadIds": "326ecc18dae6c7cbbf030608f7967ad483f7d7f2bc81f9854839b344d0a335d4", - "apps/web/src/components/Sidebar.logic.ts::getVisibleThreadsForProject": "d969d1bf672f91f33d0fac532fcc2dcd313e1ffe4c834e6f8c1b95e1ad51a5d4", - "apps/web/src/components/Sidebar.logic.ts::hasUnseenCompletion": "fc366eadeb21210df99d205e366ef528be48aafe3ba09b0b247864857856f0b1", - "apps/web/src/components/Sidebar.logic.ts::isContextMenuPointerDown": "d5d79ac944d3cb218b2489365746bd855b616c97a2c0341d72cabf92aa4aab04", - "apps/web/src/components/Sidebar.logic.ts::orderItemsByPreferredIds": "94d24025aefa022fac5753ef4cffc55f21243c5ddbe69507376f9059c4c4ebdb", - "apps/web/src/components/Sidebar.logic.ts::resolveAdjacentThreadId": "4bcdfed3b42028def92c30d93f1ec416680d5734f888c7c0f785544dbccd4907", - "apps/web/src/components/Sidebar.logic.ts::resolveProjectStatusIndicator": "080a5876add5573e46e93d1d38c613d588637cf08fff77139619f13daaa7841a", - "apps/web/src/components/Sidebar.logic.ts::resolveSidebarNewThreadEnvMode": "0571b6970677645c58cd8a8521735b5c6eaee8dbf591db73ea603addd2ee8eaf", - "apps/web/src/components/Sidebar.logic.ts::resolveSidebarNewThreadSeedContext": "ff23c969ecbfa129c85c6b3a3808b25390714bf284bc4fb6e9dd34d0b6767f36", - "apps/web/src/components/Sidebar.logic.ts::resolveThreadRowClassName": "dcb1312aa96536ee0869258a5a6725853a0c1cf90848c40a8a5d28e47da14fca", - "apps/web/src/components/Sidebar.logic.ts::resolveThreadStatusPill": "7e984cef874080dcde7b4963d353195ce05efb5cd47f5697a6e1a1197031abaa", - "apps/web/src/components/Sidebar.logic.ts::shouldClearThreadSelectionOnMouseDown": "39fb5510e2a4d9b67b232ddf13544dcf4b9314363c040072c00316ee33eb7e2d", - "apps/web/src/components/Sidebar.logic.ts::sortProjectsForSidebar": "4dd2dc23571002c9b09c95b2059e9b42a0aa43f9e16d9e07f20d198df72a046b", - "apps/web/src/components/Sidebar.logic.ts::useThreadJumpHintVisibility": "efb969840884c613913423e18c760a5e297d3beee7c58476bf482f559b836bdd", - "apps/web/src/components/Sidebar.tsx::Sidebar": "9c9f64e58265f412eae2e0963dd9a3f3bb813f2d76aa255dcd60bfe9c33d6c47", - "apps/web/src/components/SplashScreen.tsx::SplashScreen": "0b9d08e70c868c253349a66c8c58d72aa0ae4b828eb4aa939995d83189d38a88", - "apps/web/src/components/ThreadStatusIndicators.tsx::ChangeRequestStatusIcon": "8e678d17f275240811f216d17000c7e66bdb93d4c9597e5682b3ef8f9cbae04c", - "apps/web/src/components/ThreadStatusIndicators.tsx::ThreadRowLeadingStatus": "3ddb5e097d57a4226c73cb0af0b01e125f0e55fe40c5c05361cbe611de8ff0fb", - "apps/web/src/components/ThreadStatusIndicators.tsx::ThreadRowTrailingStatus": "d696ec1151012dfa0aa25be3054533e485b2c66fc707bb4b66853e150784962c", - "apps/web/src/components/ThreadStatusIndicators.tsx::ThreadStatusLabel": "a41f2ed552a3b0918191bb41426d3edd9b9b50699e76f26ee35fdd7468d5bd22", - "apps/web/src/components/ThreadStatusIndicators.tsx::prStatusIndicator": "ad2e23c16ee84894c3cb30b46ecfe9ecadb800cb8dcb3040574eed40d83b4f65", - "apps/web/src/components/ThreadStatusIndicators.tsx::resolveThreadPr": "c1e80c2da51f7c2c00aa81844d0d19d399c5fd408ed7853246fa54fab7bbf6bd", - "apps/web/src/components/ThreadStatusIndicators.tsx::terminalStatusFromRunningIds": "e72b13b906119aff17c3a18ead799c8096531b03d192ad135d31ca9ac0487e7e", - "apps/web/src/components/ThreadTerminalDrawer.tsx::TerminalViewport": "ab7eb6ddbec383e66330f93f20111ea0ada7d5a0a7845aef27280a4841f80458", - "apps/web/src/components/ThreadTerminalDrawer.tsx::ThreadTerminalDrawer": "601568d3dd8eee23b43839e59c54f223877328a756d459003577bfcdacdea8cd", - "apps/web/src/components/ThreadTerminalDrawer.tsx::resolveTerminalSelectionActionPosition": "35a42eac9ad5ed88e4f4c6bfff6d079a185163148b6f96c25432ee4025708237", - "apps/web/src/components/ThreadTerminalDrawer.tsx::selectPendingTerminalEventEntries": "21853ac0c36daf29c7b5609ffff628018f282fe131131577d27bd193a2373b0f", - "apps/web/src/components/ThreadTerminalDrawer.tsx::selectTerminalEventEntriesAfterSnapshot": "29c3e213435a4a09004ac2a881a9e89e37eecd83466cc415c78dc4d7a17b41e4", - "apps/web/src/components/ThreadTerminalDrawer.tsx::shouldHandleTerminalSelectionMouseUp": "da0760e5fc172990200759dbfed5dd12d32383eacf2b2354e84c86369c6f92e3", - "apps/web/src/components/ThreadTerminalDrawer.tsx::terminalSelectionActionDelayForClickCount": "425e88cdb15ad72593ea5967ad7da6d9fc77792d095dc2002ea87d08670cab2b", - "apps/web/src/components/WebSocketConnectionSurface.tsx::SlowRpcAckToastCoordinator": "c4c0360ef3fd3e09952cc4fb3c092eafc12355f274a5a5cfd6643acdf054f19e", - "apps/web/src/components/WebSocketConnectionSurface.tsx::WebSocketConnectionCoordinator": "9869c8732276fc094c5c48fce8090c2968f8b933878fd34ba30ce2dbc403f76c", - "apps/web/src/components/WebSocketConnectionSurface.tsx::WebSocketConnectionSurface": "012f35aa1045fa7253a42e7d001462dc33169ac935f1ba34dab2599e378c29fc", - "apps/web/src/components/WebSocketConnectionSurface.tsx::shouldAutoReconnect": "3fff9f29b567c26d1910d2938d4fe07c1c804f2983514e768c0f03f9ca092ffe", - "apps/web/src/components/WebSocketConnectionSurface.tsx::shouldRestartStalledReconnect": "01abc9c4bc8232f897631b92bd94f4004faee48579340e05deb32fbcac3f5cc1", - "apps/web/src/components/auth/PairingRouteSurface.tsx::HostedPairingRouteSurface": "5e74d19a2bcf88e85de91b2eb666bb8bffc73d3bf1cff7cc70a9fe768810c1d4", - "apps/web/src/components/auth/PairingRouteSurface.tsx::PairingPendingSurface": "b352fe3bacb4066dd7c38823f97cd1a138281fccb1ef9812cde32aa9f4360c22", - "apps/web/src/components/auth/PairingRouteSurface.tsx::PairingRouteSurface": "efab2dbda6cc25c42c3ddbfe4817af9486c5ac8a4d8c1a94c13be8dfe30e6cfd", - "apps/web/src/components/chat/ChangedFilesTree.tsx::renderTreeNode": "a62445772e9083a1107274354c9d2f0693343a56e89af612193437ff2564d4ca", - "apps/web/src/components/chat/ChatComposer.tsx::addComposerImages": "7e9f7f0956bccc9e18cc1219f9a93c41e6ea837103b65ef2eec9a5c469600839", - "apps/web/src/components/chat/ChatComposer.tsx::getPersistedAttachmentsForThread": "a840f53bfe1ce08980c29166e1c5f8b01794278fdfc97e9cee64a780f8338144", - "apps/web/src/components/chat/ChatComposer.tsx::measureComposerFormWidth": "1dacbb6e1a35e9311d2c06c218cc35e0676eb3361053b4f0c6ab3ae3c7c96b32", - "apps/web/src/components/chat/ChatComposer.tsx::measureFooterCompactness": "98c02e4485ee10ece40ea0150a3aac04fc6bfaf36ddfe0e806fe79a87f2fef31", - "apps/web/src/components/chat/ChatComposer.tsx::onComposerCommandKey": "86ca2ab5f609d44e17df5468644e58898feeea8adcd5135b42cacc37794ddfd1", - "apps/web/src/components/chat/ChatComposer.tsx::onComposerDragEnter": "97dc058275b49de1397da70b04b01ea8560046d6ecfdb9cf09cbedbfc00e84d5", - "apps/web/src/components/chat/ChatComposer.tsx::onComposerDragLeave": "2a85dfc2e2cacd338fbfb2b887469edfd95dbea43e2b651163e96eda6631cd3b", - "apps/web/src/components/chat/ChatComposer.tsx::onComposerDragOver": "f7dc3d9c4945490ca167220fb9c0c4dbd80998bc14a84a5efaf84792dca6da1d", - "apps/web/src/components/chat/ChatComposer.tsx::onComposerDrop": "f7a7356e8b718bdd5823332a0d35dd2a3918a5d56d65ad6bd750af4d4a47a552", - "apps/web/src/components/chat/ChatComposer.tsx::onComposerPaste": "f458fc6aec2f5341f4a9eb374662feabd2b0dda376ca1de9bddfe99c0558c517", - "apps/web/src/components/chat/ChatComposer.tsx::removeComposerImage": "1d6afed0e34a60d9f5e89c47cce27b047a8fa687e609ea47c6b45ae34013f643", - "apps/web/src/components/chat/ChatHeader.tsx::shouldShowOpenInPicker": "30e878f12140b6ebc7e2a92a6a45e549d1a8fe5a793912b6e54d8a3740fd36c8", - "apps/web/src/components/chat/ComposerBannerStack.tsx::ComposerBannerStack": "45989c2e63f39b5a102246c5f934e6c4cdc3fd8ea162b785457f332f517cbd7e", - "apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx::ComposerPendingTerminalContextChip": "17ad79e927fd6f645456edaf3eb93bc0ed36cca1154936b914b2a9201e0108c2", - "apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx::ComposerPendingTerminalContexts": "446653e94a6e60fa8ae30737b8b0898dcccd4e3ba4d2e084b7083164a284ccfa", - "apps/web/src/components/chat/ComposerPrimaryActions.tsx::formatPendingPrimaryActionLabel": "dd1fa367095ebeca91c7c85cbd75f69f33676de274a5b10794c339984b5d4865", - "apps/web/src/components/chat/ContextWindowMeter.tsx::ContextWindowMeter": "7c2bc349fefcc7abee4bc5063017cb0005cf9d1552fd73f0dff10c24d8c57f79", - "apps/web/src/components/chat/DiffStatLabel.tsx::hasNonZeroStat": "6e47683314b9ce1b9ff3a80dbf18b7f70f7e772834c210a2d5c124d962e381da", - "apps/web/src/components/chat/ExpandedImageDialog.tsx::onKeyDown": "d0a99a3fa295968b21b33a9d82c7cd430da838c2d60ae7b9de673375609b434b", - "apps/web/src/components/chat/ExpandedImagePreview.tsx::buildExpandedImagePreview": "fac91344c3f2441740681aadd30de644a17aff7f96c7f18441a5af736fccdfe6", - "apps/web/src/components/chat/MessagesTimeline.logic.ts::computeMessageDurationStart": "aea06a5b336ed58a859416d0f171f0ffab6edc2e015502f79a0f99401ef2acd8", - "apps/web/src/components/chat/MessagesTimeline.logic.ts::computeStableMessagesTimelineRows": "3592578d630b2a268f2658c409fe6c07eed0fc268bf4e581ad6579dc4a9645dd", - "apps/web/src/components/chat/MessagesTimeline.logic.ts::deriveMessagesTimelineRows": "9d79b44bb00c38500e81c1bd3330c0a84b9f7872ba1ea52f3a3014442b6ccadd", - "apps/web/src/components/chat/MessagesTimeline.logic.ts::normalizeCompactToolLabel": "22cfc4133dc458ecd565b34315bd767609531fad5a2b37fa7ed190dcc83a1605", - "apps/web/src/components/chat/MessagesTimeline.logic.ts::resolveAssistantMessageCopyState": "60cd78bfcb4c70a29c29d096a2482309f20f2d51cf59b5fbeaa4bd942737402c", - "apps/web/src/components/chat/ModelPickerContent.tsx::measureScrollArea": "2f7e0aca7240fcb4eef4759d8458f8c68461275f21cdfe2d50da2852547d2d6e", - "apps/web/src/components/chat/ModelPickerContent.tsx::onWindowKeyDown": "d1edc8fccf2dff729071bafd5adfffb7bb3960e32f5787fd02012f90f1e55c5e", - "apps/web/src/components/chat/ModelPickerSidebar.tsx::handleSelect": "eb76fe0380f5402ad50770a1e5768034cd144ae8d7ddb37186546a91c84909b7", - "apps/web/src/components/chat/OpenInPicker.tsx::handler": "9a4c265c8409c7c21e872efe964c53f3c4d7f5aefcf398b3a2feb4c2909cd94f", - "apps/web/src/components/chat/ProposedPlanCard.tsx::handleCopyPlan": "12a9909236f7d757954bab00e72d52221005cca2987d9f02878d88520e80f8db", - "apps/web/src/components/chat/ProposedPlanCard.tsx::handleDownload": "8d728f8d0ba8b340f2a31b1e307ae9cd1fca5dcc29defc95e42796fe4243ddc7", - "apps/web/src/components/chat/ProposedPlanCard.tsx::handleSaveToWorkspace": "531a859c70744cf3ac850cbadab14379cdf5065e87176ea0d13fa5b9b23460b1", - "apps/web/src/components/chat/ProposedPlanCard.tsx::openSaveDialog": "6231f24dfdc987dfc22c8def6f102944d0e02235cf74823c1424f947c78ee5ca", - "apps/web/src/components/chat/ProviderInstanceIcon.tsx::providerInstanceInitials": "5611a2a73e1bc8fc3eab3e697d747e9ea5a0d8984e0f9f896ee8991475504d9c", - "apps/web/src/components/chat/ProviderModelPicker.tsx::handleInstanceModelChange": "1e45c9c6ee7d62f3c49cb8a533e27874f5af2dc7de67c17d16895fdfad5e6316", - "apps/web/src/components/chat/ProviderModelPicker.tsx::setIsMenuOpen": "4c307010a56418b0129a0f24deafed071d77e1e5b220b3bfd4b9d292de199ecd", - "apps/web/src/components/chat/SkillInlineText.tsx::SkillInlineText": "504eb078a172f3485f6305e246d16cb1f44ab87138d909380553d5e825134453", - "apps/web/src/components/chat/SkillInlineText.tsx::renderSkillInlineMarkdownChildren": "a0844b4859c28b2d2dfe03fba0e595ebb22bd0f7809ca8205ef1607a35530cc6", - "apps/web/src/components/chat/TerminalContextInlineChip.tsx::TerminalContextInlineChip": "a78f05f66546ef01d173eb744e19d7ed25ac4dfb3d7736caa03f8617d26442d4", - "apps/web/src/components/chat/TraitsPicker.tsx::handleSelectChange": "759c45f757268d10726ee62cd793d44e801ea9cec07fd7d053f3cc53d321d6e0", - "apps/web/src/components/chat/TraitsPicker.tsx::shouldRenderTraitsControls": "1ada12b48f33c63acf8efe18b7b029da4f3c6a2bc0eb9504922eb6c19ab6af85", - "apps/web/src/components/chat/TraitsPicker.tsx::updateDescriptors": "20ad635b1e22116ae4cf8a2e6dcd771d1ac66a042f9816dfa87c97a44434fea5", - "apps/web/src/components/chat/composerMenuHighlight.ts::resolveComposerMenuActiveItemId": "b377e4b25c8df0c3883b54da9b536aa687bb20a4c4306dd1f5f18d17c64b01e3", - "apps/web/src/components/chat/composerProviderState.tsx::getComposerProviderState": "c119d93a9dc29322b24e268273542f16e4fb3e033bf96573ad919857538639e6", - "apps/web/src/components/chat/composerProviderState.tsx::renderProviderTraitsMenuContent": "2a7961919e3bec085e4f0c74b4fb4d82e6ef8b14b2edd5119baa5e0adba03d11", - "apps/web/src/components/chat/composerProviderState.tsx::renderProviderTraitsPicker": "16d35b4c2d176e274a8e4339e6b466fbc4a430c26c7d7a652d4ad87ebd5e915d", - "apps/web/src/components/chat/composerSlashCommandSearch.ts::searchSlashCommandItems": "46c69a9bbbfa4934998a6433330b14c857afe5216f1401c8f86a73fbd38eb550", - "apps/web/src/components/chat/modelPickerModelHighlights.ts::isModelPickerNewModel": "4ae19ef606c32904f19afd8b87978fb33b3500aebd310487e70a72652640d6d9", - "apps/web/src/components/chat/modelPickerSearch.ts::buildModelPickerSearchText": "9f215e60914fbc169615a1fa681a0e49eaee5362740de5bae840bf8f406ed6b8", - "apps/web/src/components/chat/modelPickerSearch.ts::scoreModelPickerSearch": "0e5d14a5c646368f5ffbf92aced09b9e3d6ceff6fc68786dbd393231123e4688", - "apps/web/src/components/chat/providerIconUtils.ts::getDisplayModelName": "fbb30390450e72286ec4f4b0ec4a6d37829afe8250855e2b087cfc444fa8665b", - "apps/web/src/components/chat/providerIconUtils.ts::getTriggerDisplayModelLabel": "a2bf4e31e2791d2136e524e20a09da238670a99779bb9b0657faa8f548c97a1c", - "apps/web/src/components/chat/providerIconUtils.ts::getTriggerDisplayModelName": "fbb9ad7574d68eb29cb5f16e5f2ca45679df5828c4922c47d977bf0cbd0b86a1", - "apps/web/src/components/chat/userMessageTerminalContexts.ts::buildInlineTerminalContextText": "0615d650d94ba020d1bfa887181bba63893734be585b750add9b49c4a7c5d3f7", - "apps/web/src/components/chat/userMessageTerminalContexts.ts::formatInlineTerminalContextLabel": "da58682716628c34b715c7313c1c2251ba7bf34f80519bd78ed84945e742bbe6", - "apps/web/src/components/chat/userMessageTerminalContexts.ts::textContainsInlineTerminalContextLabels": "66485f3c5b77553de40cc10efbf64d13587ee8deba13dfc6a5dbb4a9cfb8aafc", - "apps/web/src/components/composerFooterLayout.ts::shouldUseCompactComposerFooter": "dde0ba5ac3570f3157e898d89b0fbb0da97ad89b861d2530e25a20a0a7a81a9f", - "apps/web/src/components/composerFooterLayout.ts::shouldUseCompactComposerPrimaryActions": "c872c8571481869ef212fbae3ef53be2445ea579afd0d7a0c8e95ced8e0dd4bd", - "apps/web/src/components/desktop/SshPasswordPromptDialog.tsx::SshPasswordPromptDialog": "19ada60501d20c45f7ad53b251dd7cab324ae380c0387dfc7408e65738a953d5", - "apps/web/src/components/desktopUpdate.logic.ts::canCheckForUpdate": "053b5d5c5f68332a58a24fcce171628bb5ae46887f2a7db517db9e821103adaf", - "apps/web/src/components/desktopUpdate.logic.ts::getArm64IntelBuildWarningDescription": "2814ad3b5e10d8626fd0a6489d0dd03065ede51abfdcb3e2887a5d37709ee023", - "apps/web/src/components/desktopUpdate.logic.ts::getDesktopUpdateActionError": "25c426b7c1975ddd9acb9b80fd1c5cef6cada3df067e3676c78b6eaef965d609", - "apps/web/src/components/desktopUpdate.logic.ts::getDesktopUpdateButtonTooltip": "2353a6faf70300f14fdc356a572beab1c1c0edca3485decc061e3c52e492e3a7", - "apps/web/src/components/desktopUpdate.logic.ts::getDesktopUpdateInstallConfirmationMessage": "a1e3dddb05ccb1b2f807163b111f29b728691e78f9d5a12d24ab800165cfb76e", - "apps/web/src/components/desktopUpdate.logic.ts::isDesktopUpdateButtonDisabled": "d356b8dd5944ca0f17790b45eefa7869095c693d63b666e640ce65e7375f0e90", - "apps/web/src/components/desktopUpdate.logic.ts::resolveDesktopUpdateButtonAction": "81bf19cbc8cfb76731a144e7c6415dc4e5f1c903fadfdfb4c87e78a4a11d99c5", - "apps/web/src/components/desktopUpdate.logic.ts::shouldHighlightDesktopUpdateError": "91db0e7ef7e2b419833503ad3dd11236d62dd69c0b5ea2a0f84fae1f44fee18d", - "apps/web/src/components/desktopUpdate.logic.ts::shouldShowArm64IntelBuildWarning": "9a89117280e3553056ad82224a726da300ca5ee35b7148614aa46ac10fd44ffc", - "apps/web/src/components/desktopUpdate.logic.ts::shouldShowDesktopUpdateButton": "dc283afb30fa89e07b75e541693c09fe7f11f252bc7dba4e5b46cd0f837011f6", - "apps/web/src/components/desktopUpdate.logic.ts::shouldToastDesktopUpdateActionResult": "bbd2dacd90beb8d5379e5b8943a420fae56057037f722c2d2deb0392a9c5e7ca", - "apps/web/src/components/settings/AddProviderInstanceDialog.tsx::AddProviderInstanceDialog": "e7eaed620bd5d146135ba312fac0d63f51b8b65896b74a5738c3a0da3e8e4fba", - "apps/web/src/components/settings/ConnectionsSettings.tsx::ConnectionsSettings": "f60632a9e01ee7e7de733aa0ce9922e353b39e46c3aa5f220661ae68e02f684f", - "apps/web/src/components/settings/DiagnosticsSettings.tsx::DiagnosticsSettingsPanel": "d8c904d257778a688230131947687f52d0c6149a74bd5f7bc530fc7d72a0f9d2", - "apps/web/src/components/settings/KeybindingsSettings.logic.ts::buildKeybindingCommandOptions": "06a988af0a46b70ecd6e96c6b3fd4a3b0e605b6943b6b98e40af6a3cf71c738c", - "apps/web/src/components/settings/KeybindingsSettings.logic.ts::buildKeybindingRows": "fc8b66c7ec9f6bbfbbb279ca87f7e82484556f8de71701a3fed650b951567734", - "apps/web/src/components/settings/KeybindingsSettings.logic.ts::buildWhenVariableOptions": "0a15a647dcf2f73c2f9d1cf4a1dbe3ffb7d42c5de7ece73cf91f1ec197e49192", - "apps/web/src/components/settings/KeybindingsSettings.logic.ts::commandLabel": "c3295d5ffa865339758ab3d91825ef4859a1507c2759a7bb43b66d41963e2f34", - "apps/web/src/components/settings/KeybindingsSettings.logic.ts::isKnownWhenVariable": "6f316987b51f506a9d280350c431fdafb2fe99a469ed81a912a6bde76ff8d133", - "apps/web/src/components/settings/KeybindingsSettings.logic.ts::keybindingConflictLabels": "7e504399c79c036d482a128d9b65e49184dc19cfa0d7c82e9403bbc74d075e03", - "apps/web/src/components/settings/KeybindingsSettings.logic.ts::keybindingFromKeyboardEvent": "6bded6273d3583fc897c30130154d2ba2654bd5decb45bd7c28035bacb01d752", - "apps/web/src/components/settings/KeybindingsSettings.logic.ts::normalizeShortcutKeyToken": "e2d952c4b0345d8d28647772dc39645944ba734b002083bc8db4b2a449ecf4f8", - "apps/web/src/components/settings/KeybindingsSettings.logic.ts::parseWhenExpressionDraft": "2fcac26bd27d381116ea472780eb93a78aa468b760e206036ca82af3b1418280", - "apps/web/src/components/settings/KeybindingsSettings.logic.ts::shortcutToKeybindingInput": "fd114c11aa3c0cd92d0858e5be25ca0b9d2737d7c3009aff1f728a52a3f71528", - "apps/web/src/components/settings/KeybindingsSettings.logic.ts::unknownWhenVariables": "43e9b50a8fc1568c7bce0104201d4ff9f952c6ea8b3b2424c940d5ef06e140d9", - "apps/web/src/components/settings/KeybindingsSettings.logic.ts::whenAstToExpression": "01195a16b6b8e9943b06d61f6506e8677b386b969cf1d21a7ded937459d5dbf8", - "apps/web/src/components/settings/KeybindingsSettings.tsx::KeybindingsSettingsPanel": "bb3e95c3e5e0b89b7c0947520c6002d58e44731725e79bd05fd7462ff23d189d", - "apps/web/src/components/settings/ProviderInstanceCard.tsx::ProviderInstanceCard": "60b664790468ba5f0290437264113728d6d5079fbe54de2afba2bda3a1b886bc", - "apps/web/src/components/settings/ProviderInstanceCard.tsx::deriveProviderModelsForDisplay": "a8b58ca028767ad5c2f0060ba753940b92707bbca16fa76de6260fc7e13a532d", - "apps/web/src/components/settings/ProviderModelsSection.tsx::ProviderModelsSection": "8aa13dc2756042d3f4253472f84492680027fbe63f7b86e3200e734e956d6dbd", - "apps/web/src/components/settings/ProviderSettingsForm.tsx::ProviderSettingsForm": "1dc74ab9b5e8286d81c83f8467b18a332f5a8a21a23a21fd48ca55135dc77d50", - "apps/web/src/components/settings/ProviderSettingsForm.tsx::deriveProviderSettingsFields": "db38dac7e185f29778fed90738e896634cc175e0ba69a633db0ea811ad3da857", - "apps/web/src/components/settings/ProviderSettingsForm.tsx::nextProviderConfigWithFieldValue": "d1335797da95cad79dc011ea356c35e990b9576adff986b3c5995311cc5df20b", - "apps/web/src/components/settings/ProviderSettingsForm.tsx::readProviderConfigBoolean": "af2482c4ea70645d194c67512a1267dacc7b0b874dabd5d97f6958654457f7ab", - "apps/web/src/components/settings/ProviderSettingsForm.tsx::readProviderConfigString": "c60782ef1bb8bfcc304974b7297f826832cb59067976bfa273da8a26ff471482", - "apps/web/src/components/settings/RedactedSensitiveText.tsx::RedactedSensitiveText": "49affac15b69abd03f31d7684e1922465cfa4e050eecdddfb84a8db8a2057068", - "apps/web/src/components/settings/SettingsPanels.logic.ts::buildProviderInstanceUpdatePatch": "47959bcfb8173d61df48c301538a840e3ebaf0a003dc0491c00287e8075a4db2", - "apps/web/src/components/settings/SettingsPanels.logic.ts::formatDiagnosticsDescription": "c6ec9d267103ad0cc75d4cdd0a6a441238be7b7b54ea8693682a7ce1dde19886", - "apps/web/src/components/settings/SettingsPanels.tsx::ArchivedThreadsPanel": "71c018f5b40135a6f0d42158ae62906d46840d1cf6e7c1d132cbf2443067bc4d", - "apps/web/src/components/settings/SettingsPanels.tsx::GeneralSettingsPanel": "c413c6f318ee6337f878cf6a59c86655551f5c3c88845bb1c183633fcee0ce80", - "apps/web/src/components/settings/SettingsPanels.tsx::ProviderSettingsPanel": "a31a25f1fd0a665e84e59f1ddd2cfca0f856398c3c8341118ad63d58ee4f6eb8", - "apps/web/src/components/settings/SettingsPanels.tsx::useSettingsRestore": "db3ea06c586785da09ff774154a52e8300ad390525ae947729750430b59d796b", - "apps/web/src/components/settings/SettingsSidebarNav.tsx::SettingsSidebarNav": "28e92e660a77a941bf537013d3bb06dfdabb2fc01f22d037caf88cbf36067e03", - "apps/web/src/components/settings/SourceControlSettings.tsx::SourceControlSettingsPanel": "fdf43fade8694cb49d51361503e8290c0a3e98454277eba5e2fb3d06e3b1fa5e", - "apps/web/src/components/settings/pairingUrls.ts::resolveDesktopPairingUrl": "385cfc10fdf71f27be5631832c5660a8af621b1e5156b135fa3b13b3943f8fb6", - "apps/web/src/components/settings/pairingUrls.ts::resolveHostedPairingUrl": "56588f90943f00ffa6505181a8a46561311b87ea2cbf23f847953a404635579b", - "apps/web/src/components/settings/providerDriverMeta.ts::getDriverOption": "5bfc1ff573e43b4c456ffa70fc70bdbec934fb28378263656177f69f134d03bb", - "apps/web/src/components/settings/providerStatus.ts::getProviderSummary": "2959e9d1bf9d7bbe30db520d711034fa8227763b1e2e1f142e4fdf53534a8cab", - "apps/web/src/components/settings/providerStatus.ts::getProviderVersionAdvisoryPresentation": "cf56f073c9fab98fdbe55f86c52c3bd1c81491d97fd5cb301011bd4a84c2f57d", - "apps/web/src/components/settings/providerStatus.ts::getProviderVersionLabel": "adca1621e752ab1e234b9b73015ce2e561ef6da77c1613ad4337a408b8900397", - "apps/web/src/components/settings/settingsLayout.tsx::SettingResetButton": "df1aaaeeabc73a83a1d7ba4d0a6522307b34b195dbaa3570fb6d5218ab104400", - "apps/web/src/components/settings/settingsLayout.tsx::SettingsPageContainer": "5048748ee4c9de0e781467fd26963af156b884d6f9db6e059c3da4d528dfbe25", - "apps/web/src/components/settings/settingsLayout.tsx::SettingsRow": "dbf3ab5cd9e892f4385d0e8d305f6e0c6c39a668cf9c9ff240ef9bbcbddbc0da", - "apps/web/src/components/settings/settingsLayout.tsx::SettingsSection": "716c3e30a49ba53cce25d23f126225f1c04b0a4754957547fb4ce31a62605a23", - "apps/web/src/components/settings/settingsLayout.tsx::useRelativeTimeTick": "bcbd3da8c3573917d66b78bf57849ad9b1d24855a358096d7645275b896a528c", - "apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx::SidebarProviderUpdatePill": "8f5273c9c84c661ef4b22fb162e5c3b4f65cdcc91e146b4258d21556dbb932cf", - "apps/web/src/components/sidebar/SidebarUpdatePill.tsx::SidebarUpdatePill": "889c787b228930484700a2f18dec8a3bf5ecc39572b1d846ac193b127c6ff19c", - "apps/web/src/components/ui/draft-input.tsx::DraftInput": "cb5cec3f33ee38b4047b369de0e585e73ccd44c2beb56a89245e2dbd80afd14c", - "apps/web/src/components/ui/number-field.tsx::CursorGrowIcon": "f5e54597b5706a6569f5e116414bec071c8f43ffcdbb6b73ae24355eb95704d1", - "apps/web/src/components/ui/number-field.tsx::NumberField": "f6011fd9ce13f99add0af317a762a79ef8fe26468bc0f04b815bd30f8acad8f9", - "apps/web/src/components/ui/number-field.tsx::NumberFieldDecrement": "0547b04e60188aa3da1a335c3a826c903498702fca94d66a5ac96008d14e9808", - "apps/web/src/components/ui/number-field.tsx::NumberFieldGroup": "982fe9a641c9ac305be16020b704528b07d038dc54d5294c29d2edf061575a5d", - "apps/web/src/components/ui/number-field.tsx::NumberFieldIncrement": "b1c24aeaa746a0d55712ee96575068122cb13752d1d09df4d14c1348c0dc886c", - "apps/web/src/components/ui/number-field.tsx::NumberFieldInput": "d8c93aa331c15c8fc54d73278cef7049faf551998aa392a63050732a7f48bcc3", - "apps/web/src/components/ui/number-field.tsx::NumberFieldScrubArea": "8dd1b5156f75587efaa8e5afe1ae201a26b16bed1f614b212095463a0e58310c", - "apps/web/src/components/ui/toast.logic.ts::buildVisibleToastLayout": "17f1d9f3fe3aaab3aeebfad1ae50cbdc96cb47b99267cbad5d81cf97d8c3244e", - "apps/web/src/components/ui/toast.logic.ts::shouldHideCollapsedToastContent": "983fa69e44660f2aea741017a0bb703f6b9bd6158ab482d07f51d0f6ea1ce065", - "apps/web/src/components/ui/toast.logic.ts::shouldRenderThreadScopedToast": "c533986dbde37e39c61ee70b71e30273145925730548dcacb80899d0ec4d11b0", - "apps/web/src/components/ui/toastHelpers.ts::stackedThreadToast": "c71c34c74100f75f7451f0c878fe8307caede95508121989bbace6bf10f7592b", - "apps/web/src/composer-editor-mentions.ts::selectionTouchesMentionBoundary": "3ece747e485e8165f7f26fff0717273a68550b9331bf149d259f43b135aa1e18", - "apps/web/src/composer-editor-mentions.ts::splitPromptIntoComposerSegments": "edb5602952c0944e4b5a7153b233ad02b648386be7c6e0957d80338ee13329b0", - "apps/web/src/composer-logic.ts::clampCollapsedComposerCursor": "0cde6f952c51c843c98db508e616abdaba9f5436dbf2f05e71741078ae4608f8", - "apps/web/src/composer-logic.ts::collapseExpandedComposerCursor": "91d3d9094cd0a18fbda5527048c7ae86ddf394972617c17f4d3ed68d130a8b72", - "apps/web/src/composer-logic.ts::detectComposerTrigger": "9ef642bbbcea29ff1781ea9c47f088a0f771936c12da2c0e2a524d33a4c1ecf3", - "apps/web/src/composer-logic.ts::expandCollapsedComposerCursor": "3271d0a507d762cb0a6877041e639d284894c57172f00c917182fe60406ab8dc", - "apps/web/src/composer-logic.ts::isCollapsedCursorAdjacentToInlineToken": "f2c8d3b7d7589740a001656bd750f55b303564e9c573ff2fc30b8f7d80903e37", - "apps/web/src/composer-logic.ts::parseStandaloneComposerSlashCommand": "ef76cfafeac7d952b2a06a39e381a133459b8d3445024f6910bf36c0fdd5bbae", - "apps/web/src/composer-logic.ts::replaceTextRange": "6bc3404635105de8de123bb25f3e704264362cb8efcdbe8ddb1491fc98c75bed", - "apps/web/src/composerDraftStore.ts::deriveEffectiveComposerModelState": "b4166179ca0690982a1d7d2744fe21b4f2da42ae6fd9ddda8ca5bbcfdf667767", - "apps/web/src/composerDraftStore.ts::finalizePromotedDraftThreadByRef": "700f7e1af568cfc9f6e9b619bb69079b1dd18f7d471bddf0ec3fa62faa1425d0", - "apps/web/src/composerDraftStore.ts::finalizePromotedDraftThreadsByRef": "006e7903b43d7d1eb5d1260771614a6c8d6158dd4db7d1a59bdffa1a33ebbaa8", - "apps/web/src/composerDraftStore.ts::markPromotedDraftThread": "d0db9c8a94319870f384ef58f9fd55da4b7179d2f770bdd0cb55b66bae1e9057", - "apps/web/src/composerDraftStore.ts::markPromotedDraftThreadByRef": "910e83e4c2ba3c8fcaea0a2486c19c02a72515157e9399cd046ef11ad76afe79", - "apps/web/src/composerDraftStore.ts::markPromotedDraftThreads": "1b8aaf41d2e0e07cac5398b7f356b661bc2699a32f97a5b104d4cea241512925", - "apps/web/src/composerDraftStore.ts::markPromotedDraftThreadsByRef": "20337491db747bcee5c6c39c499b896bbe9240dbb9d3ba5c198e7e92f0d8c303", - "apps/web/src/composerDraftStore.ts::useComposerDraftModelState": "377792c16643f45f31ac70fd40d1b4e067e404e36b805981d5e0f4918e2559dc", - "apps/web/src/composerDraftStore.ts::useComposerThreadDraft": "05067b54a933202c5d30aad51cca796b2a2414f6bb0dd6c3b9d4b9b7ffd387e8", - "apps/web/src/composerDraftStore.ts::useEffectiveComposerModelState": "6a2868cd639c819a035db34fcc2581480dd265bb302cceec7b8351f4c12d0aff", - "apps/web/src/composerHandleContext.ts::useComposerHandleContext": "8c34088f96caf9749d80667a371740841e0ea11b27f6935f3f010b3003d8f75a", - "apps/web/src/contextMenuFallback.test.ts::FakeBody.constructor": "1782372a9134a4c6a6287488bc924eae26cd31047d2723e759ce7cbb3775c19a", - "apps/web/src/contextMenuFallback.test.ts::FakeBody.innerHTML": "291e7d1a70ff9abc4cb1397f1fa66ac807639d931cb88c2556a5e63916f8980c", - "apps/web/src/contextMenuFallback.test.ts::FakeDocument.addEventListener": "642b2d4cb3773aeabd66681d9213de54e44f257ff80ead10c06d145a0ce4a3ef", - "apps/web/src/contextMenuFallback.test.ts::FakeDocument.createElement": "9b26d9f408225686bb6ea065f49492b6a4d7a89a08a67f60fa10f97eebef10b2", - "apps/web/src/contextMenuFallback.test.ts::FakeDocument.querySelectorAll": "c86033779c9af5a6fb446d0c23816efd14a020bfc4f8e22c646611e65347bd02", - "apps/web/src/contextMenuFallback.test.ts::FakeDocument.removeEventListener": "8ad6ef0d8bbb73108b3f984fd03abf8c076ccfb689bbe9251d41fd87cc5fc212", - "apps/web/src/contextMenuFallback.test.ts::FakeDomEvent.constructor": "bb4bc1578d761df3c18386fbf52f7fb1bfe029a7406d3eb9723d3ec1b296a367", - "apps/web/src/contextMenuFallback.test.ts::FakeDomEvent.preventDefault": "1d26bcfb3795f0d7b6db150e845d32d2da4c18ffb9dbb223a041968d5b5d9cae", - "apps/web/src/contextMenuFallback.test.ts::FakeElement.addEventListener": "b5af9aa79cd574f6803bf5a17193a9a393a9f5d6ce2ad713f64b721e5f7871ff", - "apps/web/src/contextMenuFallback.test.ts::FakeElement.appendChild": "09d5eef4584a5e512016b7f46becd00f30f70be7c869ee27d69331828df570f5", - "apps/web/src/contextMenuFallback.test.ts::FakeElement.constructor": "89388cf4d41b90fb8fe2b4a82d9411e56284e0bfbfa1140135183aa71e7ede1e", - "apps/web/src/contextMenuFallback.test.ts::FakeElement.dispatchEvent": "e77bc05ab89199f6f9c5859ed866e82e31a6ddd6f24e11c39438a28bd28503a1", - "apps/web/src/contextMenuFallback.test.ts::FakeElement.getBoundingClientRect": "5a706104a06dcdf03a90beaeee8fab42aecf5f1319fbf62a19f25736064527f1", - "apps/web/src/contextMenuFallback.test.ts::FakeElement.querySelectorAll": "9a2be67eb6b2555036bea7930c06629b7010f785bea16714ed5b946c53c455bd", - "apps/web/src/contextMenuFallback.test.ts::FakeElement.remove": "7447c9aa66bcdb7029fc47c3fd4bf6b934674f615a81ffb2a6c47132393680d7", - "apps/web/src/contextMenuFallback.test.ts::FakeElement.textContent": "ea44de88e63a211585c5ec4f11795de35cc077ed354d3a24d2ea506c02071404", - "apps/web/src/contextMenuFallback.ts::showContextMenuFallback": "5ebd131f28629767056df156c0ab2fa85e6ad10b033be43669fb268cb9e4a58a", - "apps/web/src/diffRouteSearch.ts::parseDiffRouteSearch": "e2ad87d5dee8cbf6646b18d1396c381a4941fb35b69c7cf0bfbd65c4bac7be57", - "apps/web/src/diffRouteSearch.ts::stripDiffSearchParams": "b0de70c3d700cd6c71ff4afc85ecaba682773a3b9b0fd25e01bee1bfb7598fdc", - "apps/web/src/editorPreferences.ts::openInPreferredEditor": "1e2decd06a1af111a73b50c238f409d03d0eff62b2545a5e0bebf7905ff2c36c", - "apps/web/src/editorPreferences.ts::resolveAndPersistPreferredEditor": "690c9cea920cd3b3a0794b25f304f7737f9b3991c09e62f6cf90b0a9ec1e20f9", - "apps/web/src/editorPreferences.ts::usePreferredEditor": "a0ed0011098a799a0bbdd251fb7bc826511bfb49edc3c68f49538a91540388cd", - "apps/web/src/environmentApi.ts::__resetEnvironmentApiOverridesForTests": "13774799181d298a8c9c3bd4e9436f9f29bbb6c32660ec5a3f150de8bc95bf97", - "apps/web/src/environmentApi.ts::__setEnvironmentApiOverrideForTests": "dd1ae512d7da41e7a32df065586e23be9c6d1943d2849367c0036df562f6bbf7", - "apps/web/src/environmentApi.ts::createEnvironmentApi": "674df9ba45270aa35cb44aeb3f8cf911a96138170d6a7e6e790ff2b0e561aeda", - "apps/web/src/environmentApi.ts::ensureEnvironmentApi": "d35e530b5a65615f56e53b8c3150281df483a78df80cc313ed934dd4ffb0a1f7", - "apps/web/src/environmentApi.ts::readEnvironmentApi": "553ea272b3a5725176f3ad69c4584b0092a80a5110b57e28a4b9f1637bbd74fd", - "apps/web/src/environments/primary/auth.ts::__resetServerAuthBootstrapForTests": "de154766835b5c6ee6914ddc724618c126bd5197ae4724015e122e94494a4d2f", - "apps/web/src/environments/primary/auth.ts::createServerPairingCredential": "998537db7e5ffee89148f9d94769b5a8fa3e9534ab8853478b6d2e6ea16c1db6", - "apps/web/src/environments/primary/auth.ts::fetchSessionState": "7836eb042e75a581e9377e8fc0add7cf3c8fc1c60b2c76b377092d3890786cd5", - "apps/web/src/environments/primary/auth.ts::listServerClientSessions": "ca92927e7b7ac31ae8a0db5c7a0ca3ab51cfcdb5a2159859f3e88513599bf14b", - "apps/web/src/environments/primary/auth.ts::listServerPairingLinks": "971f8f2e4e4d1be0cea62c39b8babbaa8890f95bcd8d0cb52588628e88ee2c4f", - "apps/web/src/environments/primary/auth.ts::peekPairingTokenFromUrl": "6cdb8ed4d81da70cae9caaa78a31629d8e3ac18bece56dcc155c506ae481a61e", - "apps/web/src/environments/primary/auth.ts::resolveInitialServerAuthGateState": "c222a2090b5d97a41b0c2d51ab07fdbdb003bc06f97f1d8f43431da61f71c43d", - "apps/web/src/environments/primary/auth.ts::retryTransientBootstrap": "ad95770fca52a1f653aea82c510707ff03b036f229e65881b390f416ec5a4cba", - "apps/web/src/environments/primary/auth.ts::revokeOtherServerClientSessions": "4c72f41e792f5acccb1c636ff4b79281766f4666b57a21419a9c8186c4914393", - "apps/web/src/environments/primary/auth.ts::revokeServerClientSession": "37477201c58017145caca0e4ca57e1b49b44801fa19099e5a03d5e061797534d", - "apps/web/src/environments/primary/auth.ts::revokeServerPairingLink": "ca9459d44601378b5a88d45fd6cbd7af90cd68c364ee4d9a86ff7ab6067e29e4", - "apps/web/src/environments/primary/auth.ts::stripPairingTokenFromUrl": "440d350e6bb5449daaa93d6be47b7b6a2b333ba63d8589c1d6cf0dd40ea6f3c5", - "apps/web/src/environments/primary/auth.ts::submitServerAuthCredential": "0a6105673d700684c1d96fad5b6ccacadf84fb7d0811d25aa6c68090960a4355", - "apps/web/src/environments/primary/auth.ts::takePairingTokenFromUrl": "708f7c737062d856cc3796a8a404be9951f795b3f55425a6e4d34ac3f712ba46", - "apps/web/src/environments/primary/context.ts::__resetPrimaryEnvironmentBootstrapForTests": "9800c55035d83770dfd483bd027bf3fd269fa11776818b1478bb45cb360cb862", - "apps/web/src/environments/primary/context.ts::getPrimaryKnownEnvironment": "cf20e9872c3c1a495e90f9f2b8139da6d87e7288f2639ad662dfdb88f75ba89f", - "apps/web/src/environments/primary/context.ts::readPrimaryEnvironmentDescriptor": "90a5f56dceb81514dfbb4d494d343e7eae3f906f33d63f47248942eeeb127df9", - "apps/web/src/environments/primary/context.ts::resolveInitialPrimaryEnvironmentDescriptor": "e616b7ff8c11d417a7770ccda2f90fb959db7d2f11c5dc488d284738ec022520", - "apps/web/src/environments/primary/context.ts::usePrimaryEnvironmentId": "8dd19a6b9a341bba24e7b6421cfcdb7d0311f5212cf4926f08215fc35e9b25e9", - "apps/web/src/environments/primary/context.ts::writePrimaryEnvironmentDescriptor": "8fed5e9654271ab1ec7076a015165e3de29e381c0cee6c27d46bba2968275070", - "apps/web/src/environments/primary/target.ts::isLoopbackHostname": "a4568c2b53b204304f20a060cabbeb216cec1288f3385014de801ee766f47291", - "apps/web/src/environments/primary/target.ts::readPrimaryEnvironmentTarget": "90b4c7df58afbe64eeaddf7e4faa85e433a191dfae117b96231833ff23df2456", - "apps/web/src/environments/primary/target.ts::resolvePrimaryEnvironmentHttpUrl": "dd870192fd1b9b970ad010be8ef46b275fe471900aed29e8537b1dfe4af2c154", - "apps/web/src/environments/remote/api.ts::RemoteEnvironmentAuthHttpError.constructor": "0a6eda8a4398e722fa40914c98e7d701a4cfed4a9256638c51137d2c676d0495", - "apps/web/src/environments/remote/api.ts::bootstrapRemoteBearerSession": "5f5077cc58f95f7f7ea4071fb904fe0c5ff889a0c839b784c7f992620ae2ecaa", - "apps/web/src/environments/remote/api.ts::fetchRemoteEnvironmentDescriptor": "b3bd60566543c052b00f860252e8499f95dd2dbccecff7d01379791986ec19ed", - "apps/web/src/environments/remote/api.ts::fetchRemoteSessionState": "be4b3e57eb98a7e4d307a7dee4868fb0715636ac21424843d880656c8346e660", - "apps/web/src/environments/remote/api.ts::isRemoteEnvironmentAuthHttpError": "a4be1eb9bea7e1260aaf16455a48b0a306e0acb8832641266fd716aaabace369", - "apps/web/src/environments/remote/api.ts::issueRemoteWebSocketToken": "8d202abc88681287ddcd63a45ec885a91d011fa9f7d9ce0f7ef58c23206f62f0", - "apps/web/src/environments/remote/api.ts::resolveRemoteWebSocketConnectionUrl": "d6be35ee97a8fa8dfa0f3cea0c08c265e05a7a910029f2eddf719dc75719c6ec", - "apps/web/src/environments/remote/target.ts::resolveRemotePairingTarget": "ee6776da92f5a677d0a0c74eb316b80d6c4e3e0f410783b9c2226681255b7ad1", - "apps/web/src/environments/runtime/catalog.ts::getEnvironmentHttpBaseUrl": "318fc54151f93ea16fc82d3afd762caedf8b815e687e1b3dd54eabc3949abc08", - "apps/web/src/environments/runtime/catalog.ts::getSavedEnvironmentRecord": "414e7b0811aeccecf025df87101d693258cdc15cb8237549f5afdd714202a934", - "apps/web/src/environments/runtime/catalog.ts::getSavedEnvironmentRuntimeState": "c9dc06ea0d0b77b5a67522ca3c1a3b23c22bc1e0047e0df6bf73bd2614d20979", - "apps/web/src/environments/runtime/catalog.ts::hasSavedEnvironmentRegistryHydrated": "a7193370738e3c70a6e0fdb529d4625a6b954fd14eadd4d44f88385fcbb1620b", - "apps/web/src/environments/runtime/catalog.ts::listSavedEnvironmentRecords": "96abf238859867e7fafc14940c6714e661146fe9a380943d9c32806849c32f2e", - "apps/web/src/environments/runtime/catalog.ts::persistSavedEnvironmentRecord": "faa04fb8ada87cff77e0c4f6b504b97467eac03d8544ee7e8088e4ea78384ada", - "apps/web/src/environments/runtime/catalog.ts::readSavedEnvironmentBearerToken": "c6314e30c1f1f22f6f8477a6b4aeb409a21775c424676a57ad974066c2b785fc", - "apps/web/src/environments/runtime/catalog.ts::removeSavedEnvironmentBearerToken": "371ae54dd12181b419e1e8b6632ac30b50f14062ca266a615c51aabbdde8b287", - "apps/web/src/environments/runtime/catalog.ts::resetSavedEnvironmentRegistryStoreForTests": "ce4dacee1e8049e7e8bf78dacb8e728877b4a350aa086933efe7f326a5d647f1", - "apps/web/src/environments/runtime/catalog.ts::resetSavedEnvironmentRuntimeStoreForTests": "51170207784a32e6d4ae47d585262153adfda070cb701d5a049120324a0307fe", - "apps/web/src/environments/runtime/catalog.ts::resolveEnvironmentHttpUrl": "ce6cf99326152dedf38dfe87f183a882e6ce97292656db7e61a7e0bd26acccc5", - "apps/web/src/environments/runtime/catalog.ts::toPersistedSavedEnvironmentRecord": "ae75dd3e99581f481edd35c99af5c1cc8aae6da9911ce10eb2b27ee3ed165cc3", - "apps/web/src/environments/runtime/catalog.ts::waitForSavedEnvironmentRegistryHydration": "f03ffcd4ba31541a78ee0a66a6d0153be161b8c25d6c61bd21b53ec0f7448ca7", - "apps/web/src/environments/runtime/catalog.ts::writeSavedEnvironmentBearerToken": "f34ecead4bc4634863b8dc126a115bc4aa4ae0e2a482acbdcb3b444bc2cb82dd", - "apps/web/src/environments/runtime/connection.ts::createEnvironmentConnection": "7dd33f2136146bf1aa0ca53b6c3a24873efe271d0bf8deb4f192076e4424ef12", - "apps/web/src/environments/runtime/service.ts::SavedEnvironmentConnectionCancelledError.constructor": "d573de4be8f29141c060c2db1acbedf8b128951ec9e24b712ba38285b6a5dd7d", - "apps/web/src/environments/runtime/service.ts::addSavedEnvironment": "7d658654cbcbcc928af962f2e0723329ffbf934c9dc99f185737446fd670d46d", - "apps/web/src/environments/runtime/service.ts::applyEnvironmentThreadDetailEvent": "075c97b1f0027b12cf31db5c76bd9b53f94fbf255ee695970679255cc80b4180", - "apps/web/src/environments/runtime/service.ts::connectDesktopSshEnvironment": "5e1d268aac73eb5344951fe082e25f9cfe4311aad6afce2241111028f32de014", - "apps/web/src/environments/runtime/service.ts::disconnectSavedEnvironment": "1ce93a7e908b173458df61957bde54e3d76fd0f404eb627a4314f41d53d01c99", - "apps/web/src/environments/runtime/service.ts::ensureEnvironmentConnectionBootstrapped": "8b8282c0d1b110e54eeb00e8bfefce41442287610eeb101c43aecab532f0c400", - "apps/web/src/environments/runtime/service.ts::getPrimaryEnvironmentConnection": "d901f828356a3007d3934b8896731d63418fa46c9b7f20d204b7949d4f17bfb4", - "apps/web/src/environments/runtime/service.ts::listEnvironmentConnections": "4cea66e39fb1f15c05c0a8fc4df27ea92ebfadbb63e5f21b610defbdcfd3c24e", - "apps/web/src/environments/runtime/service.ts::readEnvironmentConnection": "9e360660e69aeaf445e9b8d0171700e4fc7ba1f2dcb8349f491bb2bed6771cd8", - "apps/web/src/environments/runtime/service.ts::reconnectSavedEnvironment": "94c516c3d489c1231689de7fbe67ca5cda901467da624dea02747519f04f1b7a", - "apps/web/src/environments/runtime/service.ts::removeSavedEnvironment": "e96f98178fa12606ba25176fc76d72e4846746df658cca5d51868f46524c2718", - "apps/web/src/environments/runtime/service.ts::requireEnvironmentConnection": "76b312753743d3b88586a4105d407dbb5845dc549a030ab3d42e56c021079ffb", - "apps/web/src/environments/runtime/service.ts::resetEnvironmentServiceForTests": "feb176619b2cd6d77a8bbd23bdf1be5ddd9841ea316bd7726e43065d1c4f7385", - "apps/web/src/environments/runtime/service.ts::retainThreadDetailSubscription": "eb217242070d46081a0d5b69b39906369ece76bc7739449aedbf254a7d78f262", - "apps/web/src/environments/runtime/service.ts::shouldApplyProjectionEvent": "8f5e1ceec39ee4fdf89f40157b04b4582bb3561c19f1616ea867b1023295887f", - "apps/web/src/environments/runtime/service.ts::shouldApplyProjectionSnapshot": "2413986805f966fcf59c7688a0a4233720174a0e47ca0590a530c926cf841e51", - "apps/web/src/environments/runtime/service.ts::shouldApplyTerminalEvent": "81d4446b14a34e4f6a7dc6384832037a3f99897d3ac1419b9a456bb53a10d12d", - "apps/web/src/environments/runtime/service.ts::startEnvironmentConnectionService": "bfa48a51125d93e9efdb7f8f5345a83f5d4e80ecda6ec081d36ab6257f7360f9", - "apps/web/src/environments/runtime/service.ts::subscribeEnvironmentConnections": "d8f0341a7b1584d4bafddad24ca7d029d37c44359c7f685f6f354c410737c692", - "apps/web/src/filePathDisplay.ts::formatWorkspaceRelativePath": "50eae193742af2ecbbead212210472e10c8a3302094c8e0a2f67bb5eee70d5d9", - "apps/web/src/historyBootstrap.ts::buildBootstrapInput": "9e751fc6abd96232aa80005da8c091ed0d2b9af404d645cee678f5985727801f", - "apps/web/src/hooks/useCommitOnBlur.ts::useCommitOnBlur": "3d2ae843142dce26be431df64e584c161065885c3c469688ac2137f80102195b", - "apps/web/src/hooks/useCopyToClipboard.ts::useCopyToClipboard": "827f7ef51df197647130a57aaa7bfd332149094d53857a3443b62ff6cbfef9bf", - "apps/web/src/hooks/useHandleNewThread.ts::useHandleNewThread": "364dcdf91d8f9614ac1c44e82b03c2fad149788ea31a37a65a4d03e70f52350b", - "apps/web/src/hooks/useHandleNewThread.ts::useNewThreadHandler": "8180d6ec556cd1d1a64622ba6e21bb433e584b13c792e6444f2b0c705c33dac5", - "apps/web/src/hooks/useLocalStorage.ts::getLocalStorageItem": "d7626d37696bbdca79a6354184c6f28c429ee51797fcaa365c72520d77d348e2", - "apps/web/src/hooks/useLocalStorage.ts::getLocalStorageItemWithLegacy": "73216d4a847814720680978ca4330a0fc5be6327ff3878a82f0bdeb5948d1e04", - "apps/web/src/hooks/useLocalStorage.ts::removeLocalStorageItem": "f1c89739ca3b285bfe8ef9f77cb63c7eb9098db64a9c4825b87f16ae5efb9920", - "apps/web/src/hooks/useLocalStorage.ts::removeLocalStorageItemWithLegacy": "1ad4a36f26e03e279878b399895ceae37d4e3e0656ab385ab5ac1582a7431419", - "apps/web/src/hooks/useLocalStorage.ts::setLocalStorageItem": "b8b9e6a90ae804d21d63e54e0819f7b4558c980e1ab0ea8f09d9e876f3636d3a", - "apps/web/src/hooks/useLocalStorage.ts::setLocalStorageItemWithLegacy": "a023fb1cda3305f5c53f42a1314ae13377a1059b8e88a076da359e3ae386d2a7", - "apps/web/src/hooks/useLocalStorage.ts::useLocalStorage": "2e7dcfc22790e995913593e5687d70651be7d31b966e2c5ab69851af7fd89f37", - "apps/web/src/hooks/useMediaQuery.ts::useIsMobile": "e0a8923feecbf8cd2d55a1f0a87e3e9bcfba5dd3c86cafede0691c347312c71f", - "apps/web/src/hooks/useMediaQuery.ts::useMediaQuery": "cf68fa5ccf235b15a4505b4df156a0d66ed2f5053f865d27b19eb7e03fa3fbf3", - "apps/web/src/hooks/useSettings.ts::__resetClientSettingsPersistenceForTests": "5d7dc7a63f54bea941789e812cb4116bc1a51a49286b1b8da4c37cef3af9e563", - "apps/web/src/hooks/useSettings.ts::getClientSettings": "d037776da7c57f4da6000cba76911e15a15ab893f089227f9d90c982354e9698", - "apps/web/src/hooks/useSettings.ts::useClientSettingsHydrated": "f2202be82b8082aa27d4a2964528bcbaa11cdb04063320aae7449fd4e31306c1", - "apps/web/src/hooks/useSettings.ts::useSettings": "160d6d5470c35ce2e0ac879d35680187afe49bbf30e8773c52f72768cb4d7e47", - "apps/web/src/hooks/useSettings.ts::useUpdateSettings": "aeb2041d8ac924d8573ce896673910d2da2753a5c1f6affe287bc1fc1a21ad17", - "apps/web/src/hooks/useTheme.ts::syncBrowserChromeTheme": "e2e0645d20a99f7a2a0ae6fccb88a1ab652c5391ad0f0cf87286beb0ec8917df", - "apps/web/src/hooks/useTheme.ts::useTheme": "6d8418d38f3538ca881c3c87b7ce24af6e89f162f9547caf7097c5c737b766c2", - "apps/web/src/hooks/useThreadActions.ts::useThreadActions": "ee85632f6987ef5574b183385c0448518ce546994585ed4e800c91e39f4cb4e1", - "apps/web/src/hooks/useTurnDiffSummaries.ts::useTurnDiffSummaries": "707a69edc14143ad84872b8a1fe0c37361264537cc5ab78271ed021e2b559ef6", - "apps/web/src/hostedPairing.ts::buildHostedChannelSelectionUrl": "b3d1adb019b2358b3dd7377638e22a9269b89597da20a69b1b1d41ef800a9635", - "apps/web/src/hostedPairing.ts::buildHostedPairingUrl": "c0a539cee025127fcd221c81fc56f10073a13f8d8286827de08420d1eda63754", - "apps/web/src/hostedPairing.ts::hasHostedPairingRequest": "913cbbb953999919fa00b306b5bd7e40432771169969f195b3216993f62ee451", - "apps/web/src/hostedPairing.ts::isHostedStaticApp": "719f2f1cbbb451cad90d83e372562f8c59a914f80d0bb02138a0e5c7770f193a", - "apps/web/src/hostedPairing.ts::readHostedPairingRequest": "16ba71bdce7852634684749418522bcc8aa3f09f082ef51a8a54dc88ab6a787b", - "apps/web/src/keybindings.ts::formatShortcutLabel": "d9b19a5ad72dc9644c3f4aa5291b4aba696bb1aa8694ff15e06c803e3273b087", - "apps/web/src/keybindings.ts::isChatNewLocalShortcut": "8a8efc88ea513e01225b68d62b4ec8e66abcf52f9f1ff59936d6d8eba847f4fe", - "apps/web/src/keybindings.ts::isChatNewShortcut": "9d26711d7cac59d285de0427cb75b76efa153e57be5d605b1fce9f970c123e41", - "apps/web/src/keybindings.ts::isDiffToggleShortcut": "a3a4364463d419248848202c5f1546b46cbb5a41465df3e05f54e2e1b94f1ff4", - "apps/web/src/keybindings.ts::isOpenFavoriteEditorShortcut": "26730654c4328631bb22c7a961405e31a51723976170dcb749d3ff9872845a66", - "apps/web/src/keybindings.ts::isTerminalClearShortcut": "c740420ea4b8d97673bb2670814a411515df3c935e67b6193512562f5499e98c", - "apps/web/src/keybindings.ts::isTerminalCloseShortcut": "2a89d178fb3f654a69c3238caf66e9bca67671f00642f4aa6db680b43d52ca98", - "apps/web/src/keybindings.ts::isTerminalNewShortcut": "638636e98ad99897450622fc1fba66979adf7642e691d3d24b7c65a9122a325a", - "apps/web/src/keybindings.ts::isTerminalSplitShortcut": "9228d3e368c8e369122b3d9a13684b6be9a802e613c638a6d5b2f134e8bee70c", - "apps/web/src/keybindings.ts::isTerminalToggleShortcut": "6b87c1adbd53be9ef701016b981c44cf7598a25c12a43538e468d77fedb76364", - "apps/web/src/keybindings.ts::modelPickerJumpCommandForIndex": "f4da94b4227972860f4d0979983ba58e32a5de666a6c3d3539f92c098ba9b550", - "apps/web/src/keybindings.ts::modelPickerJumpIndexFromCommand": "7f3b7d466f17fac8857641bb11c157b672417bdb07475d68f5d37a10e398eb5b", - "apps/web/src/keybindings.ts::resolveShortcutCommand": "51a8ef9263c7598fd8ff90002626d021d3ea09d113003091d49358599e066908", - "apps/web/src/keybindings.ts::shortcutLabelForCommand": "b7976160f2441de87f3de69b56a316b7995a32cd3be58a4e6140548be9ea7a76", - "apps/web/src/keybindings.ts::shouldShowModelPickerJumpHints": "bc3a2f4d0b95f41b712799b6141f5768e97ffe70a746e38640e34ffc02fb3a95", - "apps/web/src/keybindings.ts::shouldShowModelPickerJumpHintsForModifiers": "aa8fa1a24978b9e237d483dd2edf38c8868b540b8e96dad5aaa1860ceeab4ded", - "apps/web/src/keybindings.ts::shouldShowThreadJumpHints": "152a017a4c01a484b30b5cf48ad4a85d849e7e159da7258d5ce95b8f88d3281f", - "apps/web/src/keybindings.ts::shouldShowThreadJumpHintsForModifiers": "016e365bd6f6fa0d62e5790343e3f02c3b69856b8d1e8fb1cc2d8d4b665eea62", - "apps/web/src/keybindings.ts::terminalDeleteShortcutData": "33da9e2653e015fc2c92d2cb96130a9971a9276de71da7be25cb25c3c0237fea", - "apps/web/src/keybindings.ts::terminalNavigationShortcutData": "e828cc56041d042b9f4a6f3614f695e86b8cda5bf2d9af820625d987b7d9b642", - "apps/web/src/keybindings.ts::threadJumpCommandForIndex": "aa1a2c776542baa9d96cb5c3128c54748e72aba4420fbfdf1577aa8133c98921", - "apps/web/src/keybindings.ts::threadJumpIndexFromCommand": "25b25f166853deb1626bc5f0a8dc3098cc2950411fee5dc96eea65f1ea3537d9", - "apps/web/src/keybindings.ts::threadTraversalDirectionFromCommand": "5b7641ec748a8428e84fe613bf2319a9709c118f474a88d1681b86cb3e8a09fb", - "apps/web/src/lib/archivedThreadsState.ts::refreshArchivedThreadsForEnvironment": "57b4d231f002d24db75deb7246a575acfa0647e0bca03fd4ffb1169da0b43ef3", - "apps/web/src/lib/archivedThreadsState.ts::useArchivedThreadSnapshots": "209e32a8bf55bd22630885650ecc0b0a3b0a8307f7b46bd42500e9303b5c914d", - "apps/web/src/lib/chatThreadActions.ts::resolveThreadActionProjectRef": "9c1037afa6cd0783ef5371900d2db3be9c15e2767b334aa7314277110157f3cd", - "apps/web/src/lib/chatThreadActions.ts::startNewLocalThreadFromContext": "ba5c5e07aed4abe050b9f543dac2daac1d8e8c320e78c5f243acdeabcf5a1c8d", - "apps/web/src/lib/chatThreadActions.ts::startNewThreadFromContext": "7ae7333542829f76e77d4e7c0ec7a1e13afc7e38ebe7e1186fc33b127c4066b5", - "apps/web/src/lib/chatThreadActions.ts::startNewThreadInProjectFromContext": "e94565f87a75037cf006a918624db13ec1377106343682984c3140a4b220543f", - "apps/web/src/lib/contextWindow.ts::deriveLatestContextWindowSnapshot": "bca074326828dedcdcc98e808229c702eb51ab4d681ecb78f100d7721abc2901", - "apps/web/src/lib/contextWindow.ts::formatContextWindowTokens": "05cd4e760acfe5fa5a1eca35cf359a68318b1cd811133db68ef4776e9d2fff40", - "apps/web/src/lib/desktopUpdateReactQuery.ts::desktopUpdateStateQueryOptions": "53dcd180d51467ca78c9e26c3816812a13b610d8fa8885afc84410f1984b5329", - "apps/web/src/lib/desktopUpdateReactQuery.ts::setDesktopUpdateStateQueryData": "7aaec75cfdaecee3812e008e2696a01b497591591cf2d4ba00aee92296b29c20", - "apps/web/src/lib/desktopUpdateReactQuery.ts::useDesktopUpdateState": "b99d798a4f8d569ec84e305be48810a7a18d47d873d7b6e517041d215194d478", - "apps/web/src/lib/diffRendering.ts::buildPatchCacheKey": "31c781a9b849f2c1573eeb0732b0e63dcc3983cd2f5b208411170649ad1ddb06", - "apps/web/src/lib/diffRendering.ts::fnv1a32": "af4b2a54f9a13262432c44329a9b132b54a1f2fc3d39989f3e84762ad9b0dd30", - "apps/web/src/lib/diffRendering.ts::resolveDiffThemeName": "848fee9708ab0bea71f22516220f3376ce696fb567df1d8d05e3b2dd1f35371e", - "apps/web/src/lib/gitReactQuery.ts::gitBranchSearchInfiniteQueryOptions": "45a6b3fa97df59f13b89333228b748857bcb22c0416e5562c8975524881dc707", - "apps/web/src/lib/gitReactQuery.ts::gitCheckoutMutationOptions": "18e0931d471a97e22be74774d70a1a7001778df74b143a1644babb3087a927a5", - "apps/web/src/lib/gitReactQuery.ts::gitCreateWorktreeMutationOptions": "7989e3208a02644611d9efacb89b37625134824acb89d8586853ab40ba2ae91e", - "apps/web/src/lib/gitReactQuery.ts::gitInitMutationOptions": "07c46b4d8ea78a0790ba6ada28b8a59813309aceee65e5862264f02fa65a29bf", - "apps/web/src/lib/gitReactQuery.ts::gitPreparePullRequestThreadMutationOptions": "bfc89a015829dfa868944a8e7afefc9726104a40961fcfaaf54ff85e9101af97", - "apps/web/src/lib/gitReactQuery.ts::gitPullMutationOptions": "99683a8eff2db9f4856990d803b4c5b44208f2dfbadfe17ba577b0e29c16d47e", - "apps/web/src/lib/gitReactQuery.ts::gitRemoveWorktreeMutationOptions": "55d1c24db269f77b4d8676c6e9616d8e404db0c3bfc6598ea2ac7d05b8caaa1d", - "apps/web/src/lib/gitReactQuery.ts::gitResolvePullRequestQueryOptions": "a5f44d445af146656d56d9c2452c18f9de5700c7c03049c48132cc1dd407e8b0", - "apps/web/src/lib/gitReactQuery.ts::gitRunStackedActionMutationOptions": "5068ead59946e1781d7139319e3262b8d44ec10d05f5ef688a9863b15adc2f2a", - "apps/web/src/lib/gitReactQuery.ts::invalidateGitQueries": "7db37496a1ca2c6d5324328ec698abdcd5e08b3870f110500d57a243d6edad14", - "apps/web/src/lib/gitReactQuery.ts::sourceControlPublishRepositoryMutationOptions": "c4ff1f89964f81f046fed6e583f7395c7c76b8c69ac5d9a81dee474760367025", - "apps/web/src/lib/gitStatusState.ts::getGitStatusSnapshot": "dc2f73360c55af9f0be4c5cfb39fc49c071633c3d4e713e7f33d0925e647052a", - "apps/web/src/lib/gitStatusState.ts::refreshGitStatus": "f73d3cd4c9db9d5c7cb0b731a6f3261040fc3406192e28f191d18a8545a683b4", - "apps/web/src/lib/gitStatusState.ts::resetGitStatusStateForTests": "813744fc55f8c0e0af8cbffc0afcacb233c33beb3ec2f4fd8e47062657475bb7", - "apps/web/src/lib/gitStatusState.ts::useGitStatus": "4cc2fb8ca41be362726db9cfc45d01564d99b58382bf615252a9e9f1db366515", - "apps/web/src/lib/gitStatusState.ts::watchGitStatus": "e81bea78caa598ad2f2f460a103e92e542517b4394fc6ee3a39c962b68179185", - "apps/web/src/lib/lruCache.ts::LRUCache.clear": "10f8aa598d72e25e8cda2b530892a0172343ea0b9f1f2edb460e68ee425a4cc3", - "apps/web/src/lib/lruCache.ts::LRUCache.constructor": "5c8543fd678e8be219e2a15d561b707e639bca47d8e95944601a5c8c3fac6ea3", - "apps/web/src/lib/lruCache.ts::LRUCache.evictIfNeeded": "6e43c9948eff18c9bf1c572e4c2ef1fb1ab6ddd6e33bd32ae135102f7ca82a15", - "apps/web/src/lib/lruCache.ts::LRUCache.get": "946632f5136d3b62ae553341ad508d407788aecc9ec48236272eeb6d4171fd89", - "apps/web/src/lib/lruCache.ts::LRUCache.promote": "f73458ca7bd965b1f6c12d10c39519aff8dddbc5b4297181caa80a090f899dc2", - "apps/web/src/lib/lruCache.ts::LRUCache.set": "fe930d256a1c29575be17f81a76e3d2402d0a442cd8f9fc10c191ccc951cf63b", - "apps/web/src/lib/processDiagnosticsState.ts::refreshProcessDiagnostics": "e408cec4b631822839e3319f24ffe1e72875a679c1689323cf1f97f434ffa414", - "apps/web/src/lib/processDiagnosticsState.ts::useProcessDiagnostics": "837176133b017101772262f85851b8431a8bb0786e3aeb3872ecaf235f5e7890", - "apps/web/src/lib/processDiagnosticsState.ts::useProcessResourceHistory": "ea7c68c32c26c7baf51e301c8277081ecf39138fb00791d8da3bcf45a310d4a4", - "apps/web/src/lib/projectPaths.ts::appendBrowsePathSegment": "445952d48549e67b709bf105e5b7be5951b614e449cc32bb033ffdf49958a09e", - "apps/web/src/lib/projectPaths.ts::canNavigateUp": "21c28e7a513bc8c559fc27df4bd46843f3ecff4d485c1ee6ab6d22fc5ca22884", - "apps/web/src/lib/projectPaths.ts::ensureBrowseDirectoryPath": "fd88d904674e4c5ed6ae07ea5591d0ab913d61ea269d86c1a4c12f43716382a8", - "apps/web/src/lib/projectPaths.ts::findProjectByPath": "cf2a681abd730a103b2958431e57f4560cda75e2b749a7d0795879c003a22887", - "apps/web/src/lib/projectPaths.ts::getBrowseDirectoryPath": "5dd73032919e4ee1233effbc758338d873145c0b808f0bb036e997e691cb7a72", - "apps/web/src/lib/projectPaths.ts::getBrowseLeafPathSegment": "3d65cefe131e0b4eb2b24eed46e9e67a8c9910836f59cc4973192ebd9f0afbdb", - "apps/web/src/lib/projectPaths.ts::getBrowseParentPath": "ef944beab0a50a93d23e318a8aee24ba3236b7aaad1c7180be02f90faceb4802", - "apps/web/src/lib/projectPaths.ts::hasTrailingPathSeparator": "309df28788573bf474e5c976a6b347f13e2e912f3f3a8d87aa37b74196dfb426", - "apps/web/src/lib/projectPaths.ts::inferProjectTitleFromPath": "47168723089b72da9ffc133db5de893b354d3a5b1f3d92562effb6829c98c0e2", - "apps/web/src/lib/projectPaths.ts::isFilesystemBrowseQuery": "8a3f8f9f5d0700cf074943fa3d44931ee0e20e1f77b4053d2155ee606ec1f595", - "apps/web/src/lib/projectPaths.ts::isUnsupportedWindowsProjectPath": "cfbd368642a043939221ab31668a2ed81108edb497c7a442d33b6cfed3b51094", - "apps/web/src/lib/projectPaths.ts::normalizeProjectPathForComparison": "caedf439526d9a9100f431e94e931e1d49e40c87dd16927bb5a34e452ec26dae", - "apps/web/src/lib/projectPaths.ts::normalizeProjectPathForDispatch": "7c9a1d54e5be4ae266a8416d7258ba46a121c3e1ea20bd7d0d8804c7d5cd4219", - "apps/web/src/lib/projectPaths.ts::resolveProjectPathForDispatch": "dd87dec0b051df45c0cd05c9f59ce04c0b78713bce50151d1af26d1d1aa566ef", - "apps/web/src/lib/projectReactQuery.ts::projectSearchEntriesQueryOptions": "94c63a9fa17c0635ccebfc035177b189ec0d66e3057f77b9cdafadba9ac017a5", - "apps/web/src/lib/projectScriptKeybindings.ts::decodeProjectScriptKeybindingRule": "c0c56477528156f50c085f4fbe6c1987d58824ae30fe823290511470433b85b7", - "apps/web/src/lib/projectScriptKeybindings.ts::keybindingValueForCommand": "eb678a26b8be12551408a8648dc8231e09acac18314a988956cd2f7ca24a9b23", - "apps/web/src/lib/providerReactQuery.ts::checkpointDiffQueryOptions": "457cfe2ed61ae8d06f4422c8996be332baf043fb208969decedef6ef655aca4a", - "apps/web/src/lib/sourceControlDiscoveryState.ts::getSourceControlDiscoverySnapshot": "8283475512f72e28b17a482875061ef6648b01f45ce7ffc670e090d48616af0e", - "apps/web/src/lib/sourceControlDiscoveryState.ts::refreshSourceControlDiscovery": "d773111ced4c4a0a836e9578406cca94fee0d732afde45f400d2b4617e8cf561", - "apps/web/src/lib/sourceControlDiscoveryState.ts::resetSourceControlDiscoveryStateForTests": "e04078254ffc1ee626726d08d76451a970fc364c85aeb19c2d3dca3f0f029c25", - "apps/web/src/lib/sourceControlDiscoveryState.ts::useSourceControlDiscovery": "ff7b0d294dbecb115d6018fb2d23e7871c0e8744671149ab8b7a64a7866d1684", - "apps/web/src/lib/storage.ts::createDebouncedStorage": "046e35c6d07f8c44a4eaf3f86531d364023516ba73b68cf6b15093049eb3008d", - "apps/web/src/lib/storage.ts::createLegacyCompatibleStorage": "a0f533269549b801f2df5c742973f61cff6fedf65d7210856bdb605ca3a41688", - "apps/web/src/lib/storage.ts::createMemoryStorage": "e340052119e979c9f993696128afa5418d290030d41e0c72e14c9deacb072035", - "apps/web/src/lib/storage.ts::isStateStorage": "192d4ec45770275952fb576be4fff9cdabb267890b36bc6e247c896e80278e8e", - "apps/web/src/lib/storage.ts::resolveStorage": "245065100b578786622c6b3fa7a0a5ede2b7801173397d45a0118122310fed88", - "apps/web/src/lib/terminalContext.ts::appendTerminalContextsToPrompt": "79f3462234887f44a38e4f0e5e0cd3c1226c98b00682f64682da53a358b7a271", - "apps/web/src/lib/terminalContext.ts::buildTerminalContextBlock": "bc6510cc03beda4a6054ef7e160bc9a764dde6c812de9813e7769e6c35656d5b", - "apps/web/src/lib/terminalContext.ts::buildTerminalContextPreviewTitle": "a83930a807e68ffc1ea79e214e7b5e58e19c9270816eb6c5f816c697575a56d1", - "apps/web/src/lib/terminalContext.ts::countInlineTerminalContextPlaceholders": "6233674ed3c9a8d63218b0e5b69750289952fbf06ffec6aaaa89d68a68b75ec7", - "apps/web/src/lib/terminalContext.ts::deriveDisplayedUserMessageState": "01ae96c679f3e8b646544563a7eda426db6ca57c5a57459dc23a981667fc8996", - "apps/web/src/lib/terminalContext.ts::ensureInlineTerminalContextPlaceholders": "7c995744754398ce105369aeb7639bd3a1c61a2c90d03f315773d209a203712a", - "apps/web/src/lib/terminalContext.ts::extractTrailingTerminalContexts": "b07f2d64e7997e8798feee8ec28c7c46cdf182e57de202b295097f3242bcfab6", - "apps/web/src/lib/terminalContext.ts::filterTerminalContextsWithText": "b6e9440b76f83cfd6ae83c461b9b4f4f9d0a9ec5edd7ae61d27cfd5eec79065d", - "apps/web/src/lib/terminalContext.ts::formatInlineTerminalContextLabel": "bc61cc0a6eee8cea018ca45090b96e8296594e390a65b74d00fcd8f085a5154c", - "apps/web/src/lib/terminalContext.ts::formatTerminalContextLabel": "1735ee5eb388e483845833f0d2af37db10ea27328fac079ca2df4c554f6c8100", - "apps/web/src/lib/terminalContext.ts::formatTerminalContextRange": "75a39ff3eba4ead4651c6acb926f3cc79338b3c3e06a93c558c1d249d9f0b901", - "apps/web/src/lib/terminalContext.ts::hasTerminalContextText": "851ca5633a33b070b83962a940935f1494a0137315865869f2c06e2dee791e22", - "apps/web/src/lib/terminalContext.ts::insertInlineTerminalContextPlaceholder": "2623306d255446536cd9c5b9c924096a6386ade48d01820f1e546771e50e7a65", - "apps/web/src/lib/terminalContext.ts::isTerminalContextExpired": "d3396d24a58f2c2566b1285dde508060edffd578cb4a8c5e5bcf10aa2367e4a8", - "apps/web/src/lib/terminalContext.ts::materializeInlineTerminalContextPrompt": "9e20f3a4cbf21319686dee0e6a641aca7ddab78b21b215c33281c88207e8c39a", - "apps/web/src/lib/terminalContext.ts::normalizeTerminalContextSelection": "f16e22be75ab23a22ed7b9b316f78ed116d27223d8832c45cf982fb5613ff969", - "apps/web/src/lib/terminalContext.ts::normalizeTerminalContextText": "579963be96b15542cc148a05c49be3526700e2e59b402fe7e272de3f4411570b", - "apps/web/src/lib/terminalContext.ts::removeInlineTerminalContextPlaceholder": "cdd6665a69b7e9f948e836c3c6c8f00bb81760f267cabb3758ab994644b8c443", - "apps/web/src/lib/terminalContext.ts::stripInlineTerminalContextPlaceholders": "d228a023b2410eba80347eb07b96cd4f56184ee6d5f8a5743b01b666767f5ebd", - "apps/web/src/lib/terminalFocus.test.ts::MockHTMLElement.closest": "19044f1af33885f4746458ee21e3636af4b74a2720033b8088788f859431e5ef", - "apps/web/src/lib/terminalFocus.ts::isTerminalFocused": "45179ff3db02ffa4d5a71332abbafe54f17fcbb14386344ca1a1a183c4ec448e", - "apps/web/src/lib/terminalStateCleanup.ts::collectActiveTerminalThreadIds": "7b053fa7f8b1dc412f73c0e7333a91bf9cb1db9c9e90d2bdbb5a45d6e7158130", - "apps/web/src/lib/threadSort.ts::getLatestThreadForProject": "3e5d138e2f1a5b942e106f2fa945bc258a70c12728dd0f1ad6112db1e8735b49", - "apps/web/src/lib/threadSort.ts::getThreadSortTimestamp": "9b57d252a97c3eda456dccc741df7caa0d7345ea4e9990540daf0e860521eeb2", - "apps/web/src/lib/threadSort.ts::sortThreads": "6bffa9a50deba880fc0b4f98ed6a1bc6c9f6343674f5ab74fa2ea387673baec8", - "apps/web/src/lib/threadSort.ts::toSortableTimestamp": "72c4bd87feaf14329b942bf36f2646b447b1829307352b830ad3fa8fe94e502a", - "apps/web/src/lib/traceDiagnosticsState.ts::refreshTraceDiagnostics": "b19a83284109c088f6092e1157687105a9d49eb68429c668e1ab88dfb79aec8a", - "apps/web/src/lib/traceDiagnosticsState.ts::useTraceDiagnostics": "67e0f37ecbe5f83e05559b84c41cbb620749b803e270601dcdd77e5ba7d99368", - "apps/web/src/lib/turnDiffTree.ts::buildTurnDiffTree": "a884fc30d9ec11cad1fb5d2465af49f76f55d6bea7e067ac3dc5e05a074a0fc4", - "apps/web/src/lib/turnDiffTree.ts::summarizeTurnDiffStats": "33898fe29eb0bf3a01bd9ea6163701b0b8b4cb73897fbc4e98e7e7544749b4b4", - "apps/web/src/lib/utils.ts::cn": "1cdd77c59edde33397662753cf40f32c875f356b991786a702d9b9dd2de94888", - "apps/web/src/lib/utils.ts::isLinuxPlatform": "a31ee4bcea117bb056ba34d0b00bd2cb681f4104b8ced1e5b1a5cf5f65d627fd", - "apps/web/src/lib/utils.ts::isMacPlatform": "8fc3a216a2666d7c7f830c967ad787965636deb167f9bd1dc8ee7bf659cf7401", - "apps/web/src/lib/utils.ts::isWindowsPlatform": "5c50d887b75c6a9d2384c638f29bcdb53377b5dc0c8691ab01a45abea253e4a5", - "apps/web/src/lib/utils.ts::newCommandId": "124724cbcb6f90303f1ce5b1ede17ec1ac2f2e8d852929a56633523acddaa877", - "apps/web/src/lib/utils.ts::newDraftId": "73817540004d5fbcdee85dd98e77e29729c3be09ed96a55dc963845ea7c10b2b", - "apps/web/src/lib/utils.ts::newMessageId": "cd5121481d93dad240c1955893798d572ce7a002f0695c972e39c0a09c26d3f7", - "apps/web/src/lib/utils.ts::newProjectId": "af058e308dc02f7d99990999d3f2131f620af5f49bf328aff01727646ca4af02", - "apps/web/src/lib/utils.ts::newThreadId": "537e9e34d13915f38731c7f48f50377529656d148277a5abab87b51fb4218525", - "apps/web/src/lib/utils.ts::randomUUID": "8e50d6cd81ea94c773bd2745d974eb07f9048a7b92714c1e83d0a1e26b96c2d4", - "apps/web/src/lib/windowControlsOverlay.ts::WindowControlsOverlayLike.addEventListener": "496319a3b0a0ff65941284618ee17bd2ab38bc61a6a31ffe0818f278db0ca66b", - "apps/web/src/lib/windowControlsOverlay.ts::WindowControlsOverlayLike.removeEventListener": "99a1439ee585b58a5aa580dfaddb6a564c54d8846fa7311df8ac073a4fbed552", - "apps/web/src/lib/windowControlsOverlay.ts::syncDocumentWindowControlsOverlayClass": "4eb8247ccca156cd2f7eedb69521ece1350493dd27e97c0c160809e21ae03493", - "apps/web/src/localApi.ts::__resetLocalApiForTests": "30063c161124cb1e8eeb671c412366d22b8dff10d8be854d0560024c3e9fd4c5", - "apps/web/src/localApi.ts::createLocalApi": "917a2cbcfa8d642d25148bb7ff6994a8baa9bdc83ee4c61fe08afbffa63c3128", - "apps/web/src/localApi.ts::ensureLocalApi": "465a7dd964a0c0012e03e3c379b1e7d94a6b2cf203ed71922b214575728a3d4b", - "apps/web/src/localApi.ts::readLocalApi": "7c312e6729c48a0e4464474ab06ac157c2242ed0fd37eb661bbde2f42327682d", - "apps/web/src/logicalProject.ts::deriveLogicalProjectKey": "97ff09a3e8ca7eea1c94f5018d737b5839143a44c3e41949b366143df1990925", - "apps/web/src/logicalProject.ts::deriveLogicalProjectKeyFromRef": "68816a4cfc5eb9f0b7b7fc98b4bca1a6e5553e3b418e581121e85852ea265622", - "apps/web/src/logicalProject.ts::deriveLogicalProjectKeyFromSettings": "3904e203024002186293e41f27e4198e466681f9dd43fd7271f275a620833c57", - "apps/web/src/logicalProject.ts::derivePhysicalProjectKey": "76cb597fba3232f4d9f61d5b5083e7bdb57205530bfaf72da617f592a628a2f1", - "apps/web/src/logicalProject.ts::derivePhysicalProjectKeyFromPath": "13b37b2c5c4b94d24c2aba69680cb33f41b1b9b495a0a581af57cc34d39c82ba", - "apps/web/src/logicalProject.ts::deriveProjectGroupLabel": "fa6df5193a4396ff67b4b7d8ba473c076fb63631663fd0549143cbe0e891eaaa", - "apps/web/src/logicalProject.ts::deriveProjectGroupingOverrideKey": "f2d9880d25ed3261eec6fa53334c786eb71a4adb96257eb0385ec9e8ae1cfab6", - "apps/web/src/logicalProject.ts::getProjectOrderKey": "c34e350e74cb73f5e1a7b575458ff3475cb28a07e64173a65e0db14d2b273297", - "apps/web/src/logicalProject.ts::resolveProjectGroupingMode": "1c19e8bd5eb532505addcdff83f48a61faed68d5fe85bff1010c61d683ccf995", - "apps/web/src/logicalProject.ts::selectProjectGroupingSettings": "0d87bbf8d8d0832fce26972cf9d93a888704c918344c2b6283975be5b16e257f", - "apps/web/src/markdown-links.ts::normalizeMarkdownLinkDestination": "859f710ee60c7937774dd3c52a4026d7c6a194e4916c1a78e2e3277ead9d677f", - "apps/web/src/markdown-links.ts::resolveMarkdownFileLinkMeta": "4bbdf9c5591d946473d6adb74edd2e8de27be157158bcb828d501cec4b1167d8", - "apps/web/src/markdown-links.ts::resolveMarkdownFileLinkTarget": "e9ea7cd5088d181a78316cbc7af82bd11edadc35d0820316b1d16a31f297f750", - "apps/web/src/markdown-links.ts::rewriteMarkdownFileUriHref": "c33b2c8cf7075c45cb771afaaf17f637f216075453edf53e046eac54947215fc", - "apps/web/src/modelOrdering.ts::providerModelKey": "3406374d585841f9526a301de613068365dfd68051ca3228079a1763e7f1d902", - "apps/web/src/modelOrdering.ts::sortModelsForProviderInstance": "9f00c926eb27d1c60edc25a7feb22497b5e95546171bfd1728a5d541dabdacb0", - "apps/web/src/modelOrdering.ts::sortProviderModelItems": "5fae6801d56714df311c0ac061dd427896e4905f5d65686f4999d75c6f4078b3", - "apps/web/src/modelPickerOpenState.ts::setModelPickerOpen": "819d79d0c1960324adaef3693c22dbc7f4733185d11b8ea5c1fca1061033551e", - "apps/web/src/modelPickerOpenState.ts::useModelPickerOpen": "90a17a801631d97721824bf977e47e32f2fb703cf289b55cd067f5b54485d389", - "apps/web/src/modelSelection.ts::getAppModelOptions": "d3d8bbc130c1ba3b843b022b87de52751672290fe02a9fd741c0dd6086876616", - "apps/web/src/modelSelection.ts::getAppModelOptionsForInstance": "d679c85c3d3518a84e9cd6d94184acdfd8d46e0333d7b79d27fc47b51cdfd091", - "apps/web/src/modelSelection.ts::getCustomModelOptionsByInstance": "4d69fd64e76db7b0d2e49e57a2516c175223d92491896e11765782611bf271a6", - "apps/web/src/modelSelection.ts::normalizeCustomModelSlugs": "febe994d8bede6f40366487703489b20a58590a941a0c65585d68e66f239095a", - "apps/web/src/modelSelection.ts::resolveAppModelSelection": "ee39f3b806ccb1b6f70168d1bd718b85b5d6c73377cd7b76234ccd8f67bbd6a5", - "apps/web/src/modelSelection.ts::resolveAppModelSelectionForInstance": "ae2eb1397c3f1fa0f372486d4740dbe59a539a4cb5dd63c4f0619ce629b88256", - "apps/web/src/modelSelection.ts::resolveAppModelSelectionState": "e64de8c6e9b66f1b0c4ca76a8dd03e09221253659400978d4f4a333547b81f84", - "apps/web/src/observability/clientTracing.ts::__resetClientTracingForTests": "e1de4d14cb6ca335fb2a2f0a94be191602a3892ae04d1ee17d11911013d2838b", - "apps/web/src/observability/clientTracing.ts::configureClientTracing": "3e4ef0f72ac731e9575a9c45175beccd33d68e928e48a68a6c2f8f3734c66da0", - "apps/web/src/orchestrationEventEffects.ts::deriveOrchestrationBatchEffects": "b42fb1d5b95a4787227badf30746d4ca987c9cf3067e65c964d323f32c20b496", - "apps/web/src/orchestrationRecovery.ts::createOrchestrationRecoveryCoordinator": "56ff30a9964d055e834ddb13c148ecc6e2405e3fde50b95f24b00c6b60e4405b", - "apps/web/src/orchestrationRecovery.ts::deriveReplayRetryDecision": "7bdf8dcde6d43bc78d20e51b1c77d9bd5be29e66d139ee4d8cd6a2f476197fab", - "apps/web/src/pairingUrl.ts::getPairingTokenFromUrl": "17edf3fd5768bc1bee2f114cbfecf0fca2cfaada40e4d7f5ccf683ca49a5b8c8", - "apps/web/src/pairingUrl.ts::setPairingTokenOnUrl": "f59663a062dc42c1fba3edecdf351187bd7978f58592f54f3f7ed54dd62fc1f8", - "apps/web/src/pairingUrl.ts::stripPairingTokenFromUrl": "b74b3c26d6acbcb99a5f207d8c486c9bb30cf3e159b8df52ecfb394ee2c44874", - "apps/web/src/pendingUserInput.ts::buildPendingUserInputAnswers": "b616ee823c0a6e5cab4a8431ccea29ee69dfa4a184a0dde46c65739870343b02", - "apps/web/src/pendingUserInput.ts::countAnsweredPendingUserInputQuestions": "ac86aed21d4afa06ec2fb78ed7ee2f46c9cbea6d465f67a1bf3e1799c0954660", - "apps/web/src/pendingUserInput.ts::derivePendingUserInputProgress": "5d4211a0a1b1dc0afca7c1bc229a21d58d123999a11cedfc704568c4cc26d8cb", - "apps/web/src/pendingUserInput.ts::findFirstUnansweredPendingUserInputQuestionIndex": "8f8537dae685cf762874117d61f6c80661fdb5338f9a64092a674e172a286b78", - "apps/web/src/pendingUserInput.ts::resolvePendingUserInputAnswer": "4a392f0972b3da01d9aa7e0e3a2484d1ebfcd93f57ca5b5c32d0a9d5fe37545b", - "apps/web/src/pendingUserInput.ts::setPendingUserInputCustomAnswer": "6edc00dfd226e3ade18120e15ce65d3ba5d256c31fb079894c33fbb3c6745746", - "apps/web/src/pendingUserInput.ts::togglePendingUserInputOptionSelection": "0814415296bb0e01ddda599e4e20257e41624589950222b4b730e970910b0102", - "apps/web/src/projectScripts.ts::commandForProjectScript": "52c32233855ebab895bdec055ae69aa8d463191c9cd986a082e0cc232c914302", - "apps/web/src/projectScripts.ts::nextProjectScriptId": "47be76e472dc99d5962c64501aad24fa96cd38f735facb3861f97eda59410e86", - "apps/web/src/projectScripts.ts::primaryProjectScript": "b6b9d3e6af41e20aaee6ccf80e0b06beb045bfb74669d7ee9d38d691e1b28ddc", - "apps/web/src/projectScripts.ts::projectScriptIdFromCommand": "66c48039f4bd947da8b7d0ecb28cf7cc76424ae708aa07a256ca35988263af3d", - "apps/web/src/proposedPlan.ts::buildCollapsedProposedPlanPreviewMarkdown": "9f3688ea6c7bd23a5a27d41999e3024d366a4a86202c14e3ed4ddaae65ad4fe3", - "apps/web/src/proposedPlan.ts::buildPlanImplementationPrompt": "dc21d93a68dbe45c029c26f848d73cac5cd12aff36765097078b3deb15188b30", - "apps/web/src/proposedPlan.ts::buildPlanImplementationThreadTitle": "a078dbb6a7cd62a5d98a69fbf4f95b832f981dc4d86e06314ac9ad989e4013d8", - "apps/web/src/proposedPlan.ts::buildProposedPlanMarkdownFilename": "1d07df141d65d84e8d59357df1216cf3d4b7b4c4486dc218c38c3e0483e24933", - "apps/web/src/proposedPlan.ts::downloadPlanAsTextFile": "38bc2aae73720cac0ca0bbfedeb1f794d4297138facd081848a02b9f619045c8", - "apps/web/src/proposedPlan.ts::normalizePlanMarkdownForExport": "12efe35dbfda5655571bb4cf21e352f1655e95f311407449f5f54b691dac174d", - "apps/web/src/proposedPlan.ts::proposedPlanTitle": "93adde6d98452ca7085176758ecd07e6d2839883d28a84bb7ab6b5be854ef6c1", - "apps/web/src/proposedPlan.ts::resolvePlanFollowUpSubmission": "ee3f585a17dacc58c7897b3b6e6b6606ab12737f6094301eb6ca619fb09b8378", - "apps/web/src/proposedPlan.ts::stripDisplayedPlanMarkdown": "abf98fb4864c496e3744d40504801dd24a1b322c899a40612f602d36e2215b1c", - "apps/web/src/providerInstances.ts::deriveProviderInstanceEntries": "a7392980a18f023e8370fa6ac1fb37b73033624249680d4309b5f319f8cccfef", - "apps/web/src/providerInstances.ts::getProviderInstanceEntry": "d5a648871275c5ce3d15dfd65c73e75fb06f6dbbadf0a918b3dfb23487d05077", - "apps/web/src/providerInstances.ts::getProviderInstanceModels": "92d259c2f661e9d93a54a04cfab5dbdf1988632cdef3e93bdf86dda26e3522ab", - "apps/web/src/providerInstances.ts::normalizeProviderAccentColor": "7a2acd6eec2e4376705364ca477c25dd07fee0ea2036714aa815253744d5036f", - "apps/web/src/providerInstances.ts::resolveProviderDriverKindForInstanceSelection": "cd966472954332a230650554fd6344b67b1f9652568dbe0ed07a0d594f849825", - "apps/web/src/providerInstances.ts::resolveSelectableProviderInstance": "8346213b661a0a896b1e2505b17ce84bf865637fb10539b9eab6e3b9bbcb4599", - "apps/web/src/providerInstances.ts::sortProviderInstanceEntries": "012af92149d6e7594170380872c8bd8c27a7c79cc6dc4ee7e8c18792746dcf94", - "apps/web/src/providerModels.ts::formatProviderDriverKindLabel": "b2b2a2f23804e594abfcad4e4ca4bedea51bcec92f35cca742bafd1c6791670b", - "apps/web/src/providerModels.ts::getDefaultServerModel": "e7bb6deaf5985ebda2beba9bd36857376631ebfcb30df3c401ad0900499786c5", - "apps/web/src/providerModels.ts::getProviderDisplayName": "3f6856be401fbc9b0ee19f9402c1c7e05feabcfc07673b2adaa57fe59f3e9642", - "apps/web/src/providerModels.ts::getProviderInteractionModeToggle": "6762e4418e836505f03a7f61575f91aa41124fd23f665becd7d172a79a929eca", - "apps/web/src/providerModels.ts::getProviderModelCapabilities": "fbdf3473e9e426e03c6df97d4f59a82d844a4ecffc79ee74a03ea9e79e2746de", - "apps/web/src/providerModels.ts::getProviderModels": "41ca40bca1e9e666258b9f792c9fafd7197506263c0eac08a9e21f2a9b18746a", - "apps/web/src/providerModels.ts::getProviderSnapshot": "36a22da2c96f067aa7d0150de6be3dfdfd316bb0d64c0023680220c450800a10", - "apps/web/src/providerModels.ts::isProviderEnabled": "74701445586444e461d385cb785d5d7589c0b6406f87363a8d448df92f038727", - "apps/web/src/providerModels.ts::resolveSelectableProvider": "a3236da1ec8ea1e274b182b8c146b88aac88347108c6c9e7d1126d30b412bf2d", - "apps/web/src/providerSkillPresentation.ts::formatProviderSkillDisplayName": "41d6492f4950e8b6b12e8b19d4f921d7cb6f31dc9242952ad7e0a2425055af45", - "apps/web/src/providerSkillPresentation.ts::formatProviderSkillInstallSource": "59f3dffae022f51fa01d2d1884ea6434356aebebde4a4b6243d6fa99207b997b", - "apps/web/src/providerSkillSearch.ts::searchProviderSkills": "c259aab64cec07aeaf2545f8838243b101a2cfe124d927bc06af1a25d332931f", - "apps/web/src/providerUpdateDismissal.ts::dismissProviderUpdateNotification": "f754d92771d10e8284943f63f33d7d9798848c71479eb5a39a02eb468e684b34", - "apps/web/src/providerUpdateDismissal.ts::isProviderUpdateNotificationDismissed": "5524f326bffea51983933e55c1bbf503c30bb654933ba5dc203ff5e597f91478", - "apps/web/src/providerUpdateDismissal.ts::useDismissedProviderUpdateNotificationKeys": "e38b33851157d878a105a7fb6102df7d2aecf10d0b0ae5e2619f5d2d4f66bede", - "apps/web/src/pullRequestReference.ts::parsePullRequestReference": "72f8e2eabff3bdd17eff25a6bfa9535ba0482fcdd3f70e0cc138027661c86311", - "apps/web/src/router.ts::getRouter": "a0b218221f837f541be70eea77cc110cd96a53c82930f7ed5bb373b89c0fc224", - "apps/web/src/rpc/atomRegistry.ts::AppAtomRegistryProvider": "b6e64a27103e21bc453a6ff6359c54377004957833aa745aaecd8bd33ecb1774", - "apps/web/src/rpc/atomRegistry.ts::resetAppAtomRegistryForTests": "5995e27b394eeaee5a9d01a383d6851d90b24a8d2e0d178becbc071d5d5b40f9", - "apps/web/src/rpc/protocol.ts::createWsRpcProtocolLayer": "0e6f344bca10d4aaf995c9dedfb10050aaf3471921b4c45135e41e8932bf4fa7", - "apps/web/src/rpc/requestLatencyState.ts::acknowledgeRpcRequest": "536ca53c6d45375459ec79fe6784da46b1e7f1db671858417a8c55104fdcfd53", - "apps/web/src/rpc/requestLatencyState.ts::clearAllTrackedRpcRequests": "e558ba16a2d1db27c91d79c35d375038ac8b9dbae344751948a766fa33c5af38", - "apps/web/src/rpc/requestLatencyState.ts::getSlowRpcAckRequests": "86b22d235f7b682c71c841b1739a1a0cc4a710653a432db81fcfd45d9ae00e4f", - "apps/web/src/rpc/requestLatencyState.ts::resetRequestLatencyStateForTests": "501dd8fd519d68a87e374d6d85c6d46c270ca01f9acc946d1c2ddf88bac33ba8", - "apps/web/src/rpc/requestLatencyState.ts::setSlowRpcAckThresholdMsForTests": "55039e180c0fc585c7cb06c9ca38a876c1bd23ae11f6eb2806c002e69930534f", - "apps/web/src/rpc/requestLatencyState.ts::trackRpcRequestSent": "4d9a224e831fffc7448d67ad0c13a0ed2271b0b5e7b0e9203d0fe052f94af84a", - "apps/web/src/rpc/requestLatencyState.ts::useSlowRpcAckRequests": "3c67c88efccbc3a0d39d63144f497696e1915337c879da3e7b294cc739dbdc0e", - "apps/web/src/rpc/serverState.ts::applyProvidersUpdated": "e02cc7d77bd5d7d0e55263469bbdcf85a8b4255bdd58db7c3f200ae87cfd06af", - "apps/web/src/rpc/serverState.ts::applyServerConfigEvent": "fd68d0f539322075ffc84a4440c784c2109fe3194a1d8a1c1e5d05f745c4ec70", - "apps/web/src/rpc/serverState.ts::applySettingsUpdated": "d4d347d11965f500627d639a30842d1ed506a0cc3920b3615951f12458ffb536", - "apps/web/src/rpc/serverState.ts::emitWelcome": "e2bdc9ad2d454f16394e0a059ec7ca2f1740e337ff6706307f484e4a409f1913", - "apps/web/src/rpc/serverState.ts::getServerConfig": "57b2aa62de06ab051dc13a8f9964f4eab92604f8fb7295c6c138d8cc9c42367e", - "apps/web/src/rpc/serverState.ts::getServerConfigUpdatedNotification": "bc36c58b3717abae89476640f27115aaece356f8b24917936905838c1b29e4a4", - "apps/web/src/rpc/serverState.ts::getServerKeybindings": "c2dc971c381ab10a4cc29d9ae2a05616caa2a4c9b5b577d43dc8ed1221d8df3c", - "apps/web/src/rpc/serverState.ts::onProvidersUpdated": "f705c1810bb0dc82c1f0f4300e91b7a87c8255415062017f4c2a89ae3a5a65ba", - "apps/web/src/rpc/serverState.ts::onServerConfigUpdated": "e2e0607184fbefccc348f3a7ce1dae99777ecd2bba2fe9ea94bd59471bed8f60", - "apps/web/src/rpc/serverState.ts::onWelcome": "60fb6a186cf11a0a1aaa2d83b190dc97e69d8b8740e287c984290b8d75f6c4d5", - "apps/web/src/rpc/serverState.ts::resetServerStateForTests": "fe63bfe392cef1f5ea969d5d24f74ed824a2510044f396f117d7b748eb6646fb", - "apps/web/src/rpc/serverState.ts::setServerConfigSnapshot": "3f394a317eb64d90ba8e811abe90659cfff76230ba876387a05b2b63a8765dab", - "apps/web/src/rpc/serverState.ts::startServerStateSync": "56cfe0eb56d83c2f9ac844faade0016fc067052262f0a30fd0ff6e8c0356d540", - "apps/web/src/rpc/serverState.ts::useServerAvailableEditors": "e6e173367aef8c90c0f8d2e662bf5a1dca155b9d1f8491b8d92771a11558574b", - "apps/web/src/rpc/serverState.ts::useServerConfig": "53a4b7eaa54f485f749ef3edfd986bff02072d281d26d964adb0cc9fd5b4faa7", - "apps/web/src/rpc/serverState.ts::useServerConfigUpdatedSubscription": "47e5accc196fd29b5622d1c79da76d33b65895b52d8af06f43010d29e9df6a98", - "apps/web/src/rpc/serverState.ts::useServerKeybindings": "4c9a3e85fae3eb37ee88b27bafb477eae3cea68d8cfb5d13be394bca43379c04", - "apps/web/src/rpc/serverState.ts::useServerKeybindingsConfigPath": "9cc7833965c5bef3f23fa9d6be202cb9e9122d266d4fa8f90a2c02369cea6fae", - "apps/web/src/rpc/serverState.ts::useServerObservability": "0470caaf2d36cb8910ed55a6675aafdd2a35f7f5ba47b1eb3b5dc790ea2386e7", - "apps/web/src/rpc/serverState.ts::useServerProviders": "04201a26a08638aee268c437e59e6ec8915b2f10275a4fb4be590e3470119ed1", - "apps/web/src/rpc/serverState.ts::useServerSettings": "8426bea2f661ffc84f2f0a656fd29c695e6bb8de90c117c9ac95c9aa5aa77e31", - "apps/web/src/rpc/serverState.ts::useServerWelcomeSubscription": "6184a9e78c16969b5197a9f922039cbbe7aef51f1827827a2b42ec88929bc96f", - "apps/web/src/rpc/transportError.ts::isTransportConnectionErrorMessage": "439b8369e157a57cfa824ed9789d0dc18d3c0880ce4424b4651b0b9614b23d42", - "apps/web/src/rpc/transportError.ts::sanitizeThreadErrorMessage": "88ec4e56607ecf1c4be5c69a82d642f7b48a5ebdd499fcc50e4c3895ad776d35", - "apps/web/src/rpc/wsConnectionState.ts::getWsConnectionStatus": "791e8304a43340ca6cb72f642fb130432b9a58b07020fa83fed1b16d7cb5cb7c", - "apps/web/src/rpc/wsConnectionState.ts::getWsConnectionUiState": "ee61ca948597b1176e473f6bd832b9b8469f68455735465b0d1dcf5eeb09513f", - "apps/web/src/rpc/wsConnectionState.ts::getWsReconnectDelayMsForRetry": "c2207be67915d47f3920d3076296bb22c0040cf753467c4bd56d14bce81fff7f", - "apps/web/src/rpc/wsConnectionState.ts::recordWsConnectionAttempt": "e2af884228b43ee7fdf44264ca0385283cbcfd4018986bb4a549dcc138b22830", - "apps/web/src/rpc/wsConnectionState.ts::recordWsConnectionClosed": "5159f2cdf6f0917efc673b3a8de76f87750279707f03685a565f18260dde4b97", - "apps/web/src/rpc/wsConnectionState.ts::recordWsConnectionErrored": "032264ef7d7ae644cb7efaf5946f9ab034940279f9b47d82b1f613c0465f5e8c", - "apps/web/src/rpc/wsConnectionState.ts::recordWsConnectionOpened": "4365f6a58ede16a6bbca8c978b7125a7f39e938b53df5db69bd062c10dbddea7", - "apps/web/src/rpc/wsConnectionState.ts::resetWsConnectionStateForTests": "86ff5fd38711c4bb04f0be009f211d59a198f249f63f5bd09c81bddbccadbe74", - "apps/web/src/rpc/wsConnectionState.ts::resetWsReconnectBackoff": "4524d0da79c6192ed5a158e1a283260eb01914080243dc987aadbcf906f99fac", - "apps/web/src/rpc/wsConnectionState.ts::setBrowserOnlineStatus": "03d48919617931196d695869e36f4ebf2a3cc69502bbac31182b54720f343ef4", - "apps/web/src/rpc/wsConnectionState.ts::useWsConnectionStatus": "ac9b288d7fe9e531268575b1d5203552a46cddc1b25358cb28c528500dea43b2", - "apps/web/src/rpc/wsRpcClient.ts::createWsRpcClient": "5b783b97043576cbed06814891d7ac8d6c1e75cb927ea70879e9e845e34ad2f7", - "apps/web/src/rpc/wsTransport.test.ts::MockWebSocket.addEventListener": "b43f22a06ea9b51aa2086497810a9ff9d4b15a9f2435107ef168a24305a117b3", - "apps/web/src/rpc/wsTransport.test.ts::MockWebSocket.close": "ee371fa62ecc780ec9a7748972f13641321e77b79bf7741c84ef020d89ed1c7d", - "apps/web/src/rpc/wsTransport.test.ts::MockWebSocket.constructor": "0dc4da62dda39e0199ff6466fba4b0c8b58c922fce048bdd1e6cef6e8714a88c", - "apps/web/src/rpc/wsTransport.test.ts::MockWebSocket.emit": "62c5c498272292564337e54e75e2249236d2373550dc2de4996af568f8eecc14", - "apps/web/src/rpc/wsTransport.test.ts::MockWebSocket.error": "8705182bc7385871967f65602c56331b1353863791fb1ba775132cc068e0c97e", - "apps/web/src/rpc/wsTransport.test.ts::MockWebSocket.open": "716c7844eaa061a3508674a98a986d5071d7a6691e5b460a617a73c477f8ffed", - "apps/web/src/rpc/wsTransport.test.ts::MockWebSocket.removeEventListener": "49931b6aa54bad02cbe91045d64daee76588fc751fc006d8568694ad0a537f1f", - "apps/web/src/rpc/wsTransport.test.ts::MockWebSocket.send": "2cccc1810a5f732fb42b8eb6251d92e6bf6e6a6fcdbb44b7acda552fe8441a15", - "apps/web/src/rpc/wsTransport.test.ts::MockWebSocket.serverMessage": "e2263d8d1a264bda0eb7e3d62412c115141399e20a96b6add4783f500cf2f668", - "apps/web/src/rpc/wsTransport.ts::WsTransport.closeSession": "421850bcd8993c3a3890bcca7966b56de099c5f850025ad2045d8c961e7d8cf3", - "apps/web/src/rpc/wsTransport.ts::WsTransport.constructor": "4f161c8b2231383c9c94ea7f883e5a48f8c8732bc9f8a82c9a78985fe8b61916", - "apps/web/src/rpc/wsTransport.ts::WsTransport.createSession": "048e386d85fbf68f7bdae784b36a23d7a29c8b43b7d565ec6be394c255c369ea", - "apps/web/src/rpc/wsTransport.ts::WsTransport.dispose": "28a0a13e463f99b8ebf6b8c2446578fa8f4faa23b4c07e3d0caa10c8f2913935", - "apps/web/src/rpc/wsTransport.ts::WsTransport.isHeartbeatFresh": "21f9793bf85c4576730169ebb272a0491ac6efe6c7245f88ca4dec84f62fb3cb", - "apps/web/src/rpc/wsTransport.ts::WsTransport.reconnect": "95ebb92766bd454713273b0f797146d95233b344f000570383837de9b318ae40", - "apps/web/src/rpc/wsTransport.ts::WsTransport.request": "d68416757a9a3ad887d3834d4c709a1d31cc75aaaff5d48673e764e9ee43b82c", - "apps/web/src/rpc/wsTransport.ts::WsTransport.requestStream": "29a8641c220ab7431177a735da5007980025cb95c62d8e70e3d6e3a807f408f6", - "apps/web/src/rpc/wsTransport.ts::WsTransport.runStreamOnSession": "36b80938a4ab028af1225eda4bef624a1a9fdabc7287326248e48e92f3e8fe3b", - "apps/web/src/rpc/wsTransport.ts::WsTransport.subscribe": "22271f3e02c4f1d220a482f5c8db9c418551999c4c3462560c6d2139c3e3c2ce", - "apps/web/src/session-logic.ts::deriveActivePlanState": "de70605b5efe68d8f18c0d6c62c153bb5607ad7aec961a5a4728bc381f06d466", - "apps/web/src/session-logic.ts::deriveActiveWorkStartedAt": "6c78728875d9da28bccd2bcbb082b0c2b835a8817ddb83b2d4c39dd57ad07d08", - "apps/web/src/session-logic.ts::deriveCompletionDividerBeforeEntryId": "5f767ebb7511fe112fb59bd1d02ab828d57ad4641bb8abe1194b5e498dbd8630", - "apps/web/src/session-logic.ts::derivePendingApprovals": "215a8e4f5e2d23c46e30999bb800b1a49521335fbb04a4c8bebfbe12bc565737", - "apps/web/src/session-logic.ts::derivePendingUserInputs": "9bc988ceb44246be80a20deca875ad7f22669b344cb9f8962dee2036d1fbc4ca", - "apps/web/src/session-logic.ts::derivePhase": "5ad449404560022b3dad9f0c3b17eff585cf60b8ce859c1f94af54f7d681419c", - "apps/web/src/session-logic.ts::deriveTimelineEntries": "a321572c869eebde9e29b096d0ec0335746f1373864a4daf628eb5fd5297f2b0", - "apps/web/src/session-logic.ts::deriveWorkLogEntries": "1bc3bc952081cee853b1339e120b0d06c5653694c89cead0a9ab3565d64ab379", - "apps/web/src/session-logic.ts::findLatestProposedPlan": "09400aad34364b42a7ea0b3b045ff4d992be2e8a6187cf4ef16cb31d1d14d04d", - "apps/web/src/session-logic.ts::findSidebarProposedPlan": "e1f748f7c9706d633b9213cbfdac590e6a56985499b2c905a4481a1f0753eae8", - "apps/web/src/session-logic.ts::formatDuration": "8de018dd9a0ed324a015a112143f5ebb074622c51575c71be3b3274a7239943f", - "apps/web/src/session-logic.ts::formatElapsed": "3d71d2faec1e1bc91da5cc608eeda7dc2a44ea1cdfe791f8fa31819117c80ffc", - "apps/web/src/session-logic.ts::hasActionableProposedPlan": "9f390da4ededa5a285ca44c85bb22b954d1306c3631ee2539f7656afd53747b4", - "apps/web/src/session-logic.ts::hasToolActivityForTurn": "4d5cc1dcc43eeff477aaae8e3ea9b747ed11abd47704cca08864ac6cc216d2db", - "apps/web/src/session-logic.ts::inferCheckpointTurnCountByTurnId": "54ff88bcba58fc40e27dc7d3f685514a0a66f6817b36014262b0044076840750", - "apps/web/src/session-logic.ts::isLatestTurnSettled": "09c466cbf47ca2adfc4f8ba4e624bd2f5aed8763621f5d7a1e47163b56f21efb", - "apps/web/src/shortcutModifierState.ts::areShortcutModifierStatesEqual": "0c75dd9405c14a11147e6950c224e8c533cbed1a0438dc30b1dac7ea0f95dc41", - "apps/web/src/shortcutModifierState.ts::clearShortcutModifierState": "a4bc63dca79df05e11cfc4e402ea963601251355939f3c3c46dd162c860f1447", - "apps/web/src/shortcutModifierState.ts::readShortcutModifierState": "e370c2199dc15ae66490435db794b69e8a006b8448b4cd428f167e534e709b65", - "apps/web/src/shortcutModifierState.ts::setShortcutModifierState": "ea504809c434f970123f19d0b05948f4e4beb6b0ed8f4e92b3c6ce0f6ce0d19f", - "apps/web/src/shortcutModifierState.ts::syncShortcutModifierStateFromKeyboardEvent": "e35693a7076309af43cb4c1ac9bb5754285a6a2b5866eeaa52cf74da82b69de2", - "apps/web/src/shortcutModifierState.ts::useShortcutModifierState": "70af268a72817f60abd063dce7cedb29701fdafb316fb01acff8ca0858258e05", - "apps/web/src/sidebarProjectGrouping.ts::buildPhysicalToLogicalProjectKeyMap": "9a731583d2a0827852df437c0fe06f2f45ec5aed3a107b661ac2d43c2e695856", - "apps/web/src/sidebarProjectGrouping.ts::buildSidebarProjectSnapshots": "7608a939a70ec95ad909327be0547923cf42006ba0b862d8e916fd931c4ab306", - "apps/web/src/sourceControlPresentation.ts::getSourceControlPresentation": "38873b5e940c495a608bd97c1b3711723ba961525c47b774cb0cc4b10f492091", - "apps/web/src/store.ts::applyOrchestrationEvent": "3fd38c63051b52f4030c2fd7fbf05dd2225e802b4b651f63503639a937e86b8b", - "apps/web/src/store.ts::applyOrchestrationEvents": "84838b3d2a936bf39acae61cc2e9aae83d38eb47a874293d916b324aa2184c49", - "apps/web/src/store.ts::applyShellEvent": "164e276fbff5c8b14c8f04112a0dad9f3379a403f4cfa208d634656cf0a654da", - "apps/web/src/store.ts::removeEnvironmentState": "3d3f12d60692404b025d8cf94b1472b23c6a44ecc652c1547f125c43c345af55", - "apps/web/src/store.ts::selectBootstrapCompleteForActiveEnvironment": "8b2d2b5ab0b92fda8cc3b96f36f53a7aa9768b34eb78003834616fd3dab8c3ed", - "apps/web/src/store.ts::selectEnvironmentState": "214131d9b96a0084f0b23b8c0eaa479468fb8f0c33b9aa2a50d3aeca42e25e7f", - "apps/web/src/store.ts::selectProjectByRef": "26563aea4e00ffcff84649710af69e55b30b9c6d463576e9a0acf01e2a365262", - "apps/web/src/store.ts::selectProjectsAcrossEnvironments": "6a176af2b8d8ca120d08d8ad6bb54526d10d93eab906d996457af0aa7a7e4dd8", - "apps/web/src/store.ts::selectProjectsForEnvironment": "35d709d912ac4d9c1e7e51924bc415891ec57deb20a1d5f991022e139db79db8", - "apps/web/src/store.ts::selectSidebarThreadSummaryByRef": "38d9f50d35231f097f8c0e0c3ce2c4f6c2a3dde8e89bc76c968b1bdb2b72ea96", - "apps/web/src/store.ts::selectSidebarThreadsAcrossEnvironments": "1347591226561a107d569a33da1f959464ea23530634358e75a42db50e7ad70b", - "apps/web/src/store.ts::selectSidebarThreadsForProjectRef": "a9fae921c539449db8596eb1e54edbc9c3126f8c4e558f36710abaa8a816aec8", - "apps/web/src/store.ts::selectSidebarThreadsForProjectRefs": "ced7e03dbe8c8a0530a78190ad26a04c84fbf26aa426b15b2e1113ad5dca3e11", - "apps/web/src/store.ts::selectThreadByRef": "581811cfd22f012aa3bf45249ad8e0ce75fd7ae114c816a4f267713de6d1c045", - "apps/web/src/store.ts::selectThreadExistsByRef": "0ee9b87f59ab9ec09b92a2a9a0d4b022259ac6dec4a726b5ade76697ba479dba", - "apps/web/src/store.ts::selectThreadIdsByProjectRef": "4edb496266e259caeafd9fd755f0e6d97a024ab5675b4c628eba09f0d48c9a5a", - "apps/web/src/store.ts::selectThreadShellsAcrossEnvironments": "a7161ed561edf9f2f1bd1147f42affe95c8f8e442a7850d69ccf1d427b409023", - "apps/web/src/store.ts::selectThreadsAcrossEnvironments": "f3e2e7a75016c6df964fe26ea8219debaa72dbd1fcdc234a32cad6fd92d8df47", - "apps/web/src/store.ts::selectThreadsForEnvironment": "f65a34328be4a2eeec016586ccbed665a47dd026fcbd35734a13bc40fc48d722", - "apps/web/src/store.ts::setActiveEnvironmentId": "156de57a3651672ac5f60941e24e70567709f066c810d218ec7584957af7c807", - "apps/web/src/store.ts::setError": "b91ad456b88551b1fe165b6382ba52b72021c5674158d3cb0c1f53c0d7ed8d8b", - "apps/web/src/store.ts::setThreadBranch": "f72d00cb76298361ce21ffb49160d8dec5fedbdad7461849bd20b0adf96c4d23", - "apps/web/src/store.ts::syncServerShellSnapshot": "81b46c7cb6a5ff0ab1c1ee59b4c05eacf3e34a9782452c9b984a68ff338e0f9c", - "apps/web/src/store.ts::syncServerThreadDetail": "e258c5e20bd700f2c39950f4cdcfda29837d64eafb2c47a68a941316bbbb66f7", - "apps/web/src/storeSelectors.ts::createProjectSelectorByRef": "874a57d7f2705c2711a6be42b709f052e6ddd418ca80ee0bb68c2c2618ac2032", - "apps/web/src/storeSelectors.ts::createThreadSelectorAcrossEnvironments": "b04e8d06f9bdb9718a572b0a3032c3db5d6e71bbd4b02396b627664fcc233c4b", - "apps/web/src/storeSelectors.ts::createThreadSelectorByRef": "b03c7387d7b9daedf58430c24a05452d0368b0d173238559ae2ef457543ecbd1", - "apps/web/src/terminal-links.ts::TerminalBufferLineLike.translateToString": "e78c7c4a6f1c8ce43f14388e53229f61801c46cd9aa55486e4a58c02cdd0ba6f", - "apps/web/src/terminal-links.ts::collectWrappedTerminalLinkLine": "a75e48d38058889ff26bc096d10976a85399b192975f779aa438429ef08b704f", - "apps/web/src/terminal-links.ts::extractTerminalLinks": "02bfb771c95bc40935922427d36e0e91a748849b45c869685fc32fc475256f95", - "apps/web/src/terminal-links.ts::isTerminalLinkActivation": "b483191068ad28031bea827f6f181f93fcd243a5373bd8df05b7a8205f40e22c", - "apps/web/src/terminal-links.ts::resolvePathLinkTarget": "4fcd8a313d0cf449964f9ce853222ac11c17b96baaa66a728db72ea36ca8b21e", - "apps/web/src/terminal-links.ts::resolveWrappedTerminalLinkRange": "f1fdebfc80bfc6aff2d23e7d80c64437b4e9eb14b7addc1e9b99c10b8512b344", - "apps/web/src/terminal-links.ts::splitPathAndPosition": "e0c95adeecc29ac029f5613516add8b99119bd63ab181ac1e35bdbbff7921029", - "apps/web/src/terminal-links.ts::wrappedTerminalLinkRangeIntersectsBufferLine": "056aad4053f462f89fa6ca20751c3857969f5e7701cb7ffc22696ce75fdd8810", - "apps/web/src/terminalActivity.ts::terminalRunningSubprocessFromEvent": "c693eabdd8b6f3db4a50cf271f60db0e0c5e90950256cd974c677abb2c08fe12", - "apps/web/src/terminalStateStore.ts::migratePersistedTerminalStateStoreState": "22d0eaba4223cbce18b91692969764aee30284f16ac1a7051ae8e696f46dbcfb", - "apps/web/src/terminalStateStore.ts::selectTerminalEventEntries": "826428f63dc1c501a7ac9a299621c2ce65500822210f19a8db160671dd48b1b2", - "apps/web/src/terminalStateStore.ts::selectThreadTerminalState": "24bfbf974afb6a8f9fcda37185e982b3361164b16613dbf04699f2b657d7f119", - "apps/web/src/terminalStateStore.ts::updateTerminal": "9f8bf43a54d30b34583f119c062cd3fe7b17b8c2a419a33c647a69d4c7bae324", - "apps/web/src/threadDerivation.ts::getThreadFromEnvironmentState": "85b754766c8fb44de8bd7a1278645acb03e57438819713061b46d1b3f8d90c36", - "apps/web/src/threadRoutes.ts::buildDraftThreadRouteParams": "fd940ef7d9406efe0cf601f7366eb5b653f61914c12e491aa71b47c555152cd6", - "apps/web/src/threadRoutes.ts::buildThreadRouteParams": "24f80ca8fa4a8f9e91052f348a65908314ddb7b64ad505d3ae9cb2b76584e3fc", - "apps/web/src/threadRoutes.ts::resolveThreadRouteRef": "e26e2a2ed80adedaab6a2d5bdd9eb41efcdc531b7e73adc3c246b6202a57f9ad", - "apps/web/src/threadRoutes.ts::resolveThreadRouteTarget": "ef61b5f5bb26c44623e318ed148cd5231faad8523cc1a007c2d4f61b7fe1257e", - "apps/web/src/timestampFormat.ts::formatElapsedDurationLabel": "bb36846db5994b64fa60d44b87d43852f375304f152b7ad8db257c9962007252", - "apps/web/src/timestampFormat.ts::formatExpiresInLabel": "940a4057f37b856183bcf2cd7f00a7ff1dac929bb43c658615f7edbc59da5c9a", - "apps/web/src/timestampFormat.ts::formatRelativeTime": "2427bcf784454784be6348460b44234589bac3fd5fc7ff7025e586bb97aac4d6", - "apps/web/src/timestampFormat.ts::formatRelativeTimeLabel": "15dfc5449da8bc7836f47a032c8ebe55eae27f2b51789ba47dec4a1f17564694", - "apps/web/src/timestampFormat.ts::formatRelativeTimeUntil": "1e6ebf403aff52c4d0adfdc26f2877174e045175758697a27d7888667029a748", - "apps/web/src/timestampFormat.ts::formatRelativeTimeUntilLabel": "c399703df23c778f9cee2c91e352bcc002ef10f201a958becd9f7ae2b601de4d", - "apps/web/src/timestampFormat.ts::formatShortTimestamp": "c67f4704486d146fd6cd177da632f1b0e6b5b2dbb5c27b999ec97df2b0588363", - "apps/web/src/timestampFormat.ts::formatTimestamp": "9e12d35e4dbe83c9cbe1a60f7c687342adf0936fd32cd77bdceb4240c85a31ae", - "apps/web/src/timestampFormat.ts::getTimestampFormatOptions": "b212bbd2c2e80b774aba0b539e7544ebcb69c07c6497c248faf4e77f9b2364dc", - "apps/web/src/uiStateStore.ts::clearThreadUi": "bbbd64d347f28240801399cbc027d0e55b6123562859bedffe57c2d49d4fb710", - "apps/web/src/uiStateStore.ts::hydratePersistedProjectState": "e27c45b154783625b00334b04ff2c15dc045ca8d8218658f0b61246cf07dc6ac", - "apps/web/src/uiStateStore.ts::markThreadUnread": "bdd89a6427b8cbce595111a8d04713512415df9b453624d78b69176e7d123a5b", - "apps/web/src/uiStateStore.ts::markThreadVisited": "450e31b4d25bdd0eaf6f1b9e88246f7f91cb203daeda4bd1488911c57f5b72b2", - "apps/web/src/uiStateStore.ts::persistState": "020e20cb15a76043d782da44e751fd30a13e638db3587fa4a848f74c16b2891a", - "apps/web/src/uiStateStore.ts::reorderProjects": "dcd18ea15e1c98fe1727b9d21de0e3fcdd477afc0424eefd9c83e16c3e1c07d0", - "apps/web/src/uiStateStore.ts::setDefaultAdvertisedEndpointKey": "414e8292c5b5663adcc068e3f7d150a7fccaa1787c974b34f7008f12fb3e7623", - "apps/web/src/uiStateStore.ts::setProjectExpanded": "780400a1c501bb5b15e9aaaa577cbfdc940b36f776cbe6f7005dd1e58ac702c7", - "apps/web/src/uiStateStore.ts::setThreadChangedFilesExpanded": "800041fc69f9364f9446a06601b02f365183759c5f9fee1252dfdd9e1e32cd5a", - "apps/web/src/uiStateStore.ts::syncProjects": "aa73f0766a6334b8d13f0c981ee4c5389f24a764dadb9aeea27451d4f1eb0bd3", - "apps/web/src/uiStateStore.ts::syncThreads": "c3f8a19a1fb55ab08cdc65f7d8ac93a5e73b4814f8f8b2a6d34282a6632115fc", - "apps/web/src/uiStateStore.ts::toggleProject": "ebb7914282045d21c9cd6a24c69e50c55965a0b66b958dae3774b309f83abe5b", - "apps/web/src/versionSkew.ts::appendVersionMismatchHint": "14d2bd5fc17c92c00cb5ce97a1ea592969c8824ac0c92140613816236d8e648f", - "apps/web/src/versionSkew.ts::buildVersionMismatchDismissalKey": "b27d596732e017349b4e3189bf36d77ec3bf2eca657a0cf479320603fe96904b", - "apps/web/src/versionSkew.ts::dismissVersionMismatch": "c689b465eeb19f06750b250305e20770b4c2f327857360b858f6315768ac32b1", - "apps/web/src/versionSkew.ts::isVersionMismatchDismissed": "4ddae4b964182a310724849f0ea2bd0041679b47bfd4ed51029ac322fa85fdee", - "apps/web/src/versionSkew.ts::resolveServerConfigVersionMismatch": "1b697f6fe8869d12ff55b2fa94b889c894c240caf895afbeb9341f0d042741ba", - "apps/web/src/versionSkew.ts::resolveVersionMismatch": "48368d172880f4ab661fb370a4a12640eb2f2327ea7f518ba4e03986d9aa145a", - "apps/web/src/vscode-icons.ts::basenameOfPath": "491bff0bd7179a424d6525a30014d5b1efc4a34b73379390f47c904a1f99d691", - "apps/web/src/vscode-icons.ts::getVscodeIconUrlForEntry": "2a16808330e5c94f2b3eb8f86ad02c25d6599f7b9f593af424e44e27b9530ee5", - "apps/web/src/vscode-icons.ts::inferEntryKindFromPath": "1cee6cf678841a216595ec767f4bd536912c2d99fd9331f3d267c69783420ad3", - "apps/web/src/worktreeCleanup.ts::formatWorktreePathForDisplay": "7b3f7b66a362f895138179fb9d33f5bceed216ba2073770319a4d73f456a78e2", - "apps/web/src/worktreeCleanup.ts::getOrphanedWorktreePathForThread": "ee979ce9e8456f7a0eece22580c7acc3645c7fcdacdfd262fd23b723b03d5ea0", - "apps/web/test/authHttpHandlers.ts::createAuthenticatedSessionHandlers": "2382a16472d6560b26a77a53631babfc31db8a14b21a5579e112c25a0ebaf52d", - "apps/web/test/wsRpcHarness.ts::BrowserWsRpcHarness.connect": "0587abdfde1e4f8fdccff89178d769af3b8d11bcf71943535fb0069c2e684a20", - "apps/web/test/wsRpcHarness.ts::BrowserWsRpcHarness.disconnect": "42b993e4af82cf8995f68ac6850e7c107a646624c2a1eea2863683dde881a97e", - "apps/web/test/wsRpcHarness.ts::BrowserWsRpcHarness.emitStreamValue": "2799155393cf7e633cf221d2bc2f732b7639014f47e8229653a7cb779048b71d", - "apps/web/test/wsRpcHarness.ts::BrowserWsRpcHarness.handleStream": "0714aa1b75f0235bb57547e93e8458d5376d446e4ae8b11dd4d2a01fdab764f8", - "apps/web/test/wsRpcHarness.ts::BrowserWsRpcHarness.handleUnary": "daf2599e40060aeb1c7c725619d97cd3dc3dfcfe5bcec9a5d566dbd183fb5269", - "apps/web/test/wsRpcHarness.ts::BrowserWsRpcHarness.initializeStreamPubSubs": "49f39d377f388347a52ca0418e01fba9d83c1024edc76c7784503bc0f9c67b05", - "apps/web/test/wsRpcHarness.ts::BrowserWsRpcHarness.makeLayer": "6e342bd8fc406ec68971f570ed4d73577cc5e68e86cfc27d20c07e0c7f673d35", - "apps/web/test/wsRpcHarness.ts::BrowserWsRpcHarness.makeServerOptions": "92ef33b838d7fd9a6247485e8a9fd393413362278767b0f70191704be0e117bf", - "apps/web/test/wsRpcHarness.ts::BrowserWsRpcHarness.onMessage": "2879651f20d53410a05b945e20ecd20f43db8f491fe1ccd1682df7b48bdce045", - "apps/web/test/wsRpcHarness.ts::BrowserWsRpcHarness.reset": "51441c87d73af150864e0c8363c05fe9660107699755ed269853dff9f9b63b0d", - "oxlint-plugin-cafecode/test/utils.ts::OxlintFixtureExpectedFailure.message": "9288ea982cbd3675e1010a1e92adb0c83b335f92b8fe9ae3d2d3f30458576ece", - "oxlint-plugin-cafecode/test/utils.ts::createOxlintRuleHarness": "8df173b9e40706cd207be12c7aad2efb237a07a4153e217964adf76170de8e8c", - "oxlint-plugin-cafecode/test/utils.ts::runAndExpectFailure": "1a15c58f340607c396470a73a027a3efbf2ba41109418091b8c6cb078fa91da8", - "oxlint-plugin-cafecode/utils.ts::getPropertyName": "c576dfcd3990268c697a4007ef2c28fd92181980d5be4608fc2c17ff2a44d7f3", - "oxlint-plugin-cafecode/utils.ts::isIdentifier": "86b208ebc803f3a84a86bf2643806656f9b95e7cab153beaca8ab4cdeba2a436", - "oxlint-plugin-cafecode/utils.ts::unwrapExpression": "b1b1314e02ec974deb253dc418ba53af1e8c6249cd7c2a6c630bfeed78d3b2b8", - "packages/client-runtime/src/knownEnvironment.ts::attachEnvironmentDescriptor": "42691f62df2e65a0d67d41d8692a166fd5378a75923d10d90306f666e840775a", - "packages/client-runtime/src/knownEnvironment.ts::createKnownEnvironment": "897588e1feae67314b294f9660878e96bb519256b002cb27c885271489b2bcb3", - "packages/client-runtime/src/knownEnvironment.ts::getKnownEnvironmentHttpBaseUrl": "954c80db9c62c107a95a81b739f6b9dea220eeeeb90d200b5d537ed1ab9b9088", - "packages/client-runtime/src/knownEnvironment.ts::getKnownEnvironmentWsBaseUrl": "7e498ca0e48b75c8863b6ae072e2245cde193847957fdd407238da99fb1205fb", - "packages/client-runtime/src/scoped.ts::parseScopedProjectKey": "916abbcbff97c793494535975ae7b33d07c05221b5a55092f7ec20bb348692c4", - "packages/client-runtime/src/scoped.ts::parseScopedThreadKey": "7547554bafe1c4d90ef6e02ce74fe5115b36de6bcc11158646e383ccf7ac0c61", - "packages/client-runtime/src/scoped.ts::scopeProjectRef": "dd99109a5db742e0e06c511edb55f5760dff5f4401f49df3a0cca88ae4e8b034", - "packages/client-runtime/src/scoped.ts::scopeThreadRef": "8bb85a5379a80dc1ff52b61a7e0f1268584a1a7d0c9726d595552bb6d800aee1", - "packages/client-runtime/src/scoped.ts::scopedProjectKey": "a8f65c5fc98a19b5a368142f97c9bc11980152827f2a0b777ceedf320dc33382", - "packages/client-runtime/src/scoped.ts::scopedRefKey": "c29a62e808f1b798ece131332905fa509066032167ff03bc8acaefeb168d0b13", - "packages/client-runtime/src/scoped.ts::scopedThreadKey": "2328e5061a8add585940c15a88442c1741f79282a57fc53006653265322d080a", - "packages/client-runtime/src/sourceControlDiscoveryState.ts::createSourceControlDiscoveryManager": "25f9a7f778d8f00db29ca9ea3a189321336bf16b84b4bdc5b3282541d3b0eda6", - "packages/client-runtime/src/sourceControlDiscoveryState.ts::getSourceControlDiscoveryTargetKey": "2019ddff2386c5666570a1d672f09ef4845dafc439b1449b30a3f522d9a74028", - "packages/contracts/src/git.ts::GitCommandError.message": "974e8900dc646fc3f90bf2b02c8fabaa7ec2075584874c87d5cb7e72175c15e7", - "packages/contracts/src/git.ts::GitManagerError.message": "36c1f2273d5b82fa04684408574bea2583ec72d530b04fa1f0d17dda4417b796", - "packages/contracts/src/git.ts::TextGenerationError.message": "81c80e2151daafcb25c794e2386c2661dcd9393dfd01f370dab290070eabf25b", - "packages/contracts/src/keybindings.ts::KeybindingsConfigError.message": "a1fceaf5391c1345fcc0ce13cef3526a2138543cccfc0981bc569bd99e71c996", - "packages/contracts/src/providerInstance.ts::defaultInstanceIdForDriver": "a5390a4fcc00dfe2136ac04bc35e862c8367fe16882705dd2ca37b64ae4538fb", - "packages/contracts/src/providerInstance.ts::isProviderDriverKind": "843344dc0ed4d6f0ad91ba89fb3c6654ca432699638aa2de8341aa25217b4eea", - "packages/contracts/src/providerRuntime.ts::isToolLifecycleItemType": "778860367a994a87d509e4e81953019ba779b19290980e412182c18cabd2577b", - "packages/contracts/src/server.ts::ServerProviderUpdateError.message": "19119c2e910073a500a0952e57d0345e19eeec0be2dc93403ba7e870897c8ea2", - "packages/contracts/src/server.ts::isProviderAvailable": "b56676df92304a7db83a927d50300f29d07e20af9df76558bce54beaf262aaba", - "packages/contracts/src/settings.ts::ServerSettingsError.message": "911409f53abf9ce1b590003bafed290c063edcc7a831e158bdbe77beab1c1209", - "packages/contracts/src/settings.ts::makeProviderSettingsSchema": "0402b1114f9647e661543efe90c2643f7f4fc2cc58b073b19bcd75fb405ea691", - "packages/contracts/src/sourceControl.ts::SourceControlProviderError.message": "6346fb6d9f1f3ed31224b573b9865a4a54b2816225a2385feb24e5f4954ae055", - "packages/contracts/src/sourceControl.ts::SourceControlRepositoryError.message": "59ce7a3ed81d5559cc641f9166d95a456c0b16fdc6a586adcecf5a179a66aeb8", - "packages/contracts/src/terminal.ts::TerminalCwdError.message": "205061f288b57adbff596f3f63fe45b7ca3d82327ed955f24d9bab79071cf56b", - "packages/contracts/src/terminal.ts::TerminalHistoryError.message": "5453cd085883314c9bc8b2d48a64310ad2f6812b8a736554a1a45011cc3d9898", - "packages/contracts/src/terminal.ts::TerminalNotRunningError.message": "09978750f5df886ad37c69e4b2efd57282af2ad2a5b2989d9bb957b9e01acf50", - "packages/contracts/src/terminal.ts::TerminalSessionLookupError.message": "50c5affdee86e28025587d4fef89286fecc50071d8364a3e407a19a95fe355e9", - "packages/contracts/src/vcs.ts::VcsOutputDecodeError.fromProcessOutputLimitError": "23a00c5cce9847f933875160b89c9be55d756bf3e447f8ba2fe51e226c1d92d2", - "packages/contracts/src/vcs.ts::VcsOutputDecodeError.fromProcessReadError": "19abff59a9a4d32da944416613e1e2678c6ef0f5104db580580fd5fc725f1a1e", - "packages/contracts/src/vcs.ts::VcsOutputDecodeError.fromProcessStdinError": "a86bc48272eab1dca9a0fcb67ff9d1e393f0b2238067e9be190217d3ee03c9f3", - "packages/contracts/src/vcs.ts::VcsOutputDecodeError.message": "c8bb3d6ab4628c2c3831bd175f952232a6be7f1d159c4f200ce67a3b6bafe608", - "packages/contracts/src/vcs.ts::VcsOutputDecodeError.missingExitCode": "aab323d2dcca93780fa4f03804ad20d6b7f17597582c2692d5685b4753bac7d5", - "packages/contracts/src/vcs.ts::VcsProcessExitError.message": "a1ffb93a1dffe052f92e594163225f8209bb7899656145348ef87f0d3989b0d5", - "packages/contracts/src/vcs.ts::VcsProcessSpawnError.fromProcessSpawnError": "0191268dfeb9facfb6a9044e1624f93990f1d2fe6dc8483f8d2763082a5e678c", - "packages/contracts/src/vcs.ts::VcsProcessSpawnError.message": "8030f484db3d5fb3e5a571a7e72696d28955e322337d39faa466a52db1814050", - "packages/contracts/src/vcs.ts::VcsProcessTimeoutError.fromProcessTimeoutError": "6a3a2e6474bd29cb33be13bc0551abea1c8917e797330d8f85ad05f190684a3d", - "packages/contracts/src/vcs.ts::VcsProcessTimeoutError.message": "e567941e887c60c0dcfa93aca627be8ffa67aa9058a690725397f96a6ffeef21", - "packages/contracts/src/vcs.ts::VcsRepositoryDetectionError.message": "063234a21526f1107d3755404b50ed98ec0a281e89d9286a1bc490f666db2a17", - "packages/contracts/src/vcs.ts::VcsUnsupportedOperationError.message": "5852b4379145b1ed27103cfa666999552762360610462be4ffa7b7b3236b822d", - "packages/effect-acp/src/_internal/shared.ts::callRpc": "944622d603ef4226f5a1fee0925ee83125c732dee9d5dfc0ff7f200b753f7cbe", - "packages/effect-acp/src/_internal/shared.ts::decodeExtNotificationRegistration": "6fb0e4da055df62f3bc6e79db685af438a43238cecf94a458e6ca98147b80a8a", - "packages/effect-acp/src/_internal/shared.ts::decodeExtRequestRegistration": "481936761b55b507b964656288f3541618537f92e09a4f810f15c8c240103241", - "packages/effect-acp/src/_internal/shared.ts::encodeJsonl": "011d4f0507d3f316658265ab678184dc1e1f1d1f69ec356bf974bbf8d8408ffe", - "packages/effect-acp/src/_internal/shared.ts::jsonRpcNotification": "7bbea182b52953ebb2b69b8212fe42bef45d516b71ead6cd9f09650fb57d9317", - "packages/effect-acp/src/_internal/shared.ts::jsonRpcRequest": "1203ce4842a1cd8c8e9727e180e75f9b5f9903ca2d543494059843600a48d8f2", - "packages/effect-acp/src/_internal/shared.ts::jsonRpcResponse": "cb48bd9978cf50a4530cd59e943d2bc51afbc09a3b200c20307c2683d5d1d182", - "packages/effect-acp/src/_internal/stdio.ts::makeChildStdio": "db72b66c5c123cc3706d0437a5b71bdfd3442fc227c9317e17b043f181b7ae5c", - "packages/effect-acp/src/_internal/stdio.ts::makeTerminationError": "e294b1a82409b5c16e3fbbfc0f04adef257d8ef6cce8affc1ba59bdd4a486539", - "packages/effect-acp/src/agent.ts::layer": "777d5388d2f695f8eeaeb76816c015c407c972ca986a248da2d7bc4a79502137", - "packages/effect-acp/src/agent.ts::layerStdio": "455b932df524763e5cd7c089255a0e222482bab37d69bfa115257febadc85d9f", - "packages/effect-acp/src/client.ts::dispatchExtRequest": "5af9a42ee48f4363c428fc45b099d16cfea4d47e113361bc4fae4ede278e70d4", - "packages/effect-acp/src/client.ts::dispatchNotification": "1f93526611e14d9bc3706c197c1fe0cacdde75c2ebc797eef473dbb8e69b3b13", - "packages/effect-acp/src/client.ts::flushBufferedNotifications": "389473cd5f36d8660bb9c8b6d6e5188d658c42f4aedf93f01a34401f8fddad81", - "packages/effect-acp/src/client.ts::layerChildProcess": "7039061179582b54135c11f83f6eae075284162fc600417ee5ca8f950cb20231", - "packages/effect-acp/src/client.ts::runNotificationHandlers": "8a2b68931993877e3aa6572928d93087b1e783b03f3662542c753721e68ca57b", - "packages/effect-acp/src/errors.ts::AcpProcessExitedError.message": "eb97a9e72b12d7ae8ac540ffe75db12f05b7c3bb6427fcdcb27597745248bc5b", - "packages/effect-acp/src/errors.ts::AcpProtocolParseError.message": "55614df90b981a79f8156f4eb52e779737de16514f93364c1d613257c6360e75", - "packages/effect-acp/src/errors.ts::AcpRequestError.authRequired": "a0f453113c37d731e9137f02fbedced5136c1cc97ea500362c579c05170e2ca1", - "packages/effect-acp/src/errors.ts::AcpRequestError.fromProtocolError": "a64429918a01dd822c6d4358e20e9ce4700cb225bd3cf90e965d5fbbe5bfa19a", - "packages/effect-acp/src/errors.ts::AcpRequestError.internalError": "c064557295ac1169f2441058ee135c48cfeff2623b8fbe2261214b5d53095ddc", - "packages/effect-acp/src/errors.ts::AcpRequestError.invalidParams": "1dc43291c294327c0742daed168ae33389a013717c1699cbf630f5386e4986fe", - "packages/effect-acp/src/errors.ts::AcpRequestError.invalidRequest": "c028019245532752ca95395322edcd010a87d209869362cf64c53dd4a4ed4309", - "packages/effect-acp/src/errors.ts::AcpRequestError.message": "4aad7b017ec9dfdfd1c1e9537cae39807481fce828bc0005a45f897983dd1eab", - "packages/effect-acp/src/errors.ts::AcpRequestError.methodNotFound": "d58478809963fc752c62d5b3033f4f36643e52fd7978a696e3f35699b9075300", - "packages/effect-acp/src/errors.ts::AcpRequestError.parseError": "3ddfc76c3f5cdf194a7fd269cc6bdb2cba6bf976367a250d9000b8e84faf7798", - "packages/effect-acp/src/errors.ts::AcpRequestError.resourceNotFound": "355ec8dd0502b8a5954f04bc467069b9ced5b0f930eccf851bb52c53b59a3af6", - "packages/effect-acp/src/errors.ts::AcpRequestError.toProtocolError": "db46fcdd7489e2978e01d91935825f5cdd821b5d4f846ac6e47108cbb5f63a94", - "packages/effect-acp/src/errors.ts::AcpSpawnError.message": "19f17f912edd1e3863d62afe72a75980943d873eaaff7cf80e02b7734c94025b", - "packages/effect-acp/src/protocol.ts::completeExtPendingFailure": "473c796a0f1dc6c24c13701cc404b625667655aff6a37b3b10dcd750b83c5688", - "packages/effect-acp/src/protocol.ts::completeExtPendingSuccess": "21ee5c65554cceb739a31922e3d1583087e7c00d79388d720362e937c3d1f726", - "packages/effect-acp/src/protocol.ts::dispatchNotification": "3d5d7b80f4e4d5001b4a7378bca482c4aa447c7e24f7e769f7af5258cb1e3175", - "packages/effect-acp/src/protocol.ts::emitClientProtocolError": "4f3a74cba8e71c9ca3660129294eea5781941a458623622e91c5e62beaf81f16", - "packages/effect-acp/src/protocol.ts::failAllExtPending": "5d5d6fd6aa75938a46f4f7f022dfa6378d1bcec02a6b160f30760da711d592a8", - "packages/effect-acp/src/protocol.ts::handleExitEncoded": "9e796be11cbbfc9ab9ed43e4c05b9ba97ceff02130fc95ae4cc6d2f636b16699", - "packages/effect-acp/src/protocol.ts::handleExtRequest": "1d8f504012228025e4a536590efe24d6358c160de253c89d4eba102c459746e5", - "packages/effect-acp/src/protocol.ts::handleRequestEncoded": "0bbd6282b2510cf456c6ff96d10e3ad7694f7b2c1c8e07d1f3815b4af17d797d", - "packages/effect-acp/src/protocol.ts::handleTermination": "235f471116b08ff69e0b2e4f9de77e5c3e1e4eea639618370959c419a12956ce", - "packages/effect-acp/src/protocol.ts::logProtocol": "9bb27e1f42926b14a157ea57785a253cf19c5434e9e54904d1012778e3df2658", - "packages/effect-acp/src/protocol.ts::removeExtPending": "0a44f89193cfd2790302e175c16d6939257c1fe69e747399f6f2642e10c566a4", - "packages/effect-acp/src/protocol.ts::resolveExtPending": "dd9941e077e154cca7ba3ced72a8fc61f2ade5fed509522a800cdea4e02242e1", - "packages/effect-acp/src/protocol.ts::respondWithError": "3caf5e525cfd74150b55b7cc1dd71bc154a4d5cf2250209417e983a9e41cd23b", - "packages/effect-acp/src/protocol.ts::respondWithSuccess": "8a3593337f20b024ba230a96b8017805a0a2a31cd95be51850c370753c600138", - "packages/effect-acp/src/protocol.ts::routeDecodedMessage": "70f48063c10ec3904446d9471ddfdbb741e6be0754c687a5b17f4e3d7e988ff2", - "packages/effect-acp/src/terminal.ts::makeTerminal": "872a3b12884dc43ef6a0d29bf35ed06233d4cfb3f20125eeedf152b7fad44900", - "packages/effect-codex-app-server/scripts/generate.ts::GeneratorError.message": "c1f4610515b8c962deb47eab8bdbb48a941d60a57fdcf85bd57bf697ae04c9c5", - "packages/effect-codex-app-server/src/_internal/shared.ts::decodeNotificationPayload": "de0e08547ca2f1c7190e433eade5ed96b3293289fb2fa6aa51ac3d4fcd3a0dc6", - "packages/effect-codex-app-server/src/_internal/shared.ts::decodeOptionalPayload": "758eb4206a779ec06ae8bb4cbc3fd644fb3f2bb9355dcee15f997b9464a4ecb3", - "packages/effect-codex-app-server/src/_internal/shared.ts::encodeOptionalPayload": "3af24ef519220b5aa49ac1257fed872b4a9f1ea6a3fc260766d96a5a8804bdbd", - "packages/effect-codex-app-server/src/_internal/stdio.ts::makeChildStdio": "db72b66c5c123cc3706d0437a5b71bdfd3442fc227c9317e17b043f181b7ae5c", - "packages/effect-codex-app-server/src/_internal/stdio.ts::makeTerminationError": "12f12892d8b516602d0151a3f6dd7dfe3288ac343891602a85add86ebefbc3c6", - "packages/effect-codex-app-server/src/client.ts::dispatchNotification": "18b91133546f798e961729159dde4cc866707aecd6f94ac34ff747b926d0d739", - "packages/effect-codex-app-server/src/client.ts::dispatchRequest": "2a83419422889405b94cf301ddc53ee9eca1756c0ad866d8b641ed6494191899", - "packages/effect-codex-app-server/src/client.ts::getClientNotificationParamSchema": "f0f02f3286b062281ada04b041ab3625ecf4b14337ad4fb68cc87836e88a0320", - "packages/effect-codex-app-server/src/client.ts::getClientRequestParamSchema": "43d2268a7914219bd4ec20d455f26521b835c8db189090c3b0bebafae7e7bee3", - "packages/effect-codex-app-server/src/client.ts::getClientRequestResponseSchema": "6490d1cb11f3f1972d9e92930d2291cc15a2ddb0b75c763ecf6927d78da3907b", - "packages/effect-codex-app-server/src/client.ts::getServerRequestParamSchema": "82cf0df5de43bb4ba4536c24c91a51df3c475151283ccc486c28831a30b1288c", - "packages/effect-codex-app-server/src/client.ts::getServerRequestResponseSchema": "5cb02cb33fdbbf8ecc2f58a46532234f6b50dec75d15ea47a43ffb020c7b9a63", - "packages/effect-codex-app-server/src/client.ts::layerChildProcess": "87aa02e6cbcc380617a62bbce5eaf4e426a9094948592d2325015e34c235e36c", - "packages/effect-codex-app-server/src/client.ts::layerCommand": "d34c6c49a5547293183ee61021b4bbd6ae81586e92c08bfd9b1a9c93e9fde12c", - "packages/effect-codex-app-server/src/client.ts::notify": "485ba2269fdfe3389aaec2090d49ca8688b30940e04e18e19a928cae80cc0bc1", - "packages/effect-codex-app-server/src/client.ts::request": "2160ae1199194e7ec5f3a71603bea260363d7374d202767988faac8d8226039c", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerProcessExitedError.message": "f5281de7e910ee25dd5701aa672d2d8d73793f993e8fb60f70b391f2f1d12eb8", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerProtocolParseError.message": "002eb612ebb70a7da828587230af8c0befa391694e3e7c0ed9b0d417d2380a6e", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerRequestError.fromProtocolError": "3a05e7ed2ac592e179276f5020c0fd033788f285265039de49c3e7a0755d106e", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerRequestError.internalError": "31a9e5873016c6eefcecf6558ea56339acd0d7c1c396512b630affa38dae916e", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerRequestError.invalidParams": "235121fde190dd34b418d14f7723028409dbd3d665046a0b0545957cdba48e7f", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerRequestError.invalidRequest": "ea4a5024c6eaf8f47224d8c91491020e9c3a9fa2057619c74f55bec097666860", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerRequestError.message": "5f6cb12034bc1da1bab72d4dd760e2882717807d4b7d26db67341d4919dc679a", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerRequestError.methodNotFound": "3b7536a4b32f2a8b66e8885726cd91c26724bc0e95354aefcefd21f67bbdf0c4", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerRequestError.overloaded": "d27cbf89cc6a31b99f4aeb4ea156690db154f8883fbe093e021c45f0cb0831ed", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerRequestError.parseError": "f35d78bc6186faa4ca5da4fdcda4973d7512e26b376f575a623495bc33f75c63", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerRequestError.toProtocolError": "7e9970c6c1e5a74ccbf820820189b2d2241393e5eeac912fda7b10cc78b3d665", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerSpawnError.message": "268773e38f733d1f628d5112d866fc96b618bcad678e4410ecbe9e15d04b1141", - "packages/effect-codex-app-server/src/errors.ts::CodexAppServerTransportError.message": "6597de94ad41b356cf21e95c691759c87f8543455d6db62acd6ea1cfbe14d5b8", - "packages/effect-codex-app-server/src/errors.ts::normalizeToRequestError": "f2e8575078ee006b176d370ac5c5ed30a1ec0073f256b15553895de6d08fe786", - "packages/effect-codex-app-server/src/protocol.ts::failAllPending": "17ed7a7b4c5d5b127a54b95ef5825939b51ca06a56773a96a02ab8f1c17a5a4b", - "packages/effect-codex-app-server/src/protocol.ts::handleLine": "b0de1617845940e6aad267777a16d9b1c84c1f0c9d0d2a49f13ee336907ac9dd", - "packages/effect-codex-app-server/src/protocol.ts::handleNotification": "d57cdcee89c589ee33b1d6b78be175e6cfd7769b4f141f0d40721c18dad1ac1e", - "packages/effect-codex-app-server/src/protocol.ts::handleRequest": "d66597a0c7d4d1f43b0edf948263c1a3cca4a2a8909e9881f8a096892b26d18a", - "packages/effect-codex-app-server/src/protocol.ts::handleResponse": "0e16a8087f3386bfa3e611ebdbcb6e4ea98cda6fbc77b548be5219d1f301bfaa", - "packages/effect-codex-app-server/src/protocol.ts::handleTermination": "87e292ae4f54d8a398a8548a941f98a6da81b2db0613d9ea46234b747db9404a", - "packages/effect-codex-app-server/src/protocol.ts::logProtocol": "19ea22e265d08bd4832dd93f73edd940d90f3193513f166249a3c2cdc2fc9603", - "packages/effect-codex-app-server/src/protocol.ts::notify": "271cf8e2b7b798c65585d701b7f561396f6162400424b5c0d0d41b6b4c4f321a", - "packages/effect-codex-app-server/src/protocol.ts::offerOutgoing": "8e7139f5c396d00bbf66c2ef91b9151083874333767afa0a7d0f19094d241345", - "packages/effect-codex-app-server/src/protocol.ts::removePending": "f914188e4e7ca279725baff7a98fccfc17041992109a0ff9876e82b90374e7b9", - "packages/effect-codex-app-server/src/protocol.ts::request": "ecfdfcec5fb8282619bb8422c4c83e1f172ab2904c3eef45bdadc6f910876f20", - "packages/effect-codex-app-server/src/protocol.ts::resolvePending": "c64430e70745ad78d7569e31d48272369e2e0efffda8d48f45fb0ff90646b937", - "packages/effect-codex-app-server/src/protocol.ts::respond": "493fff687c670ac0372731dd3138758b575728cae64762c3bb4740e7f695a216", - "packages/effect-codex-app-server/src/protocol.ts::respondError": "f11e3134041a303b2d2fc4f53aa6c08ac5f30feec9cace305b949db4ea641141", - "packages/effect-codex-app-server/src/protocol.ts::routeMessage": "194600dbcc3ed0278a58da766af9195b1a5807b5dc930a820b22067b46438b2e", - "packages/shared/src/DrainableWorker.ts::enqueue": "a380029ee91309064ba7afc4b3a2b2907be339225546505eee2fff59e961c48d", - "packages/shared/src/DrainableWorker.ts::makeDrainableWorker": "0f5093a9118d22d6966bbc6da23ee075e4411149d7208bbc6c7617e7b0093ec0", - "packages/shared/src/KeyedCoalescingWorker.ts::cleanupFailedKey": "7c482de60138eaf8032efe7ea65f681a47cb95d4610b21d9cd1d5b7933fae571", - "packages/shared/src/KeyedCoalescingWorker.ts::drainKey": "1687677a7ef61f156f568212e2f4f7d25b0112daa978fe405f77371f394dd853", - "packages/shared/src/KeyedCoalescingWorker.ts::enqueue": "67f4fd2544b9aff0b3c4895596da9106cbfb72031bec80cce5c316820811afc7", - "packages/shared/src/KeyedCoalescingWorker.ts::makeKeyedCoalescingWorker": "10fbfe2a82e6fcd375485f35dd75fdd7af39afe2499a34bce9c90972a7d1186d", - "packages/shared/src/KeyedCoalescingWorker.ts::processKey": "c6cee046365c39fb2b743e53345214cad015aaf78918434f6b14a14b116a8f84", - "packages/shared/src/Net.ts::canListenOnHost": "658df6716555f12f434a890350a1e14e4ea8c9b3e1b291c7e2c8fc6fe229df47", - "packages/shared/src/Net.ts::make": "272ed329e85bb7de3c62271464f342efe3b0a57c391d262a38225cda06b56cd9", - "packages/shared/src/Net.ts::reserveLoopbackPort": "8e298522fa908ccbdbb0f4958572413b76087c16015da757053b8cd5a35517eb", - "packages/shared/src/Net.ts::settle": "7469ca73252a6276cff7c7eb930d25f1e2b085156c1eb10ea317b8e2353175b9", - "packages/shared/src/String.ts::truncate": "46190e28969d9408fcd999b8662a0e552a76354a19e152731ed1318cee5356cd", - "packages/shared/src/Struct.ts::deepMerge": "a560ea6b39c7e0312498f2b32df351fcbbff644bbbe752ddcc57a661660f5aee", - "packages/shared/src/advertisedEndpoint.ts::classifyHostedHttpsCompatibility": "ba2caf74097839b17e2ed071e4b481bd8727795be2fbe88132e4b41e7148a0e5", - "packages/shared/src/advertisedEndpoint.ts::createAdvertisedEndpoint": "565c22022eab4fb53a032a51d790b4d2a05169c20fdeb6244e81c10badba5b08", - "packages/shared/src/advertisedEndpoint.ts::deriveWsBaseUrl": "17e948b333b3dd882d748a3a4fa7b7337e814bfed0a96e2f7a41a0df6309bb3f", - "packages/shared/src/advertisedEndpoint.ts::normalizeHttpBaseUrl": "db17e1e0d09aad61956180dd2417e4088e762aa4f5d97ad0013ede1d67a0b815", - "packages/shared/src/cliArgs.ts::parseCliArgs": "ab31f6f0f294cd686731e7e8ff505e022eee74c11fb674dad1670f742123d4c3", - "packages/shared/src/compatEnv.ts::cafeCodeEnvName": "5918e7f81098be8707075ad1e51b6e413c8809743ede142465169e11f3c45a5f", - "packages/shared/src/compatEnv.ts::deleteCafeCodeEnvPair": "28775958961ad2f993f07fadaba0e0bb9111d220f5288675a8bab431d4795b2b", - "packages/shared/src/compatEnv.ts::legacyCompatibleConfigWithDefault": "d3b3a3d02dfa33dc947832a8609819ffd219d190790c500636d71e974db86bab", - "packages/shared/src/compatEnv.ts::legacyCompatibleOptionalConfig": "a4ffd903a9f779b893320d3733a11ebcc2cc5e62b81887cbe03826dd9200b716", - "packages/shared/src/compatEnv.ts::legacyCompatibleOptionalValueConfig": "3ac58632f2ef4961f389adfe7c138c0801e25e3e706eaebd812e4698b5b8ff9b", - "packages/shared/src/compatEnv.ts::readCafeCodeEnv": "8ba3978f4aad7d6dac0925da89c8873b00c8da05cc177ceefe581f9a2f0f2b41", - "packages/shared/src/compatEnv.ts::writeCafeCodeEnvPair": "7da633a792cbc352bb252a4bb36a84c45405754563d159de8604f82a49d81968", - "packages/shared/src/git.ts::applyGitStatusStreamEvent": "e4fe34949f6aa2e36b385b207e6364c15780575cd3f091319f90ccdf346620e7", - "packages/shared/src/git.ts::buildTemporaryWorktreeBranchName": "61e6a41a9ecd2f567344ee7c7189db972e4deaface38c9fee2574cd5d7857e85", - "packages/shared/src/git.ts::dedupeRemoteBranchesWithLocalMatches": "7b0076864a289967ff8ac4c06cdae716fa5d124ce94ea886115b145b9564c596", - "packages/shared/src/git.ts::deriveLocalBranchNameFromRemoteRef": "9045a879145d62e67348b3ce9db01da0a22b710093142a7dbfe9892b6695cf87", - "packages/shared/src/git.ts::detectSourceControlProviderFromGitRemoteUrl": "96c25bd46d6646a10f4dbaa494b5d935fe1127f3a830cd8a1ca6f0466f172c50", - "packages/shared/src/git.ts::isTemporaryWorktreeBranch": "51d5fb512d0d84b3b41cb4638eba6d625094bd65956cb843e137c5fb54d9246d", - "packages/shared/src/git.ts::mergeGitStatusParts": "95032035b49c3dee04da248409f4d7c9626db5b387eda8167ef8be7048e016d6", - "packages/shared/src/git.ts::normalizeGitRemoteUrl": "6d08d9705d740c9c7b51ba0cdc3e992a2e74d75e77e49359d9939238b38e42be", - "packages/shared/src/git.ts::parseGitHubRepositoryNameWithOwnerFromRemoteUrl": "e04621fd1d1af79b2dd1b69d6da7faa42ffc2ae3868e5bd3f30b2852f784c3bf", - "packages/shared/src/git.ts::resolveAutoFeatureBranchName": "a2227c086e5619990ba2a7d79596876d9cf19cc1b86c84a698ab7a7c7e612334", - "packages/shared/src/git.ts::sanitizeBranchFragment": "15d3107b5684a53d44f463aeb795608e6c29991dc638a7e394e94f94eeabf49a", - "packages/shared/src/git.ts::sanitizeFeatureBranchName": "f7b187c729a365624d06a97f454964e8478973d0234df365b4962c19915a12eb", - "packages/shared/src/keybindings.ts::compileResolvedKeybindingRule": "3feacef00abe1956b4767ada7d1b0832158dce3adc39e122479302ad1dc8e91b", - "packages/shared/src/keybindings.ts::compileResolvedKeybindingsConfig": "49c92d9dc14c455db33815873c9730eee14b80ab9012d8738fee441b915d0a25", - "packages/shared/src/keybindings.ts::parseKeybindingShortcut": "dd300fbe6cfc80a73687a975e0818a4352bcd5d05c975332e23bb4997e748f87", - "packages/shared/src/keybindings.ts::parseKeybindingWhenExpression": "f68c0209d7b13c8aa80b7fb75cd91e6a2cefdf945d440aece5607d87590e3d3a", - "packages/shared/src/logging.ts::RotatingFileSink.constructor": "d7fce19e58435d4e6d407d5303e7ef7deee77266f6eb2033724062c4d5ff712c", - "packages/shared/src/logging.ts::RotatingFileSink.pruneOverflowBackups": "517d0a2cabdf07f0e79b83c0b763080979de75670198a0b330096009003d7554", - "packages/shared/src/logging.ts::RotatingFileSink.readCurrentSize": "d3234d9bb23ee06561f76908385f020184c40f73865e17c95e276ee0d41e3c58", - "packages/shared/src/logging.ts::RotatingFileSink.rotate": "c4b4688929f407e5265b07cf80ab884287c4bc2fd9250948af20653ac0ffb0b9", - "packages/shared/src/logging.ts::RotatingFileSink.withSuffix": "e80085818f4314afa437b651307672c6eeba114468328452a361cd7f2e840b73", - "packages/shared/src/logging.ts::RotatingFileSink.write": "57e313397b511d3301a65c61422ae7a75836a0ec53c38c799722fbe33eb843b5", - "packages/shared/src/model.ts::applyClaudePromptEffortPrefix": "49a75ae44f11ee735c0bbed870319a989c3ecf6bb14f1edf7485d7a970ea864c", - "packages/shared/src/model.ts::buildProviderOptionSelectionsFromDescriptors": "45b6d9d2cad8b60e386bac6b1174c6556d5388ff55675c6da0a7c0e5b8b4e34f", - "packages/shared/src/model.ts::createModelCapabilities": "3af804f48678dfe8160f7d41486a23f1453a12499ed0832e7fb1ac7d70253c5f", - "packages/shared/src/model.ts::createModelSelection": "0fd74dd36dfde2e2c4d44578513f5f3eebf639e94d40d9fdaadd23ee9a276de3", - "packages/shared/src/model.ts::getModelSelectionBooleanOptionValue": "12d70f163f9c49f1a886723fb167155b84d7d93f940c7e9f6b9c0a7adc9f961f", - "packages/shared/src/model.ts::getModelSelectionOptionDescriptors": "5a57f3738f8d991087ff414645c35fc7415320281c22ba6c334882cf5cf34b8f", - "packages/shared/src/model.ts::getModelSelectionOptionValue": "a0a224fc5bcba70162bf3c230a004e46db179fb943c4bfa531a220b3db2414d3", - "packages/shared/src/model.ts::getModelSelectionStringOptionValue": "0e8a3889e753ae1fb5ec63b834ed059e90a8b36c5aa56882c8da1500cb66f874", - "packages/shared/src/model.ts::getProviderOptionBooleanSelectionValue": "b8ce53490ee810d488c0b187b91b4c35c2591fdef2e42f6c87088937f08f029f", - "packages/shared/src/model.ts::getProviderOptionCurrentLabel": "5de7293dabd6d71221c57ebfaae3a6829cd29d83411de08db6017e56462a5545", - "packages/shared/src/model.ts::getProviderOptionCurrentValue": "20b8a51d98e860659f2dd97a816e7f0f057f4ccd142c3c5b5ba2fad444714235", - "packages/shared/src/model.ts::getProviderOptionDescriptors": "3c56b5ddf47d98d153d01567e627008f43ad32abb889d5af5cfdf42ec0674a2c", - "packages/shared/src/model.ts::getProviderOptionSelectionValue": "8cdf5869a45a0777866f4b5d98e4425608ddf3d7098b34031fffcde028f412ee", - "packages/shared/src/model.ts::getProviderOptionStringSelectionValue": "8bea31c1aea7034f936b76445124c3dcbcc755fdcab122f519bf6aceb576e6ca", - "packages/shared/src/model.ts::isClaudeUltrathinkPrompt": "6146fbd196bba4ecc0c9b9243b51171139e9e24809d97c85dae0b59f1e41f4af", - "packages/shared/src/model.ts::normalizeModelSlug": "7a9d86ac9b0dab8b41c280dfabdbacbdcc04a7da7714d0ecd58893b282687c9d", - "packages/shared/src/model.ts::resolveModelSlugForProvider": "ae0fad83529b0bc2ad273a552be12fda19375e88e91a17c883f82f1fb18d9f7b", - "packages/shared/src/model.ts::resolvePromptInjectedEffort": "966b5f0b1f91082c77f2e393aa32c2a463c65d74f18edf1741917fd8f587a9a9", - "packages/shared/src/model.ts::resolveSelectableModel": "88b0703783eacdb5df2e34d9a8982ebd17a2b3eea91a714452250668db0e2051", - "packages/shared/src/model.ts::trimOrNull": "42cf8cf54911d6ddfb1a98cd5747f8e6456852b8398c7ee47042690b44275774", - "packages/shared/src/observability.ts::LocalFileSpan.addLinks": "3dcd441fc0b5a2e2cca66d8df946c788e76bd34e0b15024be2fbcf3f933bbf4b", - "packages/shared/src/observability.ts::LocalFileSpan.attribute": "083601a10a6d2003a3e75a07c2b39da31957fc70f63d14e6db41a7d6783fac53", - "packages/shared/src/observability.ts::LocalFileSpan.constructor": "688b7f1aa6ddaf33758122b0364eeec5bba4a7cbf88f21cb22ae0e93d58a3598", - "packages/shared/src/observability.ts::LocalFileSpan.end": "fc9144f21dfedef1a0fe319c0c38041dc5bd8a1bf0eb1f904c5dfb50dfa90af9", - "packages/shared/src/observability.ts::LocalFileSpan.event": "0c2693f6c0da411f20fc523841ef2eeab4baef9e2afcf36a7b78435c2599e21a", - "packages/shared/src/observability.ts::compactTraceAttributes": "5c3f7f5ec1a3fa07f379daffe943eb04dfa661447d4c5fa84913646e5e10257e", - "packages/shared/src/observability.ts::decodeOtlpTraceRecords": "62108e080a947ac42eb6d400cceb0b05a7803d97e28e358fac6c623db88d8e8d", - "packages/shared/src/observability.ts::flushUnsafe": "a9d0934956691f1a49a6fc9772e4a0464c82239a04d7ecc8566bbf6052874881", - "packages/shared/src/observability.ts::spanToTraceRecord": "05e9c892896f634fd80eb53055bf64f15571048f4fb59fc2759099a4f7fd9045", - "packages/shared/src/path.ts::isExplicitRelativePath": "516293ca734a7ebc16686790a15624846c7a5f0cacd2a28a8fb3bbf3061f7e57", - "packages/shared/src/path.ts::isUncPath": "28fe28e8d930567bb6d50d5d5847ff111ad024cab45606a419f34abd8185b66e", - "packages/shared/src/path.ts::isWindowsAbsolutePath": "1d04af99fc1f003ef8a3b13070c52f62278ed43b8215cef3e8583e3cb4073ab4", - "packages/shared/src/path.ts::isWindowsDrivePath": "ba2b5f5db04bb4be1d03ab67061b2190a7fa2af141798d8302383565aebb866c", - "packages/shared/src/projectScripts.ts::projectScriptCwd": "4be301e0a8fa2eb5751538374961fc521dd36c64c9d8aa4c9a1aeafc15750fa7", - "packages/shared/src/projectScripts.ts::projectScriptRuntimeEnv": "1647ccab995e0e9efcdb2b70aef72c8940547e694aa79ef37b41afdc07082bc6", - "packages/shared/src/projectScripts.ts::setupProjectScript": "545590f3b47ec9df8dd0ebd323f1e0dc29b04b0b005350c6de4edb6650c9c743", - "packages/shared/src/qrCode.ts::QrCode.addEccAndInterleave": "837b7eed1ad1e9468d374e3ec14f2980d1acf70eba9f9d1f48b9187639c45c70", - "packages/shared/src/qrCode.ts::QrCode.applyMask": "e143bf1e45078d53426a2932f1b1a83215e9177c2b2a3a6ac25c8b95e92e5a69", - "packages/shared/src/qrCode.ts::QrCode.constructor": "b1fd3f6aaea6c321d7f1006658476c40ea77b47bfc0ad56eff35b914b1b5dc24", - "packages/shared/src/qrCode.ts::QrCode.drawAlignmentPattern": "d9482d8355f6a5daef0b7fd5dcbfad329bdba2d5032383eab6d5660bf78659b7", - "packages/shared/src/qrCode.ts::QrCode.drawCodewords": "5d99d522c7e56a388e5116799f5a3e065a1251d8820836ad3b96f1c5dea14223", - "packages/shared/src/qrCode.ts::QrCode.drawFinderPattern": "7f279547e43d45fc19dabbe23c095e344079602d0fdf8bca136d07691406dda8", - "packages/shared/src/qrCode.ts::QrCode.drawFormatBits": "32f6d3cc68a576ff28bde36fc9e623a6e3b404595175a322c4d55f036bf893fc", - "packages/shared/src/qrCode.ts::QrCode.drawFunctionPatterns": "9c94f727908ae4c2b5e926a5b61572453c30ac05222220828368e8d37c929e5d", - "packages/shared/src/qrCode.ts::QrCode.drawVersion": "0b781610d3616f013e6d1c08f29507c978c86e51cc6252197abfb0aa85ec4eb8", - "packages/shared/src/qrCode.ts::QrCode.encodeBinary": "c9162f3872f926af5e59ee3f8f2052f07a4bf107185a34b9540dfe42aa80d550", - "packages/shared/src/qrCode.ts::QrCode.encodeSegments": "469108906468e8daf297b39de51ff923066bc44567ad6a4ae8227548da627343", - "packages/shared/src/qrCode.ts::QrCode.encodeText": "6989ac7ad71cfd0df4ab206c63fa62a55e59d110138f37d9dd9fafb38573a8fd", - "packages/shared/src/qrCode.ts::QrCode.finderPenaltyAddHistory": "5046c011c1b0a24626fa3b30752dab3a4425c42d58952ef42f95201899a360e6", - "packages/shared/src/qrCode.ts::QrCode.finderPenaltyCountPatterns": "c040692eb5222ef4f8e68d0e805c0fa863c0e8119a3f792bb1a6e4908a5591ae", - "packages/shared/src/qrCode.ts::QrCode.finderPenaltyTerminateAndCount": "a7075576abef553a29f889299a5fcb0f3b69277dd1ac2bd4bf64b4bb249efdbe", - "packages/shared/src/qrCode.ts::QrCode.getAlignmentPatternPositions": "7e01ee120ae2267c709fffbd70f5db255fe0271eca8a63a1843d640a21e95064", - "packages/shared/src/qrCode.ts::QrCode.getModule": "f139aae35c95e84ab63d553be6ddd3e356a84451d88878d7fdc8f757b8050ee6", - "packages/shared/src/qrCode.ts::QrCode.getNumDataCodewords": "430ea8380b37960e8870842a3b340ec51ad55d92d899323855e0438bd576b0a3", - "packages/shared/src/qrCode.ts::QrCode.getNumRawDataModules": "587830b0210d5de007bb0542ddd16a094513b381dcba818771cca424d7e7e46d", - "packages/shared/src/qrCode.ts::QrCode.getPenaltyScore": "126c17231af0134e2adaf042771d81037190988b455d632e6a7c983043b42a27", - "packages/shared/src/qrCode.ts::QrCode.reedSolomonComputeDivisor": "2f35e4e29d92ad8c8b97c8e29a6c86a162757627e1258ab71266251ab3df6afa", - "packages/shared/src/qrCode.ts::QrCode.reedSolomonComputeRemainder": "458d6112ccba5403e7f92a59c16f20bc0a17dc1ab8edc6f25eb2c7e6bc0927d3", - "packages/shared/src/qrCode.ts::QrCode.reedSolomonMultiply": "65499ecce7a1ccf8617252512c6613d339fc1330d246f6a7b6908514326d75a4", - "packages/shared/src/qrCode.ts::QrCode.setFunctionModule": "782ff196c60ca71c4d6893d1e154278303346279be45a3245e5b9124409d12df", - "packages/shared/src/qrCode.ts::QrCodeEcc.constructor": "3cf28032414e71de470066651d260b52eba29847eb00fcf3c064ec0819b201f4", - "packages/shared/src/qrCode.ts::QrSegment.constructor": "be8fae6321b9abbcf04fd24abf08400af7bbde9d2e5b55231b03ac59d5b31bdd", - "packages/shared/src/qrCode.ts::QrSegment.getData": "1c0ac8101cb33b47b1e2807ffcf578f10135ff9fdaf26244354e7310baa9fe07", - "packages/shared/src/qrCode.ts::QrSegment.getTotalBits": "70829856fc6e8538536dfdd15f200216c8e1f1a24a1e53e8dc872c0ae8912974", - "packages/shared/src/qrCode.ts::QrSegment.isAlphanumeric": "5743ec9c4743599ab456a7daf268aed302d5f85c04fff68fafb61eb5196ef813", - "packages/shared/src/qrCode.ts::QrSegment.isNumeric": "1943ed8d469c9caef1e7e519e86eefa265f7f15165a2e5ced2c7bd532d8e2937", - "packages/shared/src/qrCode.ts::QrSegment.makeAlphanumeric": "774957994d606607ed9cdddb5853f808edb82783fe989b46a41847a2ea6b693d", - "packages/shared/src/qrCode.ts::QrSegment.makeBytes": "99f8e122eb5f8b88c41d4cab0a5a420f4b17fa8da05b09daa559da5852e290b6", - "packages/shared/src/qrCode.ts::QrSegment.makeEci": "22e0aa4dbb6346a57451b0230e8f40f912dd61bd112d737efd1fc6404653c986", - "packages/shared/src/qrCode.ts::QrSegment.makeNumeric": "8503e7a230f7b64b44745416c0ed1a5025e1bc929413b676c7768fbee5c3c8d4", - "packages/shared/src/qrCode.ts::QrSegment.makeSegments": "7c5452b773b1c6f380cdd43b0c566e2684eea6dbe19d42150a3fd441b60f6965", - "packages/shared/src/qrCode.ts::QrSegment.toUtf8ByteArray": "9c828d395e9deb2ba99ec238c4f5be197719abd9593d8f23ce3b7465361ca6c6", - "packages/shared/src/qrCode.ts::QrSegmentMode.constructor": "a93f039a679d8ac89248efe33f838f50a41f40c5153c0a22d64cfbbac0ac57c8", - "packages/shared/src/qrCode.ts::QrSegmentMode.numCharCountBits": "ac753b29737666dd1f80e46e44bc2f9a3bdd4281aabc819234a422d864214227", - "packages/shared/src/schemaJson.ts::decodeJsonResult": "64653771a49dffc78cb40f17567f1a099ea0c8bfda1e48cd8fb6f33083e8150e", - "packages/shared/src/schemaJson.ts::decodeUnknownJsonResult": "52c38c6ffb25c768bc0f5d010fb68bc2ba7d9932e0a6eb2b5e0db32b38685a81", - "packages/shared/src/schemaJson.ts::extractJsonObject": "08dd26e9460859d35798934c219f90d88fbf92bdf625aacc3f9d317b3816790a", - "packages/shared/src/schemaJson.ts::formatSchemaError": "9bc8426ffa8f8e3729fcc3df0beacd63eb7df28cdaf4b92c15c044a0a803af5e", - "packages/shared/src/schemaJson.ts::fromJsonStringPretty": "1cb813173634ddb7d4ba18667cb5c4a738b89c0bffcb954f23c76c677c523bfc", - "packages/shared/src/schemaJson.ts::fromLenientJson": "e5e9fc70937b01c2b74e9d7090e728a473445427cc3976dbd630dd720be8c196", - "packages/shared/src/searchRanking.ts::compareRankedSearchResults": "2deb6995863e60efe1e403b43e3fea9650514ada295a190306a681a74484c27f", - "packages/shared/src/searchRanking.ts::insertRankedSearchResult": "395005757555cfa81bded10261a71228caa82484df51480f87a8cbacc764cee4", - "packages/shared/src/searchRanking.ts::normalizeSearchQuery": "069d8909edb4349568cadca90dffaee6ae98f14a71089dd3c0a817b68d8d4b04", - "packages/shared/src/searchRanking.ts::scoreQueryMatch": "21e8750a516602eef5dcf2a4ca2955a4417c8a2f9cfa49f9d899256b1ad0f54e", - "packages/shared/src/searchRanking.ts::scoreSubsequenceMatch": "2e8355395d5551988055608a72426dcdde22f7c0e95ca0dc135fb6757bcee503", - "packages/shared/src/semver.ts::compareSemverVersions": "b5b96f32e45d3c612ff0f0a6621dd7787c3d592b205c8ca9d505b68f2e4f44f0", - "packages/shared/src/semver.ts::normalizeSemverVersion": "437cf380b0c751444eccdbbf4f4307667c585c82c1458590b2ae41070940c69c", - "packages/shared/src/semver.ts::parseSemver": "d3e1b4751f9f62bbaf8cc8df0ba15baa17d9d8b9c61853c1f47d92e77f472bca", - "packages/shared/src/semver.ts::satisfiesSemverRange": "2e5e11a480c4d70d33188d7daade0f2814e9a43d64996540e5cf6780af8e4846", - "packages/shared/src/serverSettings.ts::applyServerSettingsPatch": "7dd8778445712f31566f88810bc0d61dbaada7593d81459271b6e1908d47c172", - "packages/shared/src/serverSettings.ts::extractPersistedServerObservabilitySettings": "c5dbb45e8505284246ea70b8282529f256738946ed9bba2101d82a01000c8e66", - "packages/shared/src/serverSettings.ts::normalizePersistedServerSettingString": "77b41076a0a97a6631af2146d804b8dae3b8c28d3f1763ae61004d2e3bbc1ab2", - "packages/shared/src/serverSettings.ts::parsePersistedServerObservabilitySettings": "33f950d858dcce798d8cef4f711195f64a15d776b6d1e5cc08c6035be7079fe9", - "packages/shared/src/shell.ts::extractPathFromShellOutput": "c6a5181d49693b7e2e62a07b71e9fae3342169911cb32a20d67e3af61ef85caf", - "packages/shared/src/shell.ts::isCommandAvailable": "5b133f7713b9c639bb2483ec9d96b50edbde478423e0cab45caa8c8a69222bfe", - "packages/shared/src/shell.ts::listLoginShellCandidates": "5cf2e65dff351e73fcfd8576566226084459b521275b5ee42908d1ec85a4395b", - "packages/shared/src/shell.ts::mergePathEntries": "24139b357e2d5469ec02b4ee12c4e2d7b72185fa3925d73a005dd06116a2b420", - "packages/shared/src/shell.ts::mergePathValues": "5de755d9120e4d3227b8b55bdb94e59e873b61e74cdc8a7ebe8b55e40abb3786", - "packages/shared/src/shell.ts::readEnvironmentFromLoginShell": "f15157aa9b556476602292a0b4d2f5b07a66f183c1c894264ef8c157cddd3c5c", - "packages/shared/src/shell.ts::readEnvironmentFromWindowsShell": "4557831ff1a3524d06d1f1d8c5748a7bb137e20a57126eeca7470cee456ef65c", - "packages/shared/src/shell.ts::readPathFromLaunchctl": "c3a4b8ec3c587ae088f60c25d1b5428b3ab6e161814b905ba1c096eca33d477b", - "packages/shared/src/shell.ts::readPathFromLoginShell": "3ee5e17764f50f7c498d13c454215404bd5d9ba2a962ff54b5fedd5f57482e5b", - "packages/shared/src/shell.ts::resolveCommandPath": "9ff92a14dc3a643959d1c8c1021d53a86535134ea8b5d75b4eab32302d7880d5", - "packages/shared/src/shell.ts::resolveKnownWindowsCliDirs": "115e396e435fa0d8a936dfe72ec27dca6e8cc07e9e446a141d841eea69796c43", - "packages/shared/src/shell.ts::resolveWindowsEnvironment": "abffd1c1989842113285ada8424802851994e8e53a979c3c012bf9c749bae229", - "packages/shared/src/sourceControl.ts::detectSourceControlProviderFromRemoteUrl": "1bab3de60406b7924417e95aa8c030bc66671c314707c292c8b56a71381f98ab", - "packages/shared/src/sourceControl.ts::formatChangeRequestAction": "95db402a714fbc62d182a9ca5161fa2a5cc60ce0447e8da499ee8c07abd50199", - "packages/shared/src/sourceControl.ts::formatCreateChangeRequestPhrase": "6b041f1771967832323cb897507a3faaac13b9c86622af369bc2d135165d4f9d", - "packages/shared/src/sourceControl.ts::getChangeRequestTerminology": "9b9832e467e6462f8e9da17bfb8a01cc22bf5b8a8b2c53f409fb8e502013cfcc", - "packages/shared/src/sourceControl.ts::getChangeRequestTerminologyForKind": "b0b7dac81951a4c3719e3aab8ac791c048576526a6bfbdd6caca821f20d66478", - "packages/shared/src/sourceControl.ts::resolveChangeRequestPresentation": "0cb6f9fb88eefc23a0ac171fb2f9b6300c900e4a990047c738ce93dc62c6c02c", - "packages/shared/src/sourceControl.ts::resolveChangeRequestPresentationForKind": "7f6f622d2e49d595320775fcf149e7a64c29b4638c5e9ea5d0c550e4c4878b52", - "packages/shared/src/toolActivity.ts::deriveToolActivityPresentation": "94798b44791cb5dd64e13abf0ca570f836e8e98a834711c01d31fde3d22392a0", - "packages/ssh/src/auth.ts::isSshAuthFailure": "80b2a519f5373b18fb1e21cc1ef3f59b45506e500bb9a819f4bb13c9c0afb76d", - "packages/ssh/src/command.ts::baseSshArgs": "0450a9d5da44e696b72019c0c1c0d1afceccb6694b74d093cd5dcdaa4571460e", - "packages/ssh/src/command.ts::buildSshHostSpec": "5dede54808a1ee79bdb46313a6ca3f58377b1759742e9f27d6bb3d1be5569922", - "packages/ssh/src/command.ts::buildSshHostSpecEffect": "b181b536c3bacafb1c41932a1f75b14d6dba2b7165642dfaf34343ddedc9a01c", - "packages/ssh/src/command.ts::collectProcessOutput": "37971ac5cce1807462df0ac45d6d7696a2728058a7cfda198f1b849f33cdf822", - "packages/ssh/src/command.ts::getLastNonEmptyOutputLine": "9c3f4364105396d91ff620e5e71f294d2e29342d338b39cff43e2151285147d5", - "packages/ssh/src/command.ts::parseSshResolveOutput": "fb79e55f6711065a00a73e6d39c7a641bb2d83f1df8a28d95c70da0ee0eaa6ce", - "packages/ssh/src/command.ts::remoteStateKey": "fa1eca0787e2b8e0cfc9eec3c80c451d3dd9b3eda4cc6e253794c69c603bb4bf", - "packages/ssh/src/command.ts::resolveRemoteCafeCodeCliPackageSpec": "a1b5f352938d9a9255e0e7e7eab0024a7fa7e43a7f57652b30468e049cab6c2f", - "packages/ssh/src/command.ts::targetConnectionKey": "48822e4a512ae7c18c443c16f37ffd3fd9ca15ad7d491a9771fbfa70cbc5fc2e", - "packages/ssh/src/config.ts::parseKnownHostsHostnames": "a3c0d69c0c9a940ed204c3c2650f88224e5ae604dd6583a8c3caf5b65d7e3598", - "packages/ssh/src/tunnel.ts::buildRemoteCafeCodeRunnerScript": "078addeaeb5fea8595fed05cf3fd50d99b32ce48e6243e5df5fba76668507043", - "packages/ssh/src/tunnel.ts::buildRemoteLaunchScript": "77ba032df85ee0489d3f6d1f7848d9ebacdc2bc115484b5999edf5bff1ee8080", - "packages/ssh/src/tunnel.ts::buildRemotePairingScript": "83f3c235d13dcd1507bb36ee05764f4e8b40c217144d6015daf6c59e2b7c7ddc", - "packages/ssh/src/tunnel.ts::buildRemoteStopScript": "d61fd5d461f9dedf861747994710533fb464ae600e790123e3b739418a46f303", - "packages/ssh/src/tunnel.ts::describeReadinessCause": "d043e3f8b36dfdf98d67b8b3cf1f17415d286bb6834932b00c3aeee67633e249", - "packages/ssh/src/tunnel.ts::normalizeSshErrorMessage": "f3a02a77447a855abde37c293952e5ca5c1c386832b5f44c1b54754b0602c5f2", - "packages/tailscale/src/tailscale.ts::buildTailscaleHttpsBaseUrl": "aa777322e833c6a9878d707e2380aa86eb0fb3de92d86c7a513bb7d31b98a11f", - "packages/tailscale/src/tailscale.ts::disableTailscaleServe": "4137569163bb3b80a1464c14e0c8e7349ffec8191579d1b9d0545dbe2353af19", - "packages/tailscale/src/tailscale.ts::ensureTailscaleServe": "fb5dbf522f4915add7a629afe1902d6ba180eec8e46e3f17e0449e61fd5e3127", - "packages/tailscale/src/tailscale.ts::isTailscaleIpv4Address": "009697722b1ec46680176fc2ad7da254d2ea2ed4f737f60e7547a740927c7120", - "packages/tailscale/src/tailscale.ts::parseTailscaleMagicDnsName": "61624051e6ad470c0efb80043a7ed88829d76a47f6efefe1a5bc5724e30853cd", - "packages/tailscale/src/tailscale.ts::parseTailscaleStatus": "43493be2c350f1b285b78212b51766f493b447e6aecff92b127129a76530f418", - "packages/tailscale/src/tailscale.ts::probeTailscaleHttpsEndpoint": "a168c4e718235f3ca6770b471940a72bdb621015ff9bb956a317ed5c7f69969f", - "packages/tailscale/src/tailscale.ts::resolveTailscaleHttpsBaseUrl": "fc3b28fbc738b0a62c75fa4efd69d5921f4fb936fc3d8b96d5a97f10f58fbef4", - "scripts/build-desktop-artifact.ts::resolveDesktopBuildIconAssets": "3c2b7b4c43f1310b558fd06cd7687604e223d1f5d303a76b58771fda60e196ab", - "scripts/build-desktop-artifact.ts::resolveDesktopProductName": "216c77d5b5bff129e031e496bb8edd6447307eca45fe8f251a286b16a7a41ab8", - "scripts/build-desktop-artifact.ts::resolveDesktopRuntimeDependencies": "baad14fbd0ba1e71053a1c2e1c11f0a84ff4b76c14a8d3ac5dc9815b0b7a4fbd", - "scripts/build-desktop-artifact.ts::resolveDesktopUpdateChannel": "d36870392193a2c23eab26223fa92cef5e2960d4f2f02ea2ea45214f6270aa34", - "scripts/build-desktop-artifact.ts::resolveMockUpdateServerUrl": "bc9813190518249b5c0044e815acf23880be929bc46bde826d7f46544e03d631", - "scripts/dev-runner.ts::checkPortAvailabilityOnHosts": "ab4d14754c1c3245f55e82ba176ebbfc79a352f4e17ad62757ec2df4b14093aa", - "scripts/dev-runner.ts::createDevRunnerEnv": "f9f6cea157587e4b8592813d66280f2d0e6e8116f39c6455a8315478f589a99d", - "scripts/dev-runner.ts::findFirstAvailableOffset": "f7c537e46d7e42a2d3bfcf78272833ddbc2b93727524618560b4d0d351c4cdba", - "scripts/dev-runner.ts::resolveModePortOffsets": "ab91a66db522f810d086ea2bbd196df5e62206177931e88adcc6cdc3fe8bc288", - "scripts/dev-runner.ts::resolveOffset": "c8e7eab988f3999dc2101a76a15d977890ac344dda3ca49787b75c47d25821c2", - "scripts/dev-runner.ts::runDevRunnerWithInput": "9f2aba62b3d0af433345ab2469f5773e2390da2de11229266669c523a345ec9f", - "scripts/lib/brand-assets.ts::resolveWebAssetBrandForChannel": "20bed4b6a774f5d484fd7de93b3b5aa429267179a5cba4a01887ce5d1ed610d4", - "scripts/lib/brand-assets.ts::resolveWebIconOverrides": "1a18c341e71d79bf305f2cf7ce8cb9b82d3f559b8e6e4a63283a471b2eb27cbc", - "scripts/lib/build-target-arch.ts::getDefaultBuildArch": "51e6d3c5b1431b8de04c4704c3952a16dbf7d7820c25f8c74fa1f280a213cf12", - "scripts/lib/build-target-arch.ts::resolveHostProcessArch": "114502ebf5d52985bbdaa5012b20991ddd8a10b5d215bab1444216c8e69828cf", - "scripts/lib/resolve-catalog.ts::resolveCatalogDependencies": "93fd0f563e3541f5c7d5c5c632e5719171650f480c27696e8546d2ef603c5766", - "scripts/lib/update-manifest.ts::mergeUpdateManifests": "0e9bf3c4c9ed5fdf47845bdc31615d56654ed4f15920fd7a7de499b40eac222f", - "scripts/lib/update-manifest.ts::parseUpdateManifest": "8d54fd96d5728775094dd16f78134145e498bb04276c60f9ebe37f6aab78072d", - "scripts/lib/update-manifest.ts::serializeUpdateManifest": "5324f8c4c51a338d30a01a7b63efeef2b77292d63cc6c4529ed67b5b146b7faf", - "scripts/merge-update-manifests.ts::mergePlatformUpdateManifests": "8e75cc07fd6669f4022bccc2c0aee43c0edf635fe304d02359607cff44175eca", - "scripts/merge-update-manifests.ts::parsePlatformUpdateManifest": "5aa777065583ca7c5ec7471cbd5894d86557b1c6c72b7db2a89144127cfa45d1", - "scripts/merge-update-manifests.ts::serializePlatformUpdateManifest": "1f18b723d20696fed740bfbc680f3e5a1050c0c29d422d3605a2b1687e5e4f86", - "scripts/mock-update-server.ts::makeMockUpdateRouteLayer": "b8171381cc46098a526d97a6862b1ff0254df3dd270aca5051bca60b21e0936e", - "scripts/notify-discord-release.ts::buildDiscordReleaseAnnouncement": "129bcc947306cc0573a985b8902d1301173165510e0c31125793788568c3d0ba", - "scripts/resolve-nightly-release.ts::resolveNightlyBaseVersion": "f104866b339aef50cb14192a611849fbd72ccc9c4f2e234bd9365301c772f7cb", - "scripts/resolve-nightly-release.ts::resolveNightlyReleaseMetadata": "2156a574e094511fab18ca0a2b1d89c4263ce8adbd023c374ca6cdda8cb99626", - "scripts/resolve-nightly-release.ts::resolveNightlyTargetVersion": "43b4796ab22b2a744fb69373bf4836edf9dd7a1e080e02bb09961b0179a7de4a" - } -} diff --git a/.selene/manifest.toml b/.selene/manifest.toml deleted file mode 100644 index ead882da..00000000 --- a/.selene/manifest.toml +++ /dev/null @@ -1,4 +0,0 @@ -name = "cafe-code" -description = "Selene-managed T3 Code fork: Bun/TypeScript monorepo for browser and desktop coding-agent sessions." -implementer = "codex" -auditor = "claude" diff --git a/.selene/nonclaims.md b/.selene/nonclaims.md deleted file mode 100644 index 484eb3e2..00000000 --- a/.selene/nonclaims.md +++ /dev/null @@ -1,16 +0,0 @@ -## Hard Non-Claims - -- No Selene artifact claims that cafe-code is secure, correct, race-free, - deadlock-free, data-loss-free, or compatible with any upstream provider API - outside the artifact's predeclared scope. -- No user-facing behavior, migration, protocol, or security claim is promoted - unless it is grounded in source review and the repository's required quality - gates for the touched surface. -- Provider output is untrusted. Selene enforces process discipline; it does not - make Claude, Codex, Cursor, OpenCode, or any other provider semantically - correct. -- This artifact does not claim coverage of credentials, tokens, local files, - persisted conversations, WebSocket sessions, or provider subprocess behavior - beyond the exact checks recorded in the artifact. -- This artifact does not promote any classification beyond what the executable - strict-close rules in `.selene/classifications.py` admit. diff --git a/.selene/project.json b/.selene/project.json deleted file mode 100644 index 55340f05..00000000 --- a/.selene/project.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "description": "T3 Code fork: TypeScript/Bun monorepo for a web GUI around coding agents, with server, web, desktop, contracts, and shared packages.", - "initialised_at": "2026-05-21", - "name": "cafe-code", - "selene_version": "0.1.0", - "template": "library" -} diff --git a/.selene/terminology_lock_v1.md b/.selene/terminology_lock_v1.md deleted file mode 100644 index 489035ea..00000000 --- a/.selene/terminology_lock_v1.md +++ /dev/null @@ -1,44 +0,0 @@ -# cafe-code — Terminology Lock v1 - -This document is the canonical vocabulary for cafe-code. Every artifact -produced under this project must include a Terminology section pointing back here. - -## Primitive terms - -| Term | Definition | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| cafe-code | This fork of T3 Code, a Bun/TypeScript monorepo for a browser and desktop GUI around coding agents. | -| T3 Code | The upstream application lineage. Use only for inherited behavior or upstream-compatible concepts. | -| provider | A coding-agent backend exposed through the server, such as Codex, Claude, Cursor, or OpenCode. | -| provider session | One server-managed runtime instance for a provider conversation or resumed provider thread. | -| Codex app-server | The Codex JSON-RPC-over-stdio process wrapped by the server for Codex provider sessions. | -| WebSocket protocol | The client/server RPC and push-event channel between `apps/web` and `apps/server`. | -| orchestration domain event | A durable server-side event representing session, turn, provider, projection, or lifecycle state. | -| projection | A read-model view derived from persisted orchestration events for UI consumption. | -| environment | A web UI workspace/runtime context that subscribes to shell and detail streams. | -| contracts package | `packages/contracts`; schema-only shared TypeScript contracts for protocol and model shapes. | -| shared package | `packages/shared`; runtime utilities exported through explicit subpath exports. | -| desktop app | `apps/desktop`; the Tauri wrapper around the web/server experience. | -| security-sensitive data | Credentials, provider tokens, session tokens, API keys, local filesystem paths, and any persisted conversation content that may contain secrets. | - -## Anti-confusion table - -| Term | This project's meaning | NOT to be confused with | -| ------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------- | -| provider | Local abstraction for a coding-agent backend | Dependency injection provider, React context provider, or cloud vendor generally | -| session | Provider or application runtime conversation state | Browser cookie alone | -| orchestration | Event-driven provider/session coordination inside this app | The deprecated Selene daemon/orchestrator | -| event | Persisted or streamed application state transition | Browser DOM event unless explicitly qualified | -| contract | Runtime/schema boundary shared between packages | Legal agreement | -| environment | UI/runtime workspace context | Shell environment variables unless explicitly qualified | -| projection | Derived read model | Database table generally or visual projection | - -## Anti-bleed rule - -Any artifact that uses a term outside this lock's scope must namespace-prefix it -explicitly (e.g., `External-foo`). - -## Updates - -This lock evolves only through new ADRs that explicitly amend it. Bursts may not -modify this file directly. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index c3170642..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index c310a5b6..0bc5e070 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -2,9 +2,7 @@ Cafe Code reads keybindings from: -- `~/.cafecode/keybindings.json` - -Existing `~/.t3/keybindings.json` files remain supported as a legacy fallback. +- `~/.cafe-code/keybindings.json` The file must be a JSON array of rules: diff --git a/README.md b/README.md index 22d7c7f0..d639817b 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,176 @@ # Cafe Code -Cafe Code is a minimal desktop GUI for coding agents (currently Codex, Claude, and OpenCode, more coming soon). +![Cafe Code desktop screenshot](./docs/images/cafe-code-desktop.png) -## Installation +_Cafe Code is very small, barely does a thing at all. Chat goes in and chat comes out, soft and sweet, without a shout._ -> [!WARNING] -> Cafe Code currently supports Codex, Claude, and OpenCode. -> Install and authenticate at least one provider before use: -> -> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` -> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` -> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` +Cafe Code is a tiny desktop GUI for coding agents. It is a fork of [T3 Code](https://github.com/pingdotgg/t3code), with a basket of bug fixes, a little sweep-up, and some very opinionated trimming for people who want the agent chat and not much else. -### Run without installing +It is meant to stay light, calm, and out of the way — not freeze, drag, or get all sleepy like so many other clients do. + +T3 Code said it wanted to be minimal. Cafe Code went even smaller. + +No terminal drawer. No pretend IDE. No giant dashboard wearing a useful-looking hat. If you want a console, use a real console. If you want to inspect code, open it in VS Code. + +

+ Cafe Code character +

+ +## Why Fork? + +Because the app should stay small, fast, and predictable. + +Bug fixes are welcome. Performance fixes are welcome. Reliability fixes are +welcome. Security fixes are extra welcome. + +Feature requests need to pass the tiny-window test: does this make Cafe Code +smaller, calmer, faster, easier to understand, lower CPU, lower memory, or less +annoying when something fails? + +If yes, maybe. + +If it turns Cafe Code into a pretend IDE, a pretend terminal, a release +dashboard, a project-management suite, or a museum of buttons, no. + +## What Changed From T3 Code + +This is the practical working list. It will probably get cleaned up later. + +- Rebranded the app around Cafe Code. +- Moved local app data into `~/.cafe-code`. +- Removed the in-app terminal drawer and terminal UI. +- Removed hosted web-app assumptions and focused the project on the Electron app. +- Disabled update checks until Cafe Code has its own release path. +- Added a queue/follow-up workflow for prompts sent while a provider is running. +- Added provider-aware queue actions: steer when supported, interrupt when that + is the honest behavior. +- Added thread moving between project folders and working directories. +- Added "Move to Recycle Bin", "Recently Deleted", restore, permanent delete, + and empty recycle bin flows. +- Added a default editor setting for VS Code, Antigravity, Finder, or system + default. +- Made file-change rows and path pills open real paths instead of truncated + display text. +- Added a localhost-only debug endpoint behind `--cafe-debug`. +- Reduced needless Git polling and checkpoint churn. +- Hardened hidden checkpoint handling, ignored-file capture, and old ref pruning. +- Fixed provider/session edge cases around reconnects, stale running state, + resume metadata, and null checkpoint timestamps. +- Removed or hid features that do not belong in a minimal coding-agent shell. + +## Run From npm + +For now there are no desktop packages. No DMG, no updater, no notarized bundle, +no "drag this into Applications" ceremony. + +Run Cafe Code directly from npm: ```bash -npx cafe-code +npx @cafeai/cafe-code ``` -Run Cafe Code with `npx cafe-code`. +`npx` downloads the package if needed and starts Cafe Code immediately. + +If you want a normal command on your machine: + +```bash +npm install -g @cafeai/cafe-code +cafe-code +``` -### Desktop app +Cafe Code expects at least one provider to already be installed and +authenticated: -Install the latest version of the desktop app from [GitHub Releases](https://github.com/cafeai/cafe-code/releases). +- Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` +- Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` -Package registry aliases are deferred until Cafe Code-owned package IDs are published. +OpenCode exists in the codebase, but Cafe Code is currently developed around +Codex and Claude first. -## Some notes +## Local Development -We are very very early in this project. Expect bugs. +Run the app from a checkout: -We are not accepting contributions yet. +```bash +bun install +bun start:desktop +``` -Observability guide: [docs/observability.md](./docs/observability.md) +Run the desktop package directly: -## If you REALLY want to contribute still.... read this first +```bash +bun --cwd apps/desktop start +``` -Before local development, prepare the environment and install dependencies: +Debug mode: ```bash -# Optional: only needed if you use mise for dev tool management. -mise install -bun install . +bun --cwd apps/desktop start -- --cafe-debug ``` -Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. +The app prints a localhost-only debug URL on startup. + +Useful checks: + +```bash +bun fmt +bun lint +bun typecheck +bun run test +``` + +Do not run `bun test`; this repo uses `bun run test`. + +## 日本語でちゅ + +Cafe Code は、Codex とか Claude とお話するための、 +ちいさめデスクトップアプリだわ。 + +T3 Code から fork して、 +バグ直して、重いところ軽くして、 +いらない機能はぽいぽいした。 + +ターミナルいらない。 +でかいダッシュボードいらない。 +ボタンだらけの謎コックピットもいらない。 + +コード見たいなら VS Code ひらこ。 +コンソール使いたいなら、本物のコンソール使お。 + +Cafe Code は、チャットする。 +作業を見る。 +邪魔しない。 +それだけ。えらい。 + +### npm から動かす + +まだ DMG とか、インストーラーとか、アップデーターとかはないよ。 +今は npm からそのまま起動するのがいちばん素直。 + +```bash +npx @cafeai/cafe-code +``` + +`npx` は、必要ならパッケージを取ってきて、そのまま Cafe Code を起動するよ。 +「インストールだけ」じゃなくて、これで起動までいく。 + +Codex を使うなら先に `codex login`。 +Claude を使うなら先に `claude auth login`。 +そこは自分でログインしておいてね。 + +```bash +bun fmt +bun lint +bun typecheck +bun run test +``` + +`bun test` は使わないでね。 +このリポジトリは `bun run test` の子なの。 + +## License + +Cafe Code is AGPL-3.0-or-later. -Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). +The fork keeps the upstream attribution story intact; see the license and notice +files for details. diff --git a/REMOTE.md b/REMOTE.md deleted file mode 100644 index 1a1e6fcf..00000000 --- a/REMOTE.md +++ /dev/null @@ -1,184 +0,0 @@ -# Remote Access - -Use this when you want to connect to a Cafe Code server from another device such as a phone, tablet, or separate desktop app. - -## Recommended Setup - -Use a trusted private network that meshes your devices together, such as a tailnet. - -That gives you: - -- a stable address to connect to -- transport security at the network layer -- less exposure than opening the server to the public internet - -## Enabling Network Access - -There are two ways to expose your server for remote connections: from the desktop app or from the CLI. - -### Option 1: Desktop App - -If you are already running the desktop app and want to make it reachable from other devices: - -1. Open **Settings** → **Connections**. -2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces. -3. The settings panel will show the default reachable endpoint, with a `+N` control when more endpoints are available. Expand it to inspect alternatives such as loopback, LAN, private-network, or HTTPS endpoints. -4. Use **Create Link** to generate a pairing link you can share with another device. - -The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks. - -When no user default is saved, the app uses the built-in LAN endpoint for pairing links when -available. You can set another endpoint as the default from the expanded endpoint list. - -- HTTPS/WSS-compatible endpoints are useful when another trusted client can reach that endpoint - directly. -- Non-loopback HTTP endpoints are useful for direct LAN pairing. -- Loopback-only endpoints are not useful for another device unless that device is the same machine. - -If the copied link points directly at `http://192.168.x.y:3773`, open it from a client that can reach that LAN address. Pairing links are direct backend links; Cafe Code no longer ships a hosted static web app for browser-side pairing. - -### Tailscale Endpoints - -When the desktop app can detect Tailscale, it adds Tailnet endpoints to the reachable endpoint list. - -Depending on your Tailscale setup, this may include: - -- the machine's `100.x.y.z` Tailnet IP -- a MagicDNS name -- an HTTPS MagicDNS endpoint when Tailscale Serve is configured for this backend - -The Tailscale HTTPS endpoint uses the clean MagicDNS URL, such as -`https://machine.tailnet.ts.net/`, and is disabled until the app verifies that the URL reaches this -backend. Use **Setup** on the Tailscale HTTPS row to opt in. The desktop app restarts the backend -with the same server-side behavior as `cafe-code serve --tailscale-serve`, then the server asks Tailscale -Serve to proxy HTTPS traffic to the local backend. - -The Tailscale support is an endpoint provider add-on. The core remote model still works without Tailscale: LAN HTTP endpoints, custom HTTPS endpoints, future tunnels, and SSH-launched environments all use the same saved environment and pairing flow. - -Plain HTTP Tailnet endpoints can work from a desktop client or another browser page served over HTTP. HTTPS Tailnet endpoints are preferable for clients opened from secure browser contexts because browsers block HTTPS pages from connecting to insecure HTTP or WS backends. - -### Option 2: Headless Server (CLI) - -Use this when you want to run the server without a GUI, for example on a remote machine over SSH. - -Run the server with `cafe-code serve`. - -```bash -npx cafe-code serve --host "$(tailscale ip -4)" -``` - -`cafe-code serve` starts the server without opening a browser and prints: - -- a connection string -- a pairing token -- a pairing URL -- a QR code for the pairing URL - -From there, connect from another device in either of these ways: - -- scan the QR code on your phone -- in the desktop app, enter the full pairing URL -- in the desktop app, enter the host and token separately - -Use `cafe-code serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument. - -For HTTPS pairing over Tailscale, opt in to Tailscale Serve: - -```bash -npx cafe-code serve --tailscale-serve -``` - -By default this configures Tailscale Serve on HTTPS port 443 and advertises -`https://machine.tailnet.ts.net/`. Advanced users can choose a different HTTPS port: - -```bash -npx cafe-code serve --tailscale-serve --tailscale-serve-port 8443 -``` - -> Note -> The GUIs do not currently support adding projects on remote environments. -> For now, use `cafe-code project ...` on the server machine instead. -> Full GUI support for remote project management is coming soon. - -### Option 3: Desktop-Managed SSH Launch - -Use this when you want the desktop app to start or reuse Cafe Code on another machine over SSH. - -1. Open **Settings** → **Connections**. -2. Under **Remote Environments**, choose **Add environment**. -3. Select the SSH launch flow. -4. Enter the SSH target, such as `user@example.com`. -5. Confirm the launch. The desktop app probes the host, starts or reuses a remote Cafe Code server, opens a local port forward, and saves the environment. - -After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint. The remote host still owns the actual Cafe Code server, projects, files, git state, terminals, and provider sessions. - -SSH launch is a desktop feature because it needs local process and SSH access. Once the environment is paired and saved, it uses the same environment list and connection model as direct LAN, Tailscale, HTTPS, or future tunnel-backed environments. - -#### SSH Launch Troubleshooting - -The desktop SSH launcher connects with a non-interactive `sh` session, writes a small launcher script under `~/.cafecode/ssh-launch//`, starts or reuses a remote Cafe Code server, and forwards the remote loopback port back to your desktop. Existing `~/.t3/ssh-launch` state remains supported as a legacy fallback. - -The remote host must have a compatible Node.js runtime. Cafe Code uses the server package's `engines.node` requirement: - -```text -^22.16 || ^23.11 || >=24.10 -``` - -During SSH launch, Cafe Code first checks whether `node` is already available on `PATH`. If it is missing, the launcher tries common non-interactive shell locations and version-manager shims/activation hooks: - -- `~/.local/bin`, `~/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin` -- Volta via `~/.volta/bin` -- asdf via `~/.asdf/shims`, `~/.asdf/bin`, or `~/.asdf/asdf.sh` -- mise via `~/.local/share/mise/shims`, `~/.mise/shims`, or `mise activate sh` -- fnm via `fnm env --use-on-cd --shell sh` or `fnm env --shell sh` -- nodenv via `~/.nodenv/bin`, `~/.nodenv/shims`, or `nodenv init -` -- nvm via `$NVM_DIR/nvm.sh`, then `nvm use default`, `nvm use node`, or `nvm use --lts` -- installed nvm versions under `$NVM_DIR/versions/node/*/bin` - -If launch fails with `node: command not found`, a port-scan failure, or a message that the remote Node version does not satisfy the required range, SSH into the host and check the same non-interactive shell path Cafe Code uses: - -```bash -ssh user@example.com 'sh -lc "command -v node && node --version"' -``` - -If that does not print a compatible Node version, configure your version manager for non-interactive shells or install a compatible Node binary in one of the searched locations. For example, with nvm you may need a default alias: - -```bash -nvm alias default 24 -``` - -With mise/asdf/fnm/nodenv, make sure the tool's shim directory is installed and points at a Node version satisfying the range above. - -If reconnecting after an app update fails, retry the SSH launch once. The launcher now compares its generated runner script, stops stale launcher-managed remote servers, clears the SSH launch PID/port state, and starts a fresh remote server. You should not normally need to delete `~/.cafecode/ssh-launch` or kill `cafe-code` processes manually. - -## How Pairing Works - -The remote device does not need a long-lived secret up front. - -Instead: - -1. `cafe-code serve` issues a one-time owner pairing token. -2. The remote device exchanges that token with the server. -3. The server creates an authenticated session for that device. - -After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device. - -## Managing Access Later - -Use `cafe-code auth` to manage access after the initial pairing flow. - -Typical uses: - -- issue additional pairing credentials -- inspect active sessions -- revoke old pairing links or sessions - -Use `cafe-code auth --help` and the nested subcommand help pages for the full reference. - -## Security Notes - -- Treat pairing URLs and pairing tokens like passwords. -- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly. -- Anyone with a valid pairing credential can create a session until that credential expires or is revoked. -- Pairing links keep the credential in the URL hash so it is not sent in HTTP requests, but it can still be exposed through browser history, screenshots, logs, or copy/paste. -- Use `cafe-code auth` to revoke credentials or sessions you no longer trust. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 3d856996..00000000 --- a/TODO.md +++ /dev/null @@ -1,13 +0,0 @@ -# TODO - -## Small things - -- [ ] Submitting new messages should scroll to bottom -- [ ] Only show last 10 threads for a given project -- [ ] Thread archiving -- [ ] New projects should go on top -- [ ] Projects should be sorted by latest thread update - -## Bigger things - -- [ ] Queueing messages diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e8601b91..b1ea2bc3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@cafecode/desktop", - "version": "0.0.24", + "version": "0.0.25", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/desktop/resources/icon.icns b/apps/desktop/resources/icon.icns index da16d12a..65c53884 100644 Binary files a/apps/desktop/resources/icon.icns and b/apps/desktop/resources/icon.icns differ diff --git a/apps/desktop/resources/icon.ico b/apps/desktop/resources/icon.ico index 8298f70d..23cd8598 100644 Binary files a/apps/desktop/resources/icon.ico and b/apps/desktop/resources/icon.ico differ diff --git a/apps/desktop/resources/icon.png b/apps/desktop/resources/icon.png index 37f3f756..931400d4 100644 Binary files a/apps/desktop/resources/icon.png and b/apps/desktop/resources/icon.png differ diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 50cc313b..268c21cb 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -1,126 +1,46 @@ -// This file mostly exists because we want dev mode to say "Cafe Code (Dev)" instead of "electron" - +import { createRequire } from "node:module"; import { spawnSync } from "node:child_process"; import { copyFileSync, cpSync, existsSync, mkdirSync, - mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs"; -import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); -const APP_DISPLAY_NAME = isDevelopment ? "Cafe Code (Dev)" : "Cafe Code (Alpha)"; -const APP_BUNDLE_ID = isDevelopment ? "com.cafeai.cafecode.dev" : "com.cafeai.cafecode"; -const LAUNCHER_VERSION = 2; - const __dirname = dirname(fileURLToPath(import.meta.url)); -export const desktopDir = resolve(__dirname, ".."); -const repoRoot = resolve(desktopDir, "..", ".."); -const defaultIconPath = join(desktopDir, "resources", "icon.icns"); -const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); -function setPlistString(plistPath, key, value) { - const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { - encoding: "utf8", - }); - if (replaceResult.status === 0) { - return; - } +export const desktopDir = resolve(__dirname, ".."); - const insertResult = spawnSync("plutil", ["-insert", key, "-string", value, plistPath], { - encoding: "utf8", - }); - if (insertResult.status === 0) { - return; - } +const macAppName = "Cafe Code"; +const macBundleIdentifier = "com.cafeai.cafecode"; +const macRuntimeLauncherVersion = 1; +const macRuntimeDir = join(desktopDir, ".electron-runtime"); +const macRuntimeMetadataPath = join(macRuntimeDir, "metadata.json"); +const macRuntimeAppBundlePath = join(macRuntimeDir, `${macAppName}.app`); +const macRuntimeExecutablePath = join(macRuntimeAppBundlePath, "Contents", "MacOS", "Electron"); +const macRuntimeIconPath = join(desktopDir, "resources", "icon.icns"); - const details = [replaceResult.stderr, insertResult.stderr].filter(Boolean).join("\n"); - throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); +function resolveInstalledElectronPath() { + const require = createRequire(import.meta.url); + return require("electron"); } -function runChecked(command, args) { - const result = spawnSync(command, args, { encoding: "utf8" }); - if (result.status === 0) { - return; - } - - const details = [result.stdout, result.stderr].filter(Boolean).join("\n"); - throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); +function resolveMacSourceAppBundlePath(electronExecutablePath) { + return resolve(dirname(electronExecutablePath), "../.."); } -function ensureDevelopmentIconIcns(runtimeDir) { - const generatedIconPath = join(runtimeDir, "icon-dev.icns"); - mkdirSync(runtimeDir, { recursive: true }); - - if (!existsSync(developmentMacIconPngPath)) { - return defaultIconPath; - } - - const sourceMtimeMs = statSync(developmentMacIconPngPath).mtimeMs; - if (existsSync(generatedIconPath) && statSync(generatedIconPath).mtimeMs >= sourceMtimeMs) { - return generatedIconPath; - } - - const iconsetRoot = mkdtempSync(join(runtimeDir, "dev-iconset-")); - const iconsetDir = join(iconsetRoot, "icon.iconset"); - mkdirSync(iconsetDir, { recursive: true }); - - try { - for (const size of [16, 32, 128, 256, 512]) { - runChecked("sips", [ - "-z", - String(size), - String(size), - developmentMacIconPngPath, - "--out", - join(iconsetDir, `icon_${size}x${size}.png`), - ]); - - const retinaSize = size * 2; - runChecked("sips", [ - "-z", - String(retinaSize), - String(retinaSize), - developmentMacIconPngPath, - "--out", - join(iconsetDir, `icon_${size}x${size}@2x.png`), - ]); - } - - runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]); - return generatedIconPath; - } catch (error) { - console.warn( - "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.", - error, - ); - return defaultIconPath; - } finally { - rmSync(iconsetRoot, { recursive: true, force: true }); - } -} - -function patchMainBundleInfoPlist(appBundlePath, iconPath) { - const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); - setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); - setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); - setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); - setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); - - const resourcesDir = join(appBundlePath, "Contents", "Resources"); - copyFileSync(iconPath, join(resourcesDir, "icon.icns")); - copyFileSync(iconPath, join(resourcesDir, "electron.icns")); +function statFingerprint(path) { + const stat = statSync(path); + return `${stat.size}:${Math.trunc(stat.mtimeMs)}`; } -function readJson(path) { +function readJsonFile(path) { try { return JSON.parse(readFileSync(path, "utf8")); } catch { @@ -128,53 +48,100 @@ function readJson(path) { } } -function buildMacLauncher(electronBinaryPath) { - const sourceAppBundlePath = resolve(electronBinaryPath, "../../.."); - const runtimeDir = join(desktopDir, ".electron-runtime"); - const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); - const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron"); - const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; - const metadataPath = join(runtimeDir, "metadata.json"); +function replacePlistString(plistPath, key, value) { + const result = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { + encoding: "utf8", + }); + if (result.status !== 0) { + const message = (result.stderr || result.stdout || "unknown plutil failure").trim(); + throw new Error(`Failed to patch ${key} in ${plistPath}: ${message}`); + } +} - mkdirSync(runtimeDir, { recursive: true }); +function patchMacAppBundlePlists(appBundlePath) { + const appInfoPlistPath = join(appBundlePath, "Contents", "Info.plist"); + replacePlistString(appInfoPlistPath, "CFBundleDisplayName", macAppName); + replacePlistString(appInfoPlistPath, "CFBundleName", macAppName); + replacePlistString(appInfoPlistPath, "CFBundleIdentifier", macBundleIdentifier); + replacePlistString(appInfoPlistPath, "CFBundleIconFile", "icon.icns"); + + const frameworkPath = join(appBundlePath, "Contents", "Frameworks"); + const helperPlists = [ + { + path: join(frameworkPath, "Electron Helper.app", "Contents", "Info.plist"), + name: `${macAppName} Helper`, + identifier: `${macBundleIdentifier}.helper`, + }, + { + path: join(frameworkPath, "Electron Helper (Renderer).app", "Contents", "Info.plist"), + name: `${macAppName} Helper (Renderer)`, + identifier: `${macBundleIdentifier}.helper.renderer`, + }, + { + path: join(frameworkPath, "Electron Helper (GPU).app", "Contents", "Info.plist"), + name: `${macAppName} Helper (GPU)`, + identifier: `${macBundleIdentifier}.helper.gpu`, + }, + { + path: join(frameworkPath, "Electron Helper (Plugin).app", "Contents", "Info.plist"), + name: `${macAppName} Helper (Plugin)`, + identifier: `${macBundleIdentifier}.helper.plugin`, + }, + ]; + + for (const helper of helperPlists) { + if (!existsSync(helper.path)) { + continue; + } + replacePlistString(helper.path, "CFBundleName", helper.name); + replacePlistString(helper.path, "CFBundleIdentifier", helper.identifier); + } +} +function resolveBrandedMacElectronPath(electronExecutablePath) { + const sourceAppBundlePath = resolveMacSourceAppBundlePath(electronExecutablePath); const expectedMetadata = { - launcherVersion: LAUNCHER_VERSION, + launcherVersion: macRuntimeLauncherVersion, + appName: macAppName, + bundleIdentifier: macBundleIdentifier, sourceAppBundlePath, - sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, - iconMtimeMs: statSync(iconPath).mtimeMs, + sourceInfoFingerprint: statFingerprint(join(sourceAppBundlePath, "Contents", "Info.plist")), + sourceExecutableFingerprint: statFingerprint(electronExecutablePath), + iconFingerprint: existsSync(macRuntimeIconPath) ? statFingerprint(macRuntimeIconPath) : null, }; + const currentMetadata = readJsonFile(macRuntimeMetadataPath); - const currentMetadata = readJson(metadataPath); if ( - existsSync(targetBinaryPath) && - currentMetadata && + existsSync(macRuntimeExecutablePath) && JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { - return targetBinaryPath; + return macRuntimeExecutablePath; } - rmSync(targetAppBundlePath, { recursive: true, force: true }); - cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); - patchMainBundleInfoPlist(targetAppBundlePath, iconPath); - writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); + mkdirSync(macRuntimeDir, { recursive: true }); + rmSync(macRuntimeAppBundlePath, { recursive: true, force: true }); + cpSync(sourceAppBundlePath, macRuntimeAppBundlePath, { + recursive: true, + force: true, + verbatimSymlinks: true, + }); + + if (existsSync(macRuntimeIconPath)) { + copyFileSync( + macRuntimeIconPath, + join(macRuntimeAppBundlePath, "Contents", "Resources", "icon.icns"), + ); + } - return targetBinaryPath; + patchMacAppBundlePlists(macRuntimeAppBundlePath); + writeFileSync(macRuntimeMetadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); + return macRuntimeExecutablePath; } export function resolveElectronPath() { - const require = createRequire(import.meta.url); - const electronBinaryPath = require("electron"); - + const electronExecutablePath = resolveInstalledElectronPath(); if (process.platform !== "darwin") { - return electronBinaryPath; + return electronExecutablePath; } - - // Dev launches do not need a renamed app bundle badly enough to risk breaking - // Electron helper resource lookup on macOS. - if (isDevelopment) { - return electronBinaryPath; - } - - return buildMacLauncher(electronBinaryPath); + return resolveBrandedMacElectronPath(electronExecutablePath); } diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 375dbfe5..0fd8dd6a 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -5,7 +5,7 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], { +const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs", ...process.argv.slice(2)], { stdio: "inherit", cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index cbc7dcef..b0c48505 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,5 +1,6 @@ import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Random from "effect/Random"; @@ -21,10 +22,12 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import * as DesktopDebugServer from "../debug/DesktopDebugServer.ts"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; +const DESKTOP_SHUTDOWN_BACKEND_STOP_TIMEOUT = Duration.seconds(5); const makeDesktopRunId = Random.nextUUIDv4.pipe( Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), @@ -136,6 +139,7 @@ const bootstrap = Effect.gen(function* () { const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* logBootstrapInfo("bootstrap start"); + yield* DesktopDebugServer.start; if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); @@ -229,7 +233,9 @@ const scopedProgram = Effect.scoped( const backendManager = yield* DesktopBackendManager.DesktopBackendManager; yield* Effect.addFinalizer(() => - backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), + backendManager + .stop({ timeout: DESKTOP_SHUTDOWN_BACKEND_STOP_TIMEOUT }) + .pipe(Effect.ensuring(shutdown.markComplete)), ); yield* startup; diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index ca03502b..3c2a0f1c 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -118,7 +118,7 @@ const withIdentity = ( Layer.provideMerge( FileSystem.layerNoop({ exists: (path) => - Effect.succeed(input.legacyPathExists === true && path.includes("T3 Code (Alpha)")), + Effect.succeed(input.legacyPathExists === true && path.includes("Cafe Code (Alpha)")), readFileString: () => Effect.succeed(input.packageJson ?? '{"cafeCodeCommitHash":"abcdef1234567890"}'), }), @@ -132,13 +132,13 @@ const withIdentity = ( }; describe("DesktopAppIdentity", () => { - it.effect("keeps using the legacy userData path when it already exists", () => + it.effect("uses the Cafe Code state directory for Electron userData", () => withIdentity( Effect.gen(function* () { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; const userDataPath = yield* identity.resolveUserDataPath; - assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)"); + assert.equal(userDataPath, "/Users/alice/.cafe-code/userdata"); }), { legacyPathExists: true }, ), @@ -156,7 +156,7 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - assert.deepEqual(calls.setName, ["Cafe Code (Alpha)"]); + assert.deepEqual(calls.setName, ["Cafe Code"]); assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "Cafe Code (Alpha)"); assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 4510c5a1..1df30fbd 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -82,22 +82,13 @@ const make = Effect.gen(function* () { return commitHash; }); - const resolveUserDataPath = Effect.gen(function* () { - const legacyPath = environment.path.join( - environment.appDataDirectory, - environment.legacyUserDataDirName, - ); - const legacyPathExists = yield* fileSystem - .exists(legacyPath) - .pipe(Effect.orElseSucceed(() => false)); - return legacyPathExists - ? legacyPath - : environment.path.join(environment.appDataDirectory, environment.userDataDirName); - }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); + const resolveUserDataPath = Effect.succeed(environment.stateDir).pipe( + Effect.withSpan("desktop.appIdentity.resolveUserDataPath"), + ); const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; - yield* electronApp.setName(environment.displayName); + yield* electronApp.setName(environment.branding.baseName); yield* electronApp.setAboutPanelOptions({ applicationName: environment.displayName, applicationVersion: environment.appVersion, diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 644ae53b..286fda34 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -65,6 +65,10 @@ describe("DesktopEnvironment", () => { assert.equal(environment.appRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); assert.equal(environment.backendCwd, "/repo"); + assert.equal( + environment.developmentDockIconPath, + "/repo/assets/app-icon/cafe-code-app-icon-1024.png", + ); assert.equal(environment.appUserModelId, "com.cafeai.cafecode.dev"); assert.equal(environment.linuxWmClass, "cafecode-dev"); assert.deepEqual( @@ -95,6 +99,15 @@ describe("DesktopEnvironment", () => { }), ); + it.effect("defaults Cafe Code state to ~/.cafe-code", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment(); + + assert.equal(environment.baseDir, "/Users/alice/.cafe-code"); + assert.equal(environment.stateDir, "/Users/alice/.cafe-code/userdata"); + }), + ); + it.effect("resolves picker defaults without nullish sentinels", () => Effect.gen(function* () { const environment = yield* makeEnvironment(); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 6eb19636..2ae4cf19 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -7,7 +7,6 @@ import type { import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -138,13 +137,8 @@ function resolveDesktopRuntimeInfo(input: { const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( input: MakeDesktopEnvironmentInput, -): Effect.fn.Return< - DesktopEnvironmentShape, - Config.ConfigError, - Path.Path | FileSystem.FileSystem -> { +): Effect.fn.Return { const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; const config = yield* DesktopConfig.DesktopConfig; const homeDirectory = input.homeDirectory; const devServerUrl = config.devServerUrl; @@ -159,18 +153,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); const baseDir = yield* Option.match(config.cafeCodeHome, { onSome: (value) => Effect.succeed(value), - onNone: () => - Effect.gen(function* () { - const cafeHome = path.join(homeDirectory, ".cafecode"); - const legacyHome = path.join(homeDirectory, ".t3"); - if (yield* fileSystem.exists(cafeHome).pipe(Effect.orElseSucceed(() => false))) { - return cafeHome; - } - if (yield* fileSystem.exists(legacyHome).pipe(Effect.orElseSucceed(() => false))) { - return legacyHome; - } - return cafeHome; - }), + onNone: () => Effect.succeed(path.join(homeDirectory, ".cafe-code")), }); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; @@ -181,7 +164,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( const displayName = branding.displayName; const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata"); const userDataDirName = isDevelopment ? "cafecode-dev" : "cafecode"; - const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const legacyUserDataDirName = isDevelopment ? "Cafe Code (Dev)" : "Cafe Code (Alpha)"; const resourcesPath = input.resourcesPath; return DesktopEnvironment.of({ @@ -261,7 +244,12 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( path.join(resourcesPath, "resources", fileName), path.join(resourcesPath, fileName), ], - developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"), + developmentDockIconPath: path.join( + rootDir, + "assets", + "app-icon", + "cafe-code-app-icon-1024.png", + ), }); }); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts new file mode 100644 index 00000000..70a9d3df --- /dev/null +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -0,0 +1,204 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; + +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as IpcChannels from "../ipc/channels.ts"; + +type BeforeQuitListener = (event: Electron.Event) => void; + +function makeEvent() { + let preventDefaultCount = 0; + return { + event: { + preventDefault: () => { + preventDefaultCount += 1; + }, + } as Electron.Event, + preventDefaultCount: () => preventDefaultCount, + }; +} + +const flushMicrotasks = Effect.gen(function* () { + yield* Effect.yieldNow; + yield* Effect.yieldNow; +}); + +function makeLifecycleHarness() { + let beforeQuitListener: BeforeQuitListener | undefined; + let quitCount = 0; + let shutdownOverlayCount = 0; + + return Effect.gen(function* () { + const overlaySent = yield* Deferred.make(); + + const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata"), + name: Effect.succeed("Cafe Code"), + whenReady: Effect.void, + quit: Effect.sync(() => { + quitCount += 1; + }), + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + on: (eventName, listener) => + Effect.sync(() => { + if (eventName === "before-quit") { + beforeQuitListener = listener as unknown as BeforeQuitListener; + } + }), + } satisfies ElectronApp.ElectronAppShape); + + const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { + create: () => Effect.die("unexpected create"), + main: Effect.succeed(Option.none()), + currentMainOrFirst: Effect.succeed(Option.none()), + focusedMainOrFirst: Effect.succeed(Option.none()), + setMain: () => Effect.void, + clearMain: () => Effect.void, + reveal: () => Effect.void, + sendAll: (channel, action) => + Effect.gen(function* () { + if ( + channel === IpcChannels.MENU_ACTION_CHANNEL && + action === "desktop-shutdown-started" + ) { + shutdownOverlayCount += 1; + yield* Deferred.succeed(overlaySent, void 0); + } + }), + destroyAll: Effect.void, + syncAllAppearance: () => Effect.void, + } satisfies ElectronWindow.ElectronWindowShape); + + const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, + } satisfies ElectronTheme.ElectronThemeShape); + + const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + handleBackendReady: Effect.void, + dispatchMenuAction: () => Effect.void, + syncAppearance: Effect.void, + } satisfies DesktopWindow.DesktopWindowShape); + + const desktopEnvironmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "darwin", + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironmentShape); + + const layer = Layer.mergeAll( + DesktopLifecycle.layer, + DesktopLifecycle.layerShutdown, + DesktopState.layer, + desktopEnvironmentLayer, + desktopWindowLayer, + electronAppLayer, + electronThemeLayer, + electronWindowLayer, + TestClock.layer(), + ); + + return { + layer, + overlaySent, + getBeforeQuitListener: () => beforeQuitListener, + getQuitCount: () => quitCount, + getShutdownOverlayCount: () => shutdownOverlayCount, + }; + }); +} + +describe("DesktopLifecycle", () => { + it.effect("keeps the shutdown overlay visible for the minimum dwell before quitting", () => + Effect.gen(function* () { + const harness = yield* makeLifecycleHarness(); + + yield* Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const shutdown = yield* DesktopLifecycle.DesktopShutdown; + yield* lifecycle.register; + + const beforeQuit = harness.getBeforeQuitListener(); + assert.isDefined(beforeQuit); + if (!beforeQuit) { + throw new Error("before-quit listener was not registered."); + } + const quitEvent = makeEvent(); + beforeQuit(quitEvent.event); + + yield* Deferred.await(harness.overlaySent); + assert.equal(quitEvent.preventDefaultCount(), 1); + assert.equal(harness.getShutdownOverlayCount(), 1); + + yield* shutdown.markComplete; + yield* TestClock.adjust(Duration.millis(2_999)); + yield* flushMicrotasks; + assert.equal(harness.getQuitCount(), 0); + + yield* TestClock.adjust(Duration.millis(1)); + yield* flushMicrotasks; + assert.equal(harness.getQuitCount(), 1); + }).pipe(Effect.scoped, Effect.provide(harness.layer)); + }), + ); + + it.effect("ignores duplicate quit requests while shutdown is already in progress", () => + Effect.gen(function* () { + const harness = yield* makeLifecycleHarness(); + + yield* Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const shutdown = yield* DesktopLifecycle.DesktopShutdown; + yield* lifecycle.register; + + const beforeQuit = harness.getBeforeQuitListener(); + assert.isDefined(beforeQuit); + if (!beforeQuit) { + throw new Error("before-quit listener was not registered."); + } + const firstEvent = makeEvent(); + const secondEvent = makeEvent(); + + beforeQuit(firstEvent.event); + beforeQuit(secondEvent.event); + + yield* Deferred.await(harness.overlaySent); + yield* flushMicrotasks; + assert.equal(firstEvent.preventDefaultCount(), 1); + assert.equal(secondEvent.preventDefaultCount(), 1); + assert.equal(harness.getShutdownOverlayCount(), 1); + + yield* shutdown.markComplete; + yield* TestClock.adjust(DesktopLifecycle.DESKTOP_SHUTDOWN_OVERLAY_MINIMUM_DWELL); + yield* flushMicrotasks; + assert.equal(harness.getQuitCount(), 1); + }).pipe(Effect.scoped, Effect.provide(harness.layer)); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index adf2a646..33eee060 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -1,5 +1,6 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Layer from "effect/Layer"; @@ -11,9 +12,11 @@ import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as IpcChannels from "../ipc/channels.ts"; export interface DesktopShutdownShape { readonly request: Effect.Effect; @@ -52,7 +55,8 @@ export type DesktopLifecycleRuntimeServices = | DesktopState.DesktopState | DesktopWindow.DesktopWindow | ElectronApp.ElectronApp - | ElectronTheme.ElectronTheme; + | ElectronTheme.ElectronTheme + | ElectronWindow.ElectronWindow; export interface DesktopLifecycleShape { readonly relaunch: ( @@ -68,6 +72,8 @@ export class DesktopLifecycle extends Context.Service>( target: unknown, eventName: string, @@ -97,10 +103,24 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo }, ); +const requestDesktopShutdownAndWaitAfterOverlay = Effect.fn( + "desktop.lifecycle.requestShutdownAndWaitAfterOverlay", +)(function* (): Effect.fn.Return { + const shutdown = yield* DesktopShutdown; + yield* shutdown.request; + yield* Effect.all( + [shutdown.awaitComplete, Effect.sleep(DESKTOP_SHUTDOWN_OVERLAY_MINIMUM_DWELL)], + { + concurrency: "unbounded", + }, + ).pipe(Effect.asVoid); +}); + function handleBeforeQuit( event: Electron.Event, runEffect: (effect: Effect.Effect) => Promise, allowQuit: () => boolean, + beginShutdown: () => boolean, markQuitAllowed: () => void, ): void { if (allowQuit()) { @@ -115,12 +135,18 @@ function handleBeforeQuit( } event.preventDefault(); + if (!beginShutdown()) { + return; + } + void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; + const electronWindow = yield* ElectronWindow.ElectronWindow; yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); - yield* requestDesktopShutdownAndWait(); + yield* electronWindow.sendAll(IpcChannels.MENU_ACTION_CHANNEL, "desktop-shutdown-started"); + yield* requestDesktopShutdownAndWaitAfterOverlay(); }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), ).finally(() => { markQuitAllowed(); @@ -190,6 +216,7 @@ export const layer = Layer.succeed( const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); let quitAllowed = false; + let shutdownInProgress = false; yield* electronTheme.onUpdated(() => { void runEffect( desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), @@ -200,6 +227,13 @@ export const layer = Layer.succeed( event, runEffect, () => quitAllowed, + () => { + if (shutdownInProgress) { + return false; + } + shutdownInProgress = true; + return true; + }, () => { quitAllowed = true; }, diff --git a/apps/desktop/src/app/DesktopPowerSaveBlocker.test.ts b/apps/desktop/src/app/DesktopPowerSaveBlocker.test.ts new file mode 100644 index 00000000..d5cbf01c --- /dev/null +++ b/apps/desktop/src/app/DesktopPowerSaveBlocker.test.ts @@ -0,0 +1,110 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import * as ElectronPowerSaveBlocker from "../electron/ElectronPowerSaveBlocker.ts"; +import * as DesktopPowerSaveBlocker from "./DesktopPowerSaveBlocker.ts"; + +interface FakePowerSaveBlocker { + readonly starts: Ref.Ref; + readonly stops: Ref.Ref; + readonly activeIds: Ref.Ref>; + readonly layer: Layer.Layer; +} + +const makeFakePowerSaveBlocker = Effect.gen(function* () { + const starts = yield* Ref.make( + [], + ); + const stops = yield* Ref.make([]); + const activeIds = yield* Ref.make>(new Set()); + const nextId = yield* Ref.make(1); + + const layer = Layer.succeed(ElectronPowerSaveBlocker.ElectronPowerSaveBlocker, { + start: (type) => + Effect.gen(function* () { + const id = yield* Ref.getAndUpdate(nextId, (value) => value + 1); + yield* Ref.update(starts, (values) => [...values, type]); + yield* Ref.update(activeIds, (values) => new Set(values).add(id)); + return id; + }), + stop: (id) => + Effect.gen(function* () { + yield* Ref.update(stops, (values) => [...values, id]); + yield* Ref.update(activeIds, (values) => { + const next = new Set(values); + next.delete(id); + return next; + }); + }), + isStarted: (id) => Ref.get(activeIds).pipe(Effect.map((values) => values.has(id))), + } satisfies ElectronPowerSaveBlocker.ElectronPowerSaveBlockerShape); + + return { + starts, + stops, + activeIds, + layer, + }; +}); + +const makeLayer = (fake: FakePowerSaveBlocker) => + DesktopPowerSaveBlocker.layer.pipe(Layer.provide(fake.layer)); + +describe("DesktopPowerSaveBlocker", () => { + it.effect("keeps the blocker off by default and starts it for always mode", () => + Effect.gen(function* () { + const fake = yield* makeFakePowerSaveBlocker; + + yield* Effect.gen(function* () { + const blocker = yield* DesktopPowerSaveBlocker.DesktopPowerSaveBlocker; + + assert.deepStrictEqual(yield* Ref.get(fake.starts), []); + + yield* blocker.update({ mode: "always", chatsRunning: false }); + assert.deepStrictEqual(yield* Ref.get(fake.starts), ["prevent-display-sleep"]); + assert.strictEqual((yield* blocker.snapshot).active, true); + + yield* blocker.update({ mode: "always", chatsRunning: true }); + assert.deepStrictEqual(yield* Ref.get(fake.starts), ["prevent-display-sleep"]); + }).pipe(Effect.provide(makeLayer(fake))); + }), + ); + + it.effect("tracks running chats when configured for during-chats mode", () => + Effect.gen(function* () { + const fake = yield* makeFakePowerSaveBlocker; + + yield* Effect.gen(function* () { + const blocker = yield* DesktopPowerSaveBlocker.DesktopPowerSaveBlocker; + + yield* blocker.update({ mode: "during-chats", chatsRunning: false }); + assert.deepStrictEqual(yield* Ref.get(fake.starts), []); + + yield* blocker.update({ mode: "during-chats", chatsRunning: true }); + assert.deepStrictEqual(yield* Ref.get(fake.starts), ["prevent-display-sleep"]); + assert.strictEqual((yield* blocker.snapshot).active, true); + + yield* blocker.update({ mode: "during-chats", chatsRunning: false }); + assert.deepStrictEqual(yield* Ref.get(fake.stops), [1]); + assert.strictEqual((yield* blocker.snapshot).active, false); + }).pipe(Effect.provide(makeLayer(fake))); + }), + ); + + it.effect("stops the active blocker when disabled", () => + Effect.gen(function* () { + const fake = yield* makeFakePowerSaveBlocker; + + yield* Effect.gen(function* () { + const blocker = yield* DesktopPowerSaveBlocker.DesktopPowerSaveBlocker; + yield* blocker.update({ mode: "always", chatsRunning: false }); + yield* blocker.update({ mode: "off", chatsRunning: true }); + + assert.deepStrictEqual(yield* Ref.get(fake.stops), [1]); + assert.deepStrictEqual([...(yield* Ref.get(fake.activeIds))], []); + }).pipe(Effect.provide(makeLayer(fake))); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopPowerSaveBlocker.ts b/apps/desktop/src/app/DesktopPowerSaveBlocker.ts new file mode 100644 index 00000000..6d1f694a --- /dev/null +++ b/apps/desktop/src/app/DesktopPowerSaveBlocker.ts @@ -0,0 +1,114 @@ +import type { DesktopPowerSaveBlockerState } from "@cafecode/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; + +import * as ElectronPowerSaveBlocker from "../electron/ElectronPowerSaveBlocker.ts"; + +const INITIAL_STATE: DesktopPowerSaveBlockerState = { + mode: "off", + chatsRunning: false, +}; + +export interface DesktopPowerSaveBlockerSnapshot { + readonly desiredState: DesktopPowerSaveBlockerState; + readonly blockerId: number | null; + readonly active: boolean; +} + +export interface DesktopPowerSaveBlockerShape { + readonly update: (state: DesktopPowerSaveBlockerState) => Effect.Effect; + readonly snapshot: Effect.Effect; +} + +export class DesktopPowerSaveBlocker extends Context.Service< + DesktopPowerSaveBlocker, + DesktopPowerSaveBlockerShape +>()("cafecode/desktop/app/PowerSaveBlocker") {} + +function shouldBlockSleep(state: DesktopPowerSaveBlockerState): boolean { + return state.mode === "always" || (state.mode === "during-chats" && state.chatsRunning); +} + +const make = Effect.fn("desktop.powerSaveBlocker.make")(function* () { + const powerSaveBlocker = yield* ElectronPowerSaveBlocker.ElectronPowerSaveBlocker; + const desiredStateRef = yield* Ref.make(INITIAL_STATE); + const blockerIdRef = yield* Ref.make(null); + const mutex = yield* Semaphore.make(1); + + const stopActiveBlocker = Effect.fn("desktop.powerSaveBlocker.stopActive")(function* () { + const blockerId = yield* Ref.getAndSet(blockerIdRef, null); + if (blockerId === null) { + return; + } + + const started = yield* powerSaveBlocker.isStarted(blockerId); + if (started) { + yield* powerSaveBlocker.stop(blockerId); + } + }); + + const startBlockerIfNeeded = Effect.fn("desktop.powerSaveBlocker.startIfNeeded")(function* () { + const currentBlockerId = yield* Ref.get(blockerIdRef); + if (currentBlockerId !== null) { + const currentStarted = yield* powerSaveBlocker.isStarted(currentBlockerId); + if (currentStarted) { + return; + } + yield* Ref.set(blockerIdRef, null); + } + + const nextBlockerId = yield* powerSaveBlocker.start("prevent-display-sleep"); + yield* Ref.set(blockerIdRef, nextBlockerId); + }); + + const applyDesiredState = Effect.fn("desktop.powerSaveBlocker.apply")(function* ( + state: DesktopPowerSaveBlockerState, + ) { + yield* Ref.set(desiredStateRef, state); + + if (shouldBlockSleep(state)) { + yield* startBlockerIfNeeded(); + return; + } + + yield* stopActiveBlocker(); + }); + + const update = (state: DesktopPowerSaveBlockerState) => + mutex.withPermit(applyDesiredState(state)).pipe( + Effect.asVoid, + Effect.withSpan("desktop.powerSaveBlocker.update", { + attributes: { + mode: state.mode, + chatsRunning: state.chatsRunning, + }, + }), + ); + + const snapshot = mutex.withPermit( + Effect.gen(function* () { + const [desiredState, blockerId] = yield* Effect.all([ + Ref.get(desiredStateRef), + Ref.get(blockerIdRef), + ]); + const active = blockerId === null ? false : yield* powerSaveBlocker.isStarted(blockerId); + return { + desiredState, + blockerId, + active, + }; + }), + ); + + yield* Effect.addFinalizer(() => mutex.withPermit(stopActiveBlocker()).pipe(Effect.ignore)); + + return DesktopPowerSaveBlocker.of({ + update, + snapshot, + }); +}); + +export const layer = Layer.effect(DesktopPowerSaveBlocker, make()); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 134fd75c..0ad8de6b 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -6,8 +6,10 @@ import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -349,6 +351,72 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("clears backend state and logs when process close times out during stop", () => + Effect.gen(function* () { + const messages: string[] = []; + const logger = Logger.make(({ message }) => { + messages.push(String(message)); + }); + const started = yield* Deferred.make(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const closed = yield* Deferred.make(); + const delayedClose = Effect.sleep(Duration.seconds(5)).pipe( + Effect.andThen(Deferred.succeed(closed, void 0)), + Effect.asVoid, + ); + yield* Scope.addFinalizer(scope, delayedClose); + yield* Deferred.succeed(started, void 0); + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + kill: () => Deferred.succeed(closed, void 0).pipe(Effect.asVoid), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ spawnerLayer }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + yield* Deferred.await(started); + + const stopFiber = yield* Effect.forkDetach(manager.stop({ timeout: Duration.seconds(1) })); + yield* Effect.yieldNow; + + const stoppingSnapshot = yield* manager.snapshot; + assert.equal(stoppingSnapshot.desiredRunning, false); + assert.equal(stoppingSnapshot.ready, false); + assert.equal(Option.isNone(stoppingSnapshot.activePid), true); + + yield* TestClock.adjust(Duration.millis(999)); + yield* Effect.yieldNow; + assert.isFalse(messages.some((message) => message.includes("backend close timed out"))); + + yield* TestClock.adjust(Duration.millis(1)); + yield* Fiber.join(stopFiber); + + assert.isTrue( + messages.some((message) => message.includes("backend close timed out during stop")), + ); + yield* TestClock.adjust(Duration.seconds(5)); + }).pipe( + Effect.provide( + Layer.mergeAll( + TestClock.layer(), + managerLayer, + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }), + ); + it.effect("restarts an unexpectedly exited backend with the Effect clock", () => Effect.gen(function* () { const starts = yield* Queue.unbounded(); @@ -392,6 +460,103 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("restarts a backend that never becomes ready", () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + let startCount = 0; + let killCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + startCount += 1; + yield* Queue.offer(starts, startCount); + const killed = yield* Deferred.make(); + const kill = Effect.sync(() => { + killCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(killed, void 0)), Effect.asVoid); + return makeProcess({ + exitCode: Deferred.await(killed).pipe(Effect.as(ChildProcessSpawner.ExitCode(1))), + kill: () => kill, + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + assert.equal(yield* Queue.take(starts), 1); + + yield* TestClock.adjust(Duration.seconds(61)); + yield* Effect.yieldNow; + assert.equal(killCount > 0, true); + + yield* TestClock.adjust(Duration.millis(500)); + assert.equal(yield* Queue.take(starts), 2); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect("restarts a ready backend after repeated health check failures", () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const firstReady = yield* Deferred.make(); + let startCount = 0; + let killCount = 0; + let healthy = true; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + startCount += 1; + yield* Queue.offer(starts, startCount); + const killed = yield* Deferred.make(); + const kill = Effect.sync(() => { + killCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(killed, void 0)), Effect.asVoid); + return makeProcess({ + exitCode: Deferred.await(killed).pipe(Effect.as(ChildProcessSpawner.ExitCode(1))), + kill: () => kill, + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer((request) => + Effect.succeed(responseForRequest(request, healthy ? 200 : 503)), + ), + desktopWindow: { + handleBackendReady: Deferred.succeed(firstReady, void 0).pipe(Effect.asVoid), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + assert.equal(yield* Queue.take(starts), 1); + yield* Deferred.await(firstReady); + + healthy = false; + yield* TestClock.adjust(Duration.seconds(45)); + yield* Effect.yieldNow; + assert.equal(killCount > 0, true); + + yield* TestClock.adjust(Duration.millis(500)); + assert.equal(yield* Queue.take(starts), 2); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + it.effect("cancels a scheduled restart when start is requested manually", () => Effect.gen(function* () { const starts = yield* Queue.unbounded(); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 847676b2..993ab1ab 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -11,7 +11,6 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; @@ -36,6 +35,8 @@ const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); +const DEFAULT_BACKEND_HEALTH_CHECK_INTERVAL = Duration.seconds(15); +const DEFAULT_BACKEND_HEALTH_FAILURE_THRESHOLD = 3; const BACKEND_READINESS_PATH = CAFE_CODE_ENVIRONMENT_ENDPOINT_PATH; type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient; @@ -86,13 +87,25 @@ class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnErro } } +class BackendHealthCheckFailedError extends Data.TaggedError("BackendHealthCheckFailedError")<{ + readonly url: URL; + readonly consecutiveFailures: number; +}> { + override get message() { + return `Backend health check failed ${this.consecutiveFailures} consecutive times at ${this.url.href}.`; + } +} + type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly readinessTimeout?: Duration.Duration; + readonly healthCheckInterval?: Duration.Duration; + readonly healthFailureThreshold?: number; readonly onStarted?: (pid: number) => Effect.Effect; readonly onReady?: () => Effect.Effect; readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; + readonly onHealthFailure?: (error: BackendHealthCheckFailedError) => Effect.Effect; readonly onOutput?: ( streamName: BackendProcessOutputStream, chunk: Uint8Array, @@ -165,36 +178,98 @@ const calculateRestartDelay = (attempt: number): Duration.Duration => const closeRun = ( run: ActiveBackendRun, options?: { readonly timeout?: Duration.Duration }, -): Effect.Effect => { +): Effect.Effect<"closed" | "timed-out"> => { const waitForFiber = Option.match(run.fiber, { onNone: () => Effect.void, onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), }); const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); - return ( - options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close - ).pipe(Effect.ignore); + const timeout = options?.timeout; + if (!timeout) { + return close.pipe(Effect.as("closed" as const)); + } + + return Effect.gen(function* () { + const closeFiber = yield* Effect.forkDetach(close); + return yield* Effect.race( + Fiber.await(closeFiber).pipe(Effect.as("closed" as const)), + Effect.sleep(timeout).pipe(Effect.as("timed-out" as const)), + ); + }); }; -const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( +const readinessUrlFor = (baseUrl: URL): URL => new URL(BACKEND_READINESS_PATH, baseUrl); + +const checkHttpReadyOnce = Effect.fn("desktop.backendManager.checkHttpReadyOnce")(function* ( baseUrl: URL, - timeout: Duration.Duration, -): Effect.fn.Return { - const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); +): Effect.fn.Return { + const readinessUrl = readinessUrlFor(baseUrl); const client = (yield* HttpClient.HttpClient).pipe( HttpClient.filterStatusOk, HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), - HttpClient.retry(Schedule.spaced(DEFAULT_BACKEND_READINESS_INTERVAL)), ); - yield* client.get(readinessUrl).pipe( - Effect.asVoid, + return yield* client.get(readinessUrl).pipe( + Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT), + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ); +}); + +const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( + baseUrl: URL, + timeout: Duration.Duration, +): Effect.fn.Return { + const readinessUrl = readinessUrlFor(baseUrl); + yield* Effect.gen(function* () { + while (true) { + if (yield* checkHttpReadyOnce(baseUrl)) { + return; + } + yield* Effect.sleep(DEFAULT_BACKEND_READINESS_INTERVAL); + } + }).pipe( Effect.timeout(timeout), Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })), ); }); +const monitorHttpHealth = Effect.fn("desktop.backendManager.monitorHttpHealth")(function* ( + baseUrl: URL, + interval: Duration.Duration, + failureThreshold: number, + onFailure: (error: BackendHealthCheckFailedError) => Effect.Effect, +): Effect.fn.Return { + const readinessUrl = readinessUrlFor(baseUrl); + let consecutiveFailures = 0; + + while (true) { + yield* Effect.sleep(interval); + const healthy = yield* checkHttpReadyOnce(baseUrl); + if (healthy) { + consecutiveFailures = 0; + continue; + } + + consecutiveFailures += 1; + yield* Effect.logWarning("desktop.backend.health-check.failed", { + url: readinessUrl.href, + consecutiveFailures, + failureThreshold, + }); + if (consecutiveFailures >= failureThreshold) { + yield* onFailure( + new BackendHealthCheckFailedError({ + url: readinessUrl, + consecutiveFailures, + }), + ).pipe(Effect.ignore); + return; + } + } +}); + function describeProcessExit( result: Result.Result, ): BackendProcessExit { @@ -230,6 +305,7 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( options: RunBackendProcessOptions, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const processScope = yield* Scope.Scope; const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })), ); @@ -266,12 +342,28 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); } + const healthFailureThreshold = Math.max( + 1, + Math.trunc(options.healthFailureThreshold ?? DEFAULT_BACKEND_HEALTH_FAILURE_THRESHOLD), + ); + const healthCheckInterval = options.healthCheckInterval ?? DEFAULT_BACKEND_HEALTH_CHECK_INTERVAL; yield* waitForHttpReady( options.httpBaseUrl, options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, ).pipe( Effect.tap(() => options.onReady?.() ?? Effect.void), - Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), + Effect.tap(() => + monitorHttpHealth(options.httpBaseUrl, healthCheckInterval, healthFailureThreshold, (error) => + (options.onHealthFailure?.(error) ?? Effect.void).pipe( + Effect.andThen(handle.kill().pipe(Effect.ignore)), + ), + ).pipe(Effect.forkIn(processScope)), + ), + Effect.catch((error) => + (options.onReadinessFailure?.(error) ?? Effect.void).pipe( + Effect.andThen(handle.kill().pipe(Effect.ignore)), + ), + ), Effect.forkScoped, ); @@ -470,6 +562,10 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio logBackendManagerWarning("backend readiness check failed during bootstrap", { error: error.message, }), + onHealthFailure: (error) => + logBackendManagerWarning("backend health check failed; terminating backend", { + error: error.message, + }), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -580,7 +676,19 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }); yield* Option.match(active, { onNone: () => Effect.void, - onSome: (run) => closeRun(run, options), + onSome: (run) => + Effect.gen(function* () { + const result = yield* closeRun(run, options); + if (result !== "timed-out" || !options?.timeout) { + return; + } + + yield* logBackendManagerWarning("backend close timed out during stop", { + runId: run.id, + timeoutMs: Duration.toMillis(options.timeout), + ...(Option.isSome(run.pid) ? { pid: run.pid.value } : {}), + }); + }), }); }); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 7cc8e5a7..2d81ccee 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -96,6 +96,31 @@ describe("tailscale endpoint provider", () => { }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), ); + it.effect("does not spawn tailscale status while Tailscale HTTPS is disabled", () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: { + tailscale0: [ + { + address: "100.100.100.100", + family: "IPv4", + internal: false, + netmask: "255.192.0.0", + cidr: "100.100.100.100/10", + mac: "00:00:00:00:00:00", + }, + ], + }, + serveEnabled: false, + }); + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://100.100.100.100:3773/"], + ); + }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), + ); + it.effect( "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", () => diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 9885a225..815bf553 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -111,12 +111,15 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient > { const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); + const shouldReadMagicDnsStatus = input.statusJson !== undefined || input.serveEnabled === true; const dnsName = input.statusJson === undefined - ? yield* readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.catch(() => Effect.succeed(null)), - ) + ? shouldReadMagicDnsStatus + ? yield* readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.catch(() => Effect.succeed(null)), + ) + : null : input.statusJson ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( Effect.catch(() => Effect.succeed(null)), diff --git a/apps/desktop/src/debug/DesktopDebugServer.ts b/apps/desktop/src/debug/DesktopDebugServer.ts new file mode 100644 index 00000000..db93f47a --- /dev/null +++ b/apps/desktop/src/debug/DesktopDebugServer.ts @@ -0,0 +1,296 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalDate:off +// @effect-diagnostics globalDateInEffect:off +// @effect-diagnostics globalConsoleInEffect:off +import type { DesktopDebugEndpointState, DesktopRendererDebugSnapshot } from "@cafecode/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; +import * as NodeHttp from "node:http"; +import type * as NodeNet from "node:net"; + +const DEBUG_HOST = "127.0.0.1"; +const DEBUG_PATH = "/debug"; +const DEBUG_SWITCHES = new Set(["--cafe-debug", "--debug"]); +const RENDERER_SNAPSHOT_HISTORY_LIMIT = 50; + +interface RendererSnapshotHistoryEntry { + readonly receivedAt: string; + readonly capturedAt: string | null; + readonly source: string | null; + readonly activeThreadId: string | null; + readonly sessionStatus: string | null; + readonly activeTurnId: string | null; + readonly latestTurnState: string | null; + readonly latestTurnSettled: boolean | null; + readonly queueLength: number | null; + readonly queueBlockers: readonly string[]; + readonly phase: string | null; + readonly followUpQueuePhase: string | null; + readonly activeTurnInProgress: boolean | null; + readonly uiWorking: boolean | null; + readonly lifecycleRedFlags: readonly string[]; + readonly queueLifecycleRedFlags: readonly string[]; +} + +interface DebugServerRuntimeState { + readonly enabled: boolean; + readonly launchedAt: string; + startedAt: string | null; + url: string | null; + server: NodeHttp.Server | null; + requestsServed: number; + rendererSnapshot: DesktopRendererDebugSnapshot | null; + rendererSnapshotUpdatedAt: string | null; + rendererSnapshotHistory: RendererSnapshotHistoryEntry[]; +} + +class DesktopDebugServerStartError extends Data.TaggedError("DesktopDebugServerStartError")<{ + readonly cause: unknown; +}> { + override get message() { + return this.cause instanceof Error + ? this.cause.message + : "Cafe Code debug server failed to start."; + } +} + +export function isDesktopDebugModeEnabled(argv: readonly string[] = process.argv): boolean { + return argv.some((arg) => DEBUG_SWITCHES.has(arg)); +} + +const state: DebugServerRuntimeState = { + enabled: isDesktopDebugModeEnabled(), + launchedAt: new Date().toISOString(), + startedAt: null, + url: null, + server: null, + requestsServed: 0, + rendererSnapshot: null, + rendererSnapshotUpdatedAt: null, + rendererSnapshotHistory: [], +}; + +function isAddressInfo( + address: NodeNet.AddressInfo | string | null, +): address is NodeNet.AddressInfo { + return typeof address === "object" && address !== null && typeof address.port === "number"; +} + +function writeJson(response: NodeHttp.ServerResponse, statusCode: number, body: unknown): void { + response.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }); + response.end(`${JSON.stringify(body, null, 2)}\n`); +} + +function readRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function readNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function readBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function readStringArray(value: unknown): readonly string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function buildRendererSnapshotHistoryEntry( + snapshot: DesktopRendererDebugSnapshot, + receivedAt: string, +): RendererSnapshotHistoryEntry { + const route = readRecord(snapshot.route); + const thread = readRecord(snapshot.thread); + const session = readRecord(thread?.session); + const latestTurn = readRecord(thread?.latestTurn); + const queue = readRecord(snapshot.queue); + const gates = readRecord(snapshot.gates); + const lifecycle = readRecord(snapshot.lifecycle); + const activeLifecycle = readRecord(lifecycle?.active); + const queueCoupling = readRecord(lifecycle?.queueCoupling); + + return { + receivedAt, + capturedAt: readString(snapshot.capturedAt), + source: readString(snapshot.source), + activeThreadId: readString(route?.activeThreadId), + sessionStatus: readString(session?.status), + activeTurnId: readString(session?.activeTurnId), + latestTurnState: readString(latestTurn?.state), + latestTurnSettled: readBoolean(activeLifecycle?.latestTurnSettled), + queueLength: readNumber(queue?.length), + queueBlockers: readStringArray(queue?.blockers), + phase: readString(gates?.phase), + followUpQueuePhase: readString(gates?.followUpQueuePhase), + activeTurnInProgress: readBoolean(queueCoupling?.activeTurnInProgress), + uiWorking: readBoolean(queueCoupling?.uiWorking), + lifecycleRedFlags: readStringArray(activeLifecycle?.redFlags), + queueLifecycleRedFlags: readStringArray(queueCoupling?.redFlags), + }; +} + +function buildDebugSnapshot(): Record { + const now = Date.now(); + const rendererSnapshotUpdatedAt = state.rendererSnapshotUpdatedAt; + const rendererSnapshotAgeMs = + rendererSnapshotUpdatedAt === null + ? null + : Math.max(0, now - Date.parse(rendererSnapshotUpdatedAt)); + + return { + schemaVersion: 1, + debug: { + enabled: state.enabled, + bindHost: DEBUG_HOST, + path: DEBUG_PATH, + url: state.url, + launchedAt: state.launchedAt, + startedAt: state.startedAt, + requestsServed: state.requestsServed, + rendererSnapshotUpdatedAt, + rendererSnapshotAgeMs, + rendererSnapshotHistoryLimit: RENDERER_SNAPSHOT_HISTORY_LIMIT, + }, + process: { + pid: process.pid, + ppid: process.ppid, + platform: process.platform, + arch: process.arch, + uptimeSeconds: process.uptime(), + cwd: process.cwd(), + execPath: process.execPath, + argv: process.argv.filter((arg) => DEBUG_SWITCHES.has(arg)), + memoryUsage: process.memoryUsage(), + resourceUsage: process.resourceUsage(), + versions: { + node: process.versions.node, + electron: process.versions.electron ?? null, + chrome: process.versions.chrome ?? null, + }, + }, + renderer: + state.rendererSnapshot === null + ? { + available: false, + reason: "No renderer snapshot has been published yet.", + history: state.rendererSnapshotHistory, + } + : { + available: true, + snapshot: state.rendererSnapshot, + history: state.rendererSnapshotHistory, + }, + }; +} + +function handleRequest(request: NodeHttp.IncomingMessage, response: NodeHttp.ServerResponse): void { + const method = request.method ?? "GET"; + if (method !== "GET") { + response.writeHead(405, { + allow: "GET", + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }); + response.end(); + return; + } + + const url = new URL(request.url ?? "/", `http://${DEBUG_HOST}`); + if (url.pathname !== DEBUG_PATH) { + writeJson(response, 404, { + error: "not_found", + debugPath: DEBUG_PATH, + }); + return; + } + + state.requestsServed += 1; + writeJson(response, 200, buildDebugSnapshot()); +} + +export const getDebugEndpointState = Effect.sync( + (): DesktopDebugEndpointState => ({ + enabled: state.enabled, + url: state.url, + }), +); + +export const publishRendererDebugSnapshot = ( + snapshot: DesktopRendererDebugSnapshot, +): Effect.Effect => + Effect.sync(() => { + if (!state.enabled) { + return; + } + const receivedAt = new Date().toISOString(); + state.rendererSnapshot = snapshot; + state.rendererSnapshotUpdatedAt = receivedAt; + state.rendererSnapshotHistory = [ + ...state.rendererSnapshotHistory.slice(1 - RENDERER_SNAPSHOT_HISTORY_LIMIT), + buildRendererSnapshotHistoryEntry(snapshot, receivedAt), + ]; + }); + +const startUnsafe: Effect.Effect = Effect.gen( + function* () { + if (!state.enabled || state.server !== null) { + return; + } + + const server = NodeHttp.createServer(handleRequest); + const port = yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + const address = server.address(); + if (!isAddressInfo(address)) { + reject(new Error("Cafe Code debug server did not bind to a TCP address.")); + return; + } + resolve(address.port); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(0, DEBUG_HOST); + }), + catch: (cause) => new DesktopDebugServerStartError({ cause }), + }); + + state.server = server; + state.startedAt = new Date().toISOString(); + state.url = `http://${DEBUG_HOST}:${port}${DEBUG_PATH}`; + console.info(`[Cafe Code debug] ${state.url}`); + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + server.close(); + }), + ); + }, +); + +export const start: Effect.Effect = startUnsafe.pipe( + Effect.catch((error) => + Effect.logError("Cafe Code debug server failed to start", { cause: error.message }), + ), +); diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index a3fb2388..feee2a08 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -1,12 +1,14 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vitest"; +import desktopPackageJson from "../../package.json" with { type: "json" }; const { appendSwitchMock, exitMock, getAppPathMock, getVersionMock, + isPackagedState, onMock, quitMock, relaunchMock, @@ -23,6 +25,7 @@ const { exitMock: vi.fn(), getAppPathMock: vi.fn(() => "/app"), getVersionMock: vi.fn(() => "1.2.3"), + isPackagedState: { current: true }, onMock: vi.fn(), quitMock: vi.fn(), relaunchMock: vi.fn(), @@ -46,7 +49,9 @@ vi.mock("electron", () => ({ }, getAppPath: getAppPathMock, getVersion: getVersionMock, - isPackaged: true, + get isPackaged() { + return isPackagedState.current; + }, name: "Cafe Code", on: onMock, quit: quitMock, @@ -74,6 +79,9 @@ describe("ElectronApp", () => { relaunchMock.mockClear(); removeListenerMock.mockClear(); setPathMock.mockClear(); + getVersionMock.mockClear(); + getAppPathMock.mockClear(); + isPackagedState.current = true; }); it.effect("reads app metadata through the service", () => @@ -106,4 +114,17 @@ describe("ElectronApp", () => { assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); }).pipe(Effect.provide(ElectronApp.layer)), ); + + it.effect("uses package metadata for unpackaged Electron starts", () => + Effect.gen(function* () { + isPackagedState.current = false; + + const electronApp = yield* ElectronApp.ElectronApp; + const metadata = yield* electronApp.metadata; + + assert.equal(metadata.appVersion, desktopPackageJson.version); + assert.isFalse(metadata.isPackaged); + assert.deepEqual(getVersionMock.mock.calls, []); + }).pipe(Effect.provide(ElectronApp.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 3a38559c..329fb874 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; import * as Electron from "electron"; +import desktopPackageJson from "../../package.json" with { type: "json" }; export interface ElectronAppMetadata { readonly appVersion: string; @@ -58,7 +59,7 @@ const addScopedAppListener = >( const make = ElectronApp.of({ metadata: Effect.sync(() => ({ - appVersion: Electron.app.getVersion(), + appVersion: Electron.app.isPackaged ? Electron.app.getVersion() : desktopPackageJson.version, appPath: Electron.app.getAppPath(), isPackaged: Electron.app.isPackaged, resourcesPath: process.resourcesPath, diff --git a/apps/desktop/src/electron/ElectronPowerSaveBlocker.ts b/apps/desktop/src/electron/ElectronPowerSaveBlocker.ts new file mode 100644 index 00000000..ad81eefd --- /dev/null +++ b/apps/desktop/src/electron/ElectronPowerSaveBlocker.ts @@ -0,0 +1,29 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; + +export type ElectronPowerSaveBlockerType = "prevent-app-suspension" | "prevent-display-sleep"; + +export interface ElectronPowerSaveBlockerShape { + readonly start: (type: ElectronPowerSaveBlockerType) => Effect.Effect; + readonly stop: (id: number) => Effect.Effect; + readonly isStarted: (id: number) => Effect.Effect; +} + +export class ElectronPowerSaveBlocker extends Context.Service< + ElectronPowerSaveBlocker, + ElectronPowerSaveBlockerShape +>()("cafecode/desktop/electron/PowerSaveBlocker") {} + +const make = ElectronPowerSaveBlocker.of({ + start: (type) => Effect.sync(() => Electron.powerSaveBlocker.start(type)), + stop: (id) => + Effect.sync(() => { + Electron.powerSaveBlocker.stop(id); + }), + isStarted: (id) => Effect.sync(() => Electron.powerSaveBlocker.isStarted(id)), +}); + +export const layer = Layer.succeed(ElectronPowerSaveBlocker, make); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index fa07fdfd..00111d31 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -22,6 +22,7 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { export interface ElectronShellShape { readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly openPath: (rawPath: unknown) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; } @@ -41,6 +42,15 @@ const make = ElectronShell.of({ ), ), }), + openPath: (rawPath) => + typeof rawPath !== "string" || rawPath.length === 0 || rawPath.includes("\0") + ? Effect.succeed(false) + : Effect.promise(() => + Electron.shell.openPath(rawPath).then( + (errorMessage) => errorMessage.length === 0, + () => false, + ), + ), copyText: (text) => Effect.sync(() => { Electron.clipboard.writeText(text); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts deleted file mode 100644 index eec21a9a..00000000 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import { beforeEach, vi } from "vitest"; - -const { autoUpdaterMock } = vi.hoisted(() => ({ - autoUpdaterMock: { - allowDowngrade: false, - allowPrerelease: false, - autoDownload: true, - autoInstallOnAppQuit: true, - channel: "latest", - disableDifferentialDownload: false, - checkForUpdates: vi.fn(() => Promise.resolve(null)), - downloadUpdate: vi.fn(() => Promise.resolve([])), - on: vi.fn(), - quitAndInstall: vi.fn(), - removeListener: vi.fn(), - setFeedURL: vi.fn(), - }, -})); - -vi.mock("electron-updater", () => ({ - autoUpdater: autoUpdaterMock, -})); - -import * as ElectronUpdater from "./ElectronUpdater.ts"; - -describe("ElectronUpdater", () => { - beforeEach(() => { - autoUpdaterMock.allowDowngrade = false; - autoUpdaterMock.allowPrerelease = false; - autoUpdaterMock.autoDownload = true; - autoUpdaterMock.autoInstallOnAppQuit = true; - autoUpdaterMock.channel = "latest"; - autoUpdaterMock.disableDifferentialDownload = false; - autoUpdaterMock.checkForUpdates.mockClear(); - autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); - autoUpdaterMock.downloadUpdate.mockClear(); - autoUpdaterMock.downloadUpdate.mockImplementation(() => Promise.resolve([])); - autoUpdaterMock.on.mockClear(); - autoUpdaterMock.quitAndInstall.mockClear(); - autoUpdaterMock.removeListener.mockClear(); - autoUpdaterMock.setFeedURL.mockClear(); - }); - - it.effect("scopes updater event listeners", () => - Effect.gen(function* () { - const listener = vi.fn(); - - yield* Effect.scoped( - Effect.gen(function* () { - const updater = yield* ElectronUpdater.ElectronUpdater; - yield* updater.on("update-available", listener); - }), - ); - - assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]); - assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("wraps rejected update checks in the method-specific typed error", () => - Effect.gen(function* () { - const cause = new Error("network unavailable"); - autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause)); - const updater = yield* ElectronUpdater.ElectronUpdater; - - const exit = yield* Effect.exit(updater.checkForUpdates); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.equal(error.cause, cause); - } - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); -}); diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts new file mode 100644 index 00000000..989f6334 --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -0,0 +1,165 @@ +import * as Effect from "effect/Effect"; +import { describe, expect, it, vi } from "vitest"; + +import * as DesktopIpc from "./DesktopIpc.ts"; + +function makeTopFrame(url: string): DesktopIpc.DesktopIpcWebFrame { + const frame = { + url, + top: null, + } as DesktopIpc.DesktopIpcWebFrame; + (frame as { top: DesktopIpc.DesktopIpcWebFrame }).top = frame; + return frame; +} + +function makeIpcMainStub() { + let invokeListener: DesktopIpc.DesktopIpcHandleListener | null = null; + let syncListener: DesktopIpc.DesktopIpcSyncListener | null = null; + + return { + ipcMain: { + removeHandler: vi.fn(), + handle: vi.fn((_channel, listener) => { + invokeListener = listener; + }), + removeAllListeners: vi.fn(), + on: vi.fn((_channel, listener) => { + syncListener = listener; + }), + } satisfies DesktopIpc.DesktopIpcMain, + getInvokeListener: () => { + if (!invokeListener) throw new Error("invoke listener not registered"); + return invokeListener; + }, + getSyncListener: () => { + if (!syncListener) throw new Error("sync listener not registered"); + return syncListener; + }, + }; +} + +describe("DesktopIpc sender validation", () => { + it("classifies only file and loopback renderer URLs as trusted", () => { + expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("file:///Applications/CafeCode/index.html")).toBe( + true, + ); + expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("http://127.0.0.1:5733/")).toBe(true); + expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("http://localhost:5733/")).toBe(true); + expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("http://[::1]:5733/")).toBe(true); + expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("https://example.com/")).toBe(false); + expect(DesktopIpc.isTrustedDesktopIpcFrameUrl("app://cafe-code/index.html")).toBe(false); + }); + + it("allows invoke handlers from registered top-level production and dev frames", async () => { + const ipcMain = makeIpcMainStub(); + const ipc = DesktopIpc.make(ipcMain.ipcMain); + const sender = { id: 7, isDestroyed: () => false }; + let calls = 0; + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* ipc.trustWebContents(sender); + yield* ipc.handle({ + channel: "secure.invoke", + handler: (raw) => + Effect.sync(() => { + calls += 1; + return raw; + }), + }); + + yield* Effect.promise(() => + Promise.resolve( + ipcMain.getInvokeListener()( + { sender, senderFrame: makeTopFrame("file:///Applications/CafeCode/index.html") }, + "production", + ), + ), + ); + yield* Effect.promise(() => + Promise.resolve( + ipcMain.getInvokeListener()( + { sender, senderFrame: makeTopFrame("http://127.0.0.1:5733/") }, + "development", + ), + ), + ); + }), + ), + ); + + expect(calls).toBe(2); + }); + + it("rejects invoke handlers from untrusted origins and unexpected frames", async () => { + const ipcMain = makeIpcMainStub(); + const ipc = DesktopIpc.make(ipcMain.ipcMain); + const sender = { id: 7, isDestroyed: () => false }; + let calls = 0; + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* ipc.trustWebContents(sender); + yield* ipc.handle({ + channel: "secure.invoke", + handler: () => + Effect.sync(() => { + calls += 1; + return "handled"; + }), + }); + }), + ), + ); + + const listener = ipcMain.getInvokeListener(); + const topFrame = makeTopFrame("http://127.0.0.1:5733/"); + const childFrame = { url: "http://127.0.0.1:5733/iframe", top: topFrame }; + + await expect( + listener({ sender, senderFrame: makeTopFrame("https://evil.example/") }, "payload"), + ).rejects.toThrow(DesktopIpc.DesktopIpcSenderValidationError); + await expect(listener({ sender, senderFrame: childFrame }, "payload")).rejects.toThrow( + DesktopIpc.DesktopIpcSenderValidationError, + ); + await expect( + listener({ sender: { id: 8, isDestroyed: () => false }, senderFrame: topFrame }, "payload"), + ).rejects.toThrow(DesktopIpc.DesktopIpcSenderValidationError); + await expect(listener({ sender, senderFrame: null }, "payload")).rejects.toThrow( + DesktopIpc.DesktopIpcSenderValidationError, + ); + + expect(calls).toBe(0); + }); + + it("does not execute sync handlers from untrusted senders", async () => { + const ipcMain = makeIpcMainStub(); + const ipc = DesktopIpc.make(ipcMain.ipcMain); + let calls = 0; + + await Effect.runPromise( + Effect.scoped( + ipc.handleSync({ + channel: "secure.sync", + handler: () => + Effect.sync(() => { + calls += 1; + return "secret"; + }), + }), + ), + ); + + const event = { + returnValue: "unset", + sender: { id: 9, isDestroyed: () => false }, + senderFrame: makeTopFrame("http://127.0.0.1:5733/"), + }; + ipcMain.getSyncListener()(event); + + expect(event.returnValue).toBeNull(); + expect(calls).toBe(0); + }); +}); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index edeb80b8..8894fdf4 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -3,10 +3,25 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -export interface DesktopIpcInvokeEvent {} +export interface DesktopIpcWebContents { + readonly id?: number; + isDestroyed?: () => boolean; +} + +export interface DesktopIpcWebFrame { + readonly url: string; + readonly top?: DesktopIpcWebFrame | null; +} + +export interface DesktopIpcInvokeEvent { + readonly sender?: DesktopIpcWebContents; + readonly senderFrame?: DesktopIpcWebFrame | null; +} export interface DesktopIpcSyncEvent { returnValue: unknown; + readonly sender?: DesktopIpcWebContents; + readonly senderFrame?: DesktopIpcWebFrame | null; } export type DesktopIpcHandleListener = ( @@ -34,6 +49,7 @@ export interface DesktopSyncIpcMethod { } export interface DesktopIpcShape { + readonly trustWebContents: (webContents: DesktopIpcWebContents) => Effect.Effect; readonly handle: ( input: DesktopIpcMethod, ) => Effect.Effect; @@ -46,8 +62,73 @@ export class DesktopIpc extends Context.Service()( "cafecode/desktop/Ipc", ) {} -export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => - DesktopIpc.of({ +export class DesktopIpcSenderValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "DesktopIpcSenderValidationError"; + } +} + +function normalizeHostname(hostname: string): string { + return hostname.replace(/^\[|\]$/g, "").toLowerCase(); +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = normalizeHostname(hostname); + return normalized === "localhost" || normalized === "::1" || /^127(?:\.|$)/.test(normalized); +} + +export function isTrustedDesktopIpcFrameUrl(rawUrl: string): boolean { + try { + const url = new URL(rawUrl); + if (url.protocol === "file:") { + return true; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return false; + } + return isLoopbackHostname(url.hostname); + } catch { + return false; + } +} + +function isTopLevelFrame(frame: DesktopIpcWebFrame): boolean { + return frame.top === undefined || frame.top === null || frame.top === frame; +} + +function validateDesktopIpcSender( + event: DesktopIpcInvokeEvent | DesktopIpcSyncEvent, + trustedWebContents: WeakSet, +): void { + const sender = event.sender; + if (typeof sender !== "object" || sender === null || !trustedWebContents.has(sender)) { + throw new DesktopIpcSenderValidationError("Rejected IPC call from an untrusted webContents."); + } + + if (sender.isDestroyed?.() === true) { + throw new DesktopIpcSenderValidationError("Rejected IPC call from a destroyed webContents."); + } + + const frame = event.senderFrame; + if (!frame || !isTopLevelFrame(frame)) { + throw new DesktopIpcSenderValidationError("Rejected IPC call from an untrusted frame."); + } + + if (!isTrustedDesktopIpcFrameUrl(frame.url)) { + throw new DesktopIpcSenderValidationError("Rejected IPC call from an untrusted frame URL."); + } +} + +export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => { + const trustedWebContents = new WeakSet(); + + return DesktopIpc.of({ + trustWebContents: (webContents) => + Effect.sync(() => { + trustedWebContents.add(webContents); + }), + handle: Effect.fn("desktop.ipc.registerInvoke")(function* ({ channel, handler, @@ -59,14 +140,20 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => yield* Effect.acquireRelease( Effect.sync(() => { ipcMain.removeHandler(channel); - ipcMain.handle(channel, (_event, raw) => - runPromise( + ipcMain.handle(channel, (event, raw) => { + try { + validateDesktopIpcSender(event, trustedWebContents); + } catch (error) { + return Promise.reject(error); + } + + return runPromise( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel }); return yield* handler(raw); }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), - ), - ); + ); + }); }), () => Effect.sync(() => ipcMain.removeHandler(channel)), ); @@ -84,6 +171,16 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => Effect.sync(() => { ipcMain.removeAllListeners(channel); ipcMain.on(channel, (event) => { + try { + validateDesktopIpcSender(event, trustedWebContents); + } catch (error) { + if (error instanceof DesktopIpcSenderValidationError) { + event.returnValue = null; + return; + } + throw error; + } + event.returnValue = runSync( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel }); @@ -96,6 +193,7 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => ); }), }); +}; /** * Convenience helpers for creating IPC methods diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8717c877..64438949 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -23,7 +23,6 @@ import { fetchSshEnvironmentDescriptor, fetchSshSessionState, issueSshWebSocketToken, - resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; import { checkForUpdate, @@ -32,11 +31,14 @@ import { installUpdate, setUpdateChannel, } from "./methods/updates.ts"; +import { setPowerSaveBlockerState } from "./methods/powerSaveBlocker.ts"; +import { getDebugEndpointState, publishDebugSnapshot } from "./methods/debug.ts"; import { confirm, getAppBranding, getLocalEnvironmentBootstrap, openExternal, + openPath, pickFolder, setTheme, showContextMenu, @@ -48,8 +50,12 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getLocalEnvironmentBootstrap); + yield* ipc.handle(getDebugEndpointState); + yield* ipc.handle(publishDebugSnapshot); + yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); + yield* ipc.handle(setPowerSaveBlockerState); yield* ipc.handle(getSavedEnvironmentRegistry); yield* ipc.handle(setSavedEnvironmentRegistry); yield* ipc.handle(getSavedEnvironmentSecret); @@ -63,7 +69,6 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(bootstrapSshBearerSession); yield* ipc.handle(fetchSshSessionState); yield* ipc.handle(issueSshWebSocketToken); - yield* ipc.handle(resolveSshPasswordPrompt); yield* ipc.handle(getServerExposureState); yield* ipc.handle(setServerExposureMode); @@ -75,6 +80,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(openPath); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 2715b20c..21889c5d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,7 @@ export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const OPEN_PATH_CHANNEL = "desktop:open-path"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; @@ -12,8 +13,11 @@ export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +export const GET_DEBUG_ENDPOINT_STATE_CHANNEL = "desktop:get-debug-endpoint-state"; +export const PUBLISH_DEBUG_SNAPSHOT_CHANNEL = "desktop:publish-debug-snapshot"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; +export const SET_POWER_SAVE_BLOCKER_STATE_CHANNEL = "desktop:set-power-save-blocker-state"; export const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; @@ -26,10 +30,7 @@ export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-envir export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; export const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; -export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; -export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; -export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; diff --git a/apps/desktop/src/ipc/methods/debug.ts b/apps/desktop/src/ipc/methods/debug.ts new file mode 100644 index 00000000..79c22572 --- /dev/null +++ b/apps/desktop/src/ipc/methods/debug.ts @@ -0,0 +1,25 @@ +import { + DesktopDebugEndpointStateSchema, + DesktopRendererDebugSnapshotSchema, +} from "@cafecode/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopDebugServer from "../../debug/DesktopDebugServer.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getDebugEndpointState = makeIpcMethod({ + channel: IpcChannels.GET_DEBUG_ENDPOINT_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopDebugEndpointStateSchema, + handler: () => DesktopDebugServer.getDebugEndpointState, +}); + +export const publishDebugSnapshot = makeIpcMethod({ + channel: IpcChannels.PUBLISH_DEBUG_SNAPSHOT_CHANNEL, + payload: DesktopRendererDebugSnapshotSchema, + result: Schema.Void, + handler: (snapshot) => + DesktopDebugServer.publishRendererDebugSnapshot(snapshot).pipe(Effect.asVoid), +}); diff --git a/apps/desktop/src/ipc/methods/powerSaveBlocker.ts b/apps/desktop/src/ipc/methods/powerSaveBlocker.ts new file mode 100644 index 00000000..de782ffd --- /dev/null +++ b/apps/desktop/src/ipc/methods/powerSaveBlocker.ts @@ -0,0 +1,17 @@ +import { DesktopPowerSaveBlockerStateSchema } from "@cafecode/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopPowerSaveBlocker from "../../app/DesktopPowerSaveBlocker.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const setPowerSaveBlockerState = makeIpcMethod({ + channel: IpcChannels.SET_POWER_SAVE_BLOCKER_STATE_CHANNEL, + payload: DesktopPowerSaveBlockerStateSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.powerSaveBlocker.setState")(function* (state) { + const powerSaveBlocker = yield* DesktopPowerSaveBlocker.DesktopPowerSaveBlocker; + yield* powerSaveBlocker.update(state); + }), +}); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index e5c43799..eb8a940c 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -6,8 +6,6 @@ import { DesktopSshEnvironmentEnsureResultSchema, DesktopSshEnvironmentTargetSchema, DesktopSshHttpBaseUrlInputSchema, - DesktopSshPasswordPromptCancelledType, - DesktopSshPasswordPromptResolutionInputSchema, ExecutionEnvironmentDescriptor, AuthBearerBootstrapResult, AuthSessionState, @@ -19,7 +17,6 @@ import * as Schema from "effect/Schema"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; -import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopSshRemoteApi from "../../ssh/DesktopSshRemoteApi.ts"; export const discoverSshHosts = makeIpcMethod({ @@ -41,16 +38,7 @@ export const ensureSshEnvironment = makeIpcMethod({ options, }) { const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; - return yield* sshEnvironment.ensureEnvironment(target, options).pipe( - Effect.catch((error) => - DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) - ? Effect.succeed({ - type: DesktopSshPasswordPromptCancelledType, - message: error.message, - }) - : Effect.fail(error), - ), - ); + return yield* sshEnvironment.ensureEnvironment(target, options); }), }); @@ -112,16 +100,3 @@ export const issueSshWebSocketToken = makeIpcMethod({ return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken }); }), }); - -export const resolveSshPasswordPrompt = makeIpcMethod({ - channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, - payload: DesktopSshPasswordPromptResolutionInputSchema, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.sshEnvironment.resolvePasswordPrompt")(function* ({ - requestId, - password, - }) { - const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; - yield* prompts.resolve({ requestId, password }); - }), -}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 0ab0a1dd..474a5675 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -133,3 +133,13 @@ export const openExternal = makeIpcMethod({ return yield* shell.openExternal(url); }), }); + +export const openPath = makeIpcMethod({ + channel: IpcChannels.OPEN_PATH_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.openPath")(function* (path) { + const shell = yield* ElectronShell.ElectronShell; + return yield* shell.openPath(path); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 18dd4d4b..b72aa7b6 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -21,6 +21,7 @@ import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; +import * as ElectronPowerSaveBlocker from "./electron/ElectronPowerSaveBlocker.ts"; import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; @@ -33,13 +34,13 @@ import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; +import * as DesktopPowerSaveBlocker from "./app/DesktopPowerSaveBlocker.ts"; import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; -import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; @@ -100,6 +101,7 @@ const electronLayer = Layer.mergeAll( ElectronProtocol.layer, DesktopSecretStorage.layer, ElectronShell.layer, + ElectronPowerSaveBlocker.layer, ElectronTheme.layer, ElectronUpdater.layer, ElectronWindow.layer, @@ -114,11 +116,10 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopSavedEnvironments.layer, DesktopAssets.layer, DesktopObservability.layer, + DesktopPowerSaveBlocker.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); -const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe( - Layer.provideMerge(DesktopSshPasswordPrompts.layer()), -); +const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer); const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index f662f113..7ee76484 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -3,22 +3,6 @@ import { contextBridge, ipcRenderer } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; -function unwrapEnsureSshEnvironmentResult(result: unknown) { - if ( - typeof result === "object" && - result !== null && - "type" in result && - result.type === IpcChannels.SSH_PASSWORD_PROMPT_CANCELLED_RESULT - ) { - const message = - "message" in result && typeof result.message === "string" - ? result.message - : "SSH authentication cancelled."; - throw new Error(message); - } - return result as Awaited>; -} - contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); @@ -34,9 +18,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getDebugEndpointState: () => ipcRenderer.invoke(IpcChannels.GET_DEBUG_ENDPOINT_STATE_CHANNEL), + publishDebugSnapshot: (snapshot) => + ipcRenderer.invoke(IpcChannels.PUBLISH_DEBUG_SNAPSHOT_CHANNEL, snapshot), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), + setPowerSaveBlockerState: (state) => + ipcRenderer.invoke(IpcChannels.SET_POWER_SAVE_BLOCKER_STATE_CHANNEL, state), getSavedEnvironmentRegistry: () => ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), setSavedEnvironmentRegistry: (records) => @@ -49,12 +38,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), ensureSshEnvironment: async (target, options) => - unwrapEnsureSshEnvironmentResult( - await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, { - target, - ...(options === undefined ? {} : { options }), - }), - ), + ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, { + target, + ...(options === undefined ? {} : { options }), + }), disconnectSshEnvironment: (target) => ipcRenderer.invoke(IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target), fetchSshEnvironmentDescriptor: (httpBaseUrl) => @@ -68,19 +55,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), issueSshWebSocketToken: (httpBaseUrl, bearerToken) => ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), - onSshPasswordPrompt: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { - if (typeof request !== "object" || request === null) return; - listener(request as Parameters[0]); - }; - - ipcRenderer.on(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); - return () => { - ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); - }; - }, - resolveSshPasswordPrompt: (requestId, password) => - ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), @@ -96,6 +70,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + openPath: (path: string) => ipcRenderer.invoke(IpcChannels.OPEN_PATH_CHANNEL, path), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 0159a969..a8a3cc86 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,8 +18,10 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, + defaultEditor: "system-default", favorites: [], providerModelPreferences: {}, + powerSaveBlockerMode: "off", sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index d477b7da..0b18771f 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -160,25 +160,66 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("falls back to launchctl SSH_AUTH_SOCK on macOS when login shell omits it", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + const commands: ChildProcess.Command[] = []; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: (command) => { + commands.push(command); + if (command._tag !== "StandardCommand") return ""; + if (command.command === "/bin/launchctl") { + return command.args.includes("SSH_AUTH_SOCK") ? "/tmp/launchctl-agent.sock" : ""; + } + return envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" }); + }, + }); + + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/launchctl-agent.sock"); + const capturedCommands: string[][] = []; + for (const command of commands) { + if (command._tag !== "StandardCommand") continue; + capturedCommands.push([command.command].concat(command.args)); + } + assert.deepEqual(capturedCommands, [ + ["/bin/zsh", "-ilc", commands[0]?._tag === "StandardCommand" ? commands[0].args[1] : ""], + ["/bin/launchctl", "getenv", "SSH_AUTH_SOCK"], + ]); + }), + ); + it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { SHELL: "/opt/homebrew/bin/nu", PATH: "/usr/bin", }; - const commands: string[] = []; + const commands: string[][] = []; yield* runShellEnvironment({ env, platform: "darwin", handler: (command) => { if (command._tag !== "StandardCommand") return ""; - commands.push(command.command); - return command.command === "/bin/launchctl" ? "/opt/homebrew/bin:/usr/bin" : ""; + commands.push([command.command, ...command.args]); + if (command.command !== "/bin/launchctl") return ""; + return command.args.includes("PATH") ? "/opt/homebrew/bin:/usr/bin" : ""; }, }); - assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]); + assert.equal(commands[0]?.[0], "/opt/homebrew/bin/nu"); + assert.equal(commands[1]?.[0], "/bin/zsh"); + assert.deepEqual(commands.slice(2), [ + ["/bin/launchctl", "getenv", "PATH"], + ["/bin/launchctl", "getenv", "SSH_AUTH_SOCK"], + ]); assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); }), ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 6b4701fa..8684be21 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -222,6 +222,15 @@ const readLaunchctlPath: Effect.Effect< timeout: LAUNCHCTL_TIMEOUT, }).pipe(Effect.map(trimNonEmpty)); +const readLaunchctlValue = ( + name: string, +): Effect.Effect, never, ChildProcessSpawner.ChildProcessSpawner> => + runCommandOutput({ + command: "/bin/launchctl", + args: ["getenv", name], + timeout: LAUNCHCTL_TIMEOUT, + }).pipe(Effect.map(trimNonEmpty)); + const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")( function* ( names: ReadonlyArray, @@ -307,8 +316,17 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix if (Option.isSome(mergedPath)) { config.env.PATH = mergedPath.value; } - if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { - config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; + if (!config.env.SSH_AUTH_SOCK) { + const launchctlSshAuthSock = + config.platform === "darwin" && !shellEnvironment.SSH_AUTH_SOCK + ? yield* readLaunchctlValue("SSH_AUTH_SOCK") + : Option.none(); + const sshAuthSock = trimNonEmpty(shellEnvironment.SSH_AUTH_SOCK).pipe( + Option.orElse(() => launchctlSshAuthSock), + ); + if (Option.isSome(sshAuthSock)) { + config.env.SSH_AUTH_SOCK = sshAuthSock.value; + } } for (const name of [ diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index 1901f6f2..2ffcce2b 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -2,14 +2,12 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as NetService from "@cafecode/shared/Net"; -import { SshPasswordPromptError } from "@cafecode/ssh/errors"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as DesktopSshEnvironment from "./DesktopSshEnvironment.ts"; -import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; function makeTempHomeDir() { return Effect.gen(function* () { @@ -19,21 +17,6 @@ function makeTempHomeDir() { } describe("sshEnvironment", () => { - it("treats password prompt timeouts as cancellable authentication prompts", () => { - assert.equal( - DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation( - new SshPasswordPromptError({ - message: "SSH authentication timed out for devbox.", - cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({ - requestId: "prompt-1", - destination: "devbox", - }), - }), - ), - true, - ); - }); - it.effect("wires desktop host discovery through the ssh package runtime", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -100,13 +83,6 @@ describe("sshEnvironment", () => { }).pipe( Effect.provide( DesktopSshEnvironment.layer().pipe( - Layer.provideMerge( - Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, { - request: () => Effect.die("unexpected password prompt request"), - resolve: () => Effect.die("unexpected password prompt resolution"), - cancelPending: () => Effect.void, - }), - ), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(NetService.layer), diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index e606d47f..1ae6207d 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -4,11 +4,6 @@ import type { DesktopSshEnvironmentTarget, } from "@cafecode/contracts"; import * as NetService from "@cafecode/shared/Net"; -import { - SshPasswordPrompt, - type SshPasswordPromptShape, - type SshPasswordRequest, -} from "@cafecode/ssh/auth"; import { discoverSshHosts } from "@cafecode/ssh/config"; import { SshCommandError, @@ -16,7 +11,6 @@ import { SshInvalidTargetError, SshLaunchError, SshPairingError, - SshPasswordPromptError, SshReadinessError, } from "@cafecode/ssh/errors"; import { SshEnvironmentManager, type RemoteCafeCodeRunnerOptions } from "@cafecode/ssh/tunnel"; @@ -28,8 +22,6 @@ import * as Path from "effect/Path"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; - export type DesktopSshEnvironmentRuntimeServices = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem @@ -43,7 +35,6 @@ export type DesktopSshEnvironmentOperationError = | SshLaunchError | SshPairingError | SshReadinessError - | SshPasswordPromptError | NetService.NetError; export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError; @@ -79,36 +70,9 @@ function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { return discoverSshHosts(input ?? {}); } -export function isDesktopSshPasswordPromptCancellation( - error: unknown, -): error is SshPasswordPromptError { - return ( - error instanceof SshPasswordPromptError && - DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause) - ); -} - -const makePasswordPrompt = ( - prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPromptsShape, -): SshPasswordPromptShape => ({ - isAvailable: true, - request: (request: SshPasswordRequest) => - prompts.request(request).pipe( - Effect.mapError( - (cause) => - new SshPasswordPromptError({ - message: cause.message, - cause, - }), - ), - ), -}); - const make = Effect.gen(function* () { const manager = yield* SshEnvironmentManager; - const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; const runtimeContext = yield* Effect.context(); - const passwordPrompt = SshPasswordPrompt.of(makePasswordPrompt(prompts)); return DesktopSshEnvironment.of({ discoverHosts: (input) => @@ -119,19 +83,11 @@ const make = Effect.gen(function* () { ensureEnvironment: (target, ensureOptions) => manager .ensureEnvironment(target, ensureOptions) - .pipe( - Effect.provideService(SshPasswordPrompt, passwordPrompt), - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.ensureEnvironment"), - ), + .pipe(Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.ensureEnvironment")), disconnectEnvironment: (target) => manager .disconnectEnvironment(target) - .pipe( - Effect.provideService(SshPasswordPrompt, passwordPrompt), - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.disconnectEnvironment"), - ), + .pipe(Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.disconnectEnvironment")), }); }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts deleted file mode 100644 index 9e9cfcc7..00000000 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as TestClock from "effect/testing/TestClock"; -import type * as Electron from "electron"; - -import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as IpcChannels from "../ipc/channels.ts"; -import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; - -interface SentMessage { - readonly channel: string; - readonly args: readonly unknown[]; -} - -function makeTestWindow() { - const listeners = new Map void>>(); - const sentMessages: SentMessage[] = []; - let destroyed = false; - let minimized = true; - let restored = false; - let focused = false; - - const window = { - isDestroyed: () => destroyed, - isMinimized: () => minimized, - restore: () => { - restored = true; - minimized = false; - }, - focus: () => { - focused = true; - }, - once: (eventName: string, listener: () => void) => { - const eventListeners = listeners.get(eventName) ?? new Set<() => void>(); - eventListeners.add(listener); - listeners.set(eventName, eventListeners); - }, - removeListener: (eventName: string, listener: () => void) => { - listeners.get(eventName)?.delete(listener); - }, - webContents: { - send: (channel: string, ...args: readonly unknown[]) => { - sentMessages.push({ channel, args }); - }, - }, - }; - - return { - window, - sentMessages, - isRestored: () => restored, - isFocused: () => focused, - close: () => { - destroyed = true; - const closedListeners = [...(listeners.get("closed") ?? [])]; - listeners.delete("closed"); - for (const listener of closedListeners) { - listener(); - } - }, - }; -} - -function makeElectronWindowLayer(window: ReturnType["window"]) { - return Layer.succeed( - ElectronWindow.ElectronWindow, - ElectronWindow.ElectronWindow.of({ - create: () => Effect.die("unexpected BrowserWindow creation"), - main: Effect.succeed(Option.some(window as Electron.BrowserWindow)), - currentMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), - focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), - setMain: () => Effect.void, - clearMain: () => Effect.void, - reveal: () => Effect.void, - sendAll: () => Effect.void, - destroyAll: Effect.void, - syncAllAppearance: () => Effect.void, - }), - ); -} - -function makeLayer(window: ReturnType["window"]) { - return DesktopSshPasswordPrompts.layer({ passwordPromptTimeoutMs: 1_000 }).pipe( - Layer.provide(makeElectronWindowLayer(window)), - Layer.provideMerge(TestClock.layer()), - ); -} - -describe("DesktopSshPasswordPrompts", () => { - it.effect("sends renderer prompts and resolves them by request id", () => { - const testWindow = makeTestWindow(); - - return Effect.gen(function* () { - const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; - const fiber = yield* prompts - .request({ - destination: "devbox", - username: "julius", - prompt: "Enter the SSH password.", - attempt: 1, - }) - .pipe(Effect.forkScoped); - - yield* Effect.yieldNow; - assert.equal(testWindow.sentMessages.length, 1); - const sent = testWindow.sentMessages[0]; - assert.ok(sent); - assert.equal(sent.channel, IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL); - const request = sent.args[0] as { readonly requestId: string; readonly destination: string }; - assert.equal(request.destination, "devbox"); - assert.equal(testWindow.isRestored(), true); - assert.equal(testWindow.isFocused(), true); - - yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); - assert.equal(yield* Fiber.join(fiber), "secret"); - }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); - }); - - it.effect("times out pending renderer prompts with a typed error", () => { - const testWindow = makeTestWindow(); - - return Effect.gen(function* () { - const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; - const fiber = yield* prompts - .request({ - destination: "devbox", - username: null, - prompt: "Enter the SSH password.", - attempt: 1, - }) - .pipe(Effect.forkScoped); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(1_000)); - const error = yield* Fiber.join(fiber).pipe(Effect.flip); - assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError); - assert.equal(error.destination, "devbox"); - }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); - }); -}); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts deleted file mode 100644 index 2d25ee40..00000000 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ /dev/null @@ -1,332 +0,0 @@ -import type { DesktopSshPasswordPromptRequest } from "@cafecode/contracts"; -import { DesktopSshPasswordPromptResolutionInputSchema } from "@cafecode/contracts"; -import type { SshPasswordRequest } from "@cafecode/ssh/auth"; -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Random from "effect/Random"; -import * as Ref from "effect/Ref"; - -import * as IpcChannels from "../ipc/channels.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; - -const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; -const WINDOW_UNAVAILABLE_MESSAGE = "Cafe Code window is not available for SSH authentication."; - -type DesktopSshPasswordPromptResolutionInput = - typeof DesktopSshPasswordPromptResolutionInputSchema.Type; - -export class DesktopSshPromptUnavailableError extends Data.TaggedError( - "DesktopSshPromptUnavailableError", -)<{ - readonly reason: string; -}> { - override get message() { - return this.reason; - } -} - -export class DesktopSshPromptWindowUnavailableError extends Data.TaggedError( - "DesktopSshPromptWindowUnavailableError", -)<{ - readonly destination: string; -}> { - override get message() { - return WINDOW_UNAVAILABLE_MESSAGE; - } -} - -export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{ - readonly requestId: string; - readonly destination: string; - readonly cause: unknown; -}> { - override get message() { - return WINDOW_UNAVAILABLE_MESSAGE; - } -} - -export class DesktopSshPromptTimedOutError extends Data.TaggedError( - "DesktopSshPromptTimedOutError", -)<{ - readonly requestId: string; - readonly destination: string; -}> { - override get message() { - return `SSH authentication timed out for ${this.destination}.`; - } -} - -export class DesktopSshPromptCancelledError extends Data.TaggedError( - "DesktopSshPromptCancelledError", -)<{ - readonly requestId: string; - readonly destination: string; - readonly reason: string; -}> { - override get message() { - return this.reason; - } -} - -export class DesktopSshPromptInvalidRequestIdError extends Data.TaggedError( - "DesktopSshPromptInvalidRequestIdError", -)<{ - readonly requestId: string; -}> { - override get message() { - return "Invalid SSH password prompt id."; - } -} - -export class DesktopSshPromptExpiredError extends Data.TaggedError("DesktopSshPromptExpiredError")<{ - readonly requestId: string; -}> { - override get message() { - return "SSH password prompt expired. Try connecting again."; - } -} - -export type DesktopSshPasswordPromptRequestError = - | DesktopSshPromptUnavailableError - | DesktopSshPromptWindowUnavailableError - | DesktopSshPromptSendError - | DesktopSshPromptTimedOutError - | DesktopSshPromptCancelledError; - -export type DesktopSshPasswordPromptResolveError = - | DesktopSshPromptInvalidRequestIdError - | DesktopSshPromptExpiredError; - -export type DesktopSshPasswordPromptError = - | DesktopSshPasswordPromptRequestError - | DesktopSshPasswordPromptResolveError; - -export function isDesktopSshPasswordPromptCancellation( - error: unknown, -): error is DesktopSshPromptCancelledError | DesktopSshPromptTimedOutError { - return ( - error instanceof DesktopSshPromptCancelledError || - error instanceof DesktopSshPromptTimedOutError - ); -} - -export interface DesktopSshPasswordPromptsShape { - readonly request: ( - request: SshPasswordRequest, - ) => Effect.Effect; - readonly resolve: ( - input: DesktopSshPasswordPromptResolutionInput, - ) => Effect.Effect; - readonly cancelPending: (reason: string) => Effect.Effect; -} - -export class DesktopSshPasswordPrompts extends Context.Service< - DesktopSshPasswordPrompts, - DesktopSshPasswordPromptsShape ->()("cafecode/desktop/SshPasswordPrompts") {} - -interface PendingSshPasswordPrompt { - readonly requestId: string; - readonly destination: string; - readonly deferred: Deferred.Deferred; -} - -interface LayerOptions { - readonly passwordPromptTimeoutMs?: number; -} - -const removePending = ( - pendingRef: Ref.Ref>, - requestId: string, -) => - Ref.modify(pendingRef, (pending) => { - const entry = pending.get(requestId); - if (entry === undefined) { - return [Option.none(), pending] as const; - } - - const nextPending = new Map(pending); - nextPending.delete(requestId); - return [Option.some(entry), nextPending] as const; - }); - -const failPending = ( - pending: PendingSshPasswordPrompt, - error: DesktopSshPasswordPromptRequestError, -) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); - -const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) { - const electronWindow = yield* ElectronWindow.ElectronWindow; - const pendingRef = yield* Ref.make(new Map()); - const passwordPromptTimeoutMs = - options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; - - const cancelPending = (reason: string): Effect.Effect => - Ref.getAndSet(pendingRef, new Map()).pipe( - Effect.flatMap((pending) => - Effect.forEach( - pending.values(), - (entry) => - failPending( - entry, - new DesktopSshPromptCancelledError({ - requestId: entry.requestId, - destination: entry.destination, - reason, - }), - ), - { discard: true }, - ), - ), - Effect.asVoid, - ); - - yield* Effect.addFinalizer(() => - cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore), - ); - - const resolve = Effect.fn("desktop.sshPasswordPrompts.resolve")(function* ( - input: DesktopSshPasswordPromptResolutionInput, - ): Effect.fn.Return { - const requestId = input.requestId.trim(); - if (requestId.length === 0) { - return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId }); - } - - const pending = yield* removePending(pendingRef, requestId); - if (Option.isNone(pending)) { - return yield* new DesktopSshPromptExpiredError({ requestId }); - } - - const entry = pending.value; - if (input.password === null) { - yield* failPending( - entry, - new DesktopSshPromptCancelledError({ - requestId, - destination: entry.destination, - reason: `SSH authentication cancelled for ${entry.destination}.`, - }), - ); - return; - } - - yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid); - }); - - const request = Effect.fn("desktop.sshPasswordPrompts.request")(function* ( - input: SshPasswordRequest, - ): Effect.fn.Return { - const window = yield* electronWindow.main; - if (Option.isNone(window) || window.value.isDestroyed()) { - return yield* new DesktopSshPromptWindowUnavailableError({ - destination: input.destination, - }); - } - - const requestId = yield* Random.nextUUIDv4; - const now = yield* DateTime.now; - const expiresAt = DateTime.formatIso( - DateTime.add(now, { milliseconds: passwordPromptTimeoutMs }), - ); - const promptRequest: DesktopSshPasswordPromptRequest = { - requestId, - destination: input.destination, - username: input.username, - prompt: input.prompt, - expiresAt, - }; - const deferred = yield* Deferred.make(); - const pending: PendingSshPasswordPrompt = { - requestId, - destination: input.destination, - deferred, - }; - yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending)); - - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - - const cancelOnWindowClosed = () => { - runFork( - removePending(pendingRef, requestId).pipe( - Effect.flatMap((entry) => - Option.match(entry, { - onNone: () => Effect.void, - onSome: (pending) => - failPending( - pending, - new DesktopSshPromptCancelledError({ - requestId, - destination: input.destination, - reason: "SSH authentication was cancelled because the app window closed.", - }), - ), - }), - ), - ), - ); - }; - const cleanup = Effect.sync(() => { - if (!window.value.isDestroyed()) { - window.value.removeListener("closed", cancelOnWindowClosed); - } - }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid); - const waitForPassword = Deferred.await(deferred).pipe( - Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new DesktopSshPromptTimedOutError({ - requestId, - destination: input.destination, - }), - ), - onSome: Effect.succeed, - }), - ), - ); - - return yield* Effect.try({ - try: () => { - if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); - } - window.value.once("closed", cancelOnWindowClosed); - window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); - if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); - } - if (window.value.isMinimized()) { - window.value.restore(); - } - if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); - } - window.value.focus(); - }, - catch: (cause) => - new DesktopSshPromptSendError({ - requestId, - destination: input.destination, - cause, - }), - }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); - }); - - return DesktopSshPasswordPrompts.of({ - request, - resolve, - cancelPending, - }); -}); - -export const layer = (options: LayerOptions = {}) => - Layer.effect(DesktopSshPasswordPrompts, make(options)); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts deleted file mode 100644 index 5f5b6728..00000000 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { DesktopUpdateState } from "@cafecode/contracts"; -import * as Cause from "effect/Cause"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as TestClock from "effect/testing/TestClock"; - -import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; -import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; -import * as DesktopState from "../app/DesktopState.ts"; -import * as DesktopUpdates from "./DesktopUpdates.ts"; - -interface UpdatesHarnessOptions { - readonly checkForUpdates?: Effect.Effect< - void, - ElectronUpdater.ElectronUpdaterCheckForUpdatesError - >; - readonly env?: Record; -} - -const flushCallbacks = Effect.yieldNow; - -function makeHarness(options: UpdatesHarnessOptions = {}) { - let checkCount = 0; - let allowDowngrade = false; - const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; - const listeners = new Map void>>(); - const sentStates: DesktopUpdateState[] = []; - - const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { - const eventListeners = listeners.get(eventName) ?? new Set(); - eventListeners.add(listener); - listeners.set(eventName, eventListeners); - }; - - const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { - const eventListeners = listeners.get(eventName); - if (!eventListeners) { - return; - } - eventListeners.delete(listener); - if (eventListeners.size === 0) { - listeners.delete(eventName); - } - }; - - const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, { - setFeedURL: (options) => - Effect.sync(() => { - feedUrls.push(options); - }), - setAutoDownload: () => Effect.void, - setAutoInstallOnAppQuit: () => Effect.void, - setChannel: () => Effect.void, - setAllowPrerelease: () => Effect.void, - allowDowngrade: Effect.sync(() => allowDowngrade), - setAllowDowngrade: (value) => - Effect.sync(() => { - allowDowngrade = value; - }), - setDisableDifferentialDownload: () => Effect.void, - checkForUpdates: Effect.sync(() => { - checkCount += 1; - }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), - downloadUpdate: Effect.void, - quitAndInstall: () => Effect.void, - on: (eventName, listener) => - Effect.acquireRelease( - Effect.sync(() => { - addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); - }), - () => - Effect.sync(() => { - removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); - }), - ).pipe(Effect.asVoid), - } satisfies ElectronUpdater.ElectronUpdaterShape); - - const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { - create: () => Effect.die("unexpected BrowserWindow creation"), - main: Effect.succeed(Option.none()), - currentMainOrFirst: Effect.succeed(Option.none()), - focusedMainOrFirst: Effect.succeed(Option.none()), - setMain: () => Effect.void, - clearMain: () => Effect.void, - reveal: () => Effect.void, - sendAll: (_channel, state) => - Effect.sync(() => { - sentStates.push(state as DesktopUpdateState); - }), - destroyAll: Effect.void, - syncAllAppearance: () => Effect.void, - } satisfies ElectronWindow.ElectronWindowShape); - - const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { - start: Effect.void, - stop: () => Effect.void, - currentConfig: Effect.succeed(Option.none()), - snapshot: Effect.succeed({ - desiredRunning: false, - ready: false, - activePid: Option.none(), - restartAttempt: 0, - restartScheduled: false, - }), - }); - - const environmentLayer = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/src", - homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, - platform: "darwin", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - DesktopConfig.layerTest({ - CAFE_CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, - CAFE_CODE_DESKTOP_MOCK_UPDATES: "true", - CAFE_CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", - ...options.env, - }), - ), - ), - ); - - const layer = DesktopUpdates.layer.pipe( - Layer.provideMerge(updaterLayer), - Layer.provideMerge(windowLayer), - Layer.provideMerge(backendLayer), - Layer.provideMerge(DesktopState.layer), - Layer.provideMerge(DesktopAppSettings.layer), - Layer.provideMerge( - DesktopConfig.layerTest({ - CAFE_CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, - CAFE_CODE_DESKTOP_MOCK_UPDATES: "true", - CAFE_CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", - ...options.env, - }), - ), - Layer.provideMerge(environmentLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return { - layer, - checkCount: () => checkCount, - feedUrls: () => feedUrls, - listenerCount: () => - Array.from(listeners.values()).reduce( - (total, eventListeners) => total + eventListeners.size, - 0, - ), - sentStates, - emit: (eventName: string, payload?: unknown) => { - for (const listener of listeners.get(eventName) ?? []) { - listener(payload); - } - }, - }; -} - -describe("DesktopUpdates", () => { - it.effect("configures the updater and runs startup checks on the test clock", () => { - const harness = makeHarness(); - - return Effect.gen(function* () { - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const state = yield* updates.getState; - assert.equal(state.enabled, true); - assert.equal(state.status, "idle"); - assert.deepEqual(harness.feedUrls(), [ - { provider: "generic", url: "http://localhost:4141" }, - ]); - assert.equal(harness.listenerCount(), 6); - assert.equal(harness.checkCount(), 0); - - yield* TestClock.adjust(Duration.millis(15_000)); - assert.equal(harness.checkCount(), 1); - }), - ); - - assert.equal(harness.listenerCount(), 0); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("updates and broadcasts state from updater events", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - harness.emit("update-available", { version: "1.2.4" }); - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "available"); - assert.equal(state.availableVersion, "1.2.4"); - assert.isNotNull(state.checkedAt); - assert.equal(harness.sentStates.at(-1)?.status, "available"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("persists channel changes through the settings service", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const state = yield* updates.setChannel("nightly"); - const persistedSettings = yield* settings.get; - - assert.equal(state.channel, "nightly"); - assert.equal(persistedSettings.updateChannel, "nightly"); - assert.equal(persistedSettings.updateChannelConfiguredByUser, true); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("does not persist an unchanged update channel as a user preference", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const state = yield* updates.setChannel("latest"); - const persistedSettings = yield* settings.get; - - assert.equal(state.channel, "latest"); - assert.equal(persistedSettings.updateChannel, "latest"); - assert.equal(persistedSettings.updateChannelConfiguredByUser, false); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("fails channel changes with a typed error while a check is in progress", () => - Effect.gen(function* () { - const checkStarted = yield* Deferred.make(); - const releaseCheck = yield* Deferred.make(); - const harness = makeHarness({ - checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseCheck)), - ), - }); - - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped); - yield* Deferred.await(checkStarted); - - const exit = yield* Effect.exit(updates.setChannel("nightly")); - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); - assert.equal(error.action, "check"); - } - - yield* Deferred.succeed(releaseCheck, undefined); - yield* Fiber.join(checkFiber); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }), - ); -}); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 0952a9dc..5ab5198b 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -43,6 +43,11 @@ import { const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; +const DESKTOP_UPDATES_DISABLED_REASON = "Update checks are disabled in this Cafe Code build."; + +function areDesktopUpdatesDisabledInThisBuild(): boolean { + return true; +} const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; @@ -166,6 +171,10 @@ function getAutoUpdateDisabledReason(args: { disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { + if (areDesktopUpdatesDisabledInThisBuild()) { + return DESKTOP_UPDATES_DISABLED_REASON; + } + if (!args.hasUpdateFeedConfig) { return "Automatic updates are not available because no update feed is configured."; } @@ -538,6 +547,13 @@ const make = Effect.gen(function* () { const appUpdateYmlConfig = yield* readAppUpdateYml; yield* Ref.set(appUpdateYmlConfigRef, appUpdateYmlConfig); + const settings = yield* desktopSettings.get; + const enabled = yield* shouldEnableAutoUpdates; + yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment)); + if (!enabled) { + return; + } + if (config.mockUpdates) { yield* electronUpdater.setFeedURL({ provider: "generic", @@ -545,12 +561,6 @@ const make = Effect.gen(function* () { } as ElectronUpdater.ElectronUpdaterFeedUrl); } - const settings = yield* desktopSettings.get; - const enabled = yield* shouldEnableAutoUpdates; - yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment)); - if (!enabled) { - return; - } yield* Ref.set(updaterConfiguredRef, true); yield* electronUpdater.setAutoDownload(false); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 368cd2d6..1116edf8 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -7,7 +7,6 @@ import * as Option from "effect/Option"; import type * as Electron from "electron"; import * as DesktopObservability from "../app/DesktopObservability.ts"; -import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -95,10 +94,11 @@ const handleCheckForUpdatesMenuClick: Effect.Effect< }).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); const make = Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const appName = yield* electronApp.name; + const updates = yield* DesktopUpdates.DesktopUpdates; + const updatesDisabledReason = yield* updates.disabledReason; + const updatesMenuEnabled = Option.isNone(updatesDisabledReason); const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); @@ -131,11 +131,12 @@ const make = Effect.gen(function* () { if (environment.platform === "darwin") { template.push({ - label: appName, + label: environment.branding.baseName, submenu: [ { role: "about" }, { label: "Check for Updates...", + enabled: updatesMenuEnabled, click: checkForUpdatesClick, }, { type: "separator" }, @@ -195,6 +196,7 @@ const make = Effect.gen(function* () { submenu: [ { label: "Check for Updates...", + enabled: updatesMenuEnabled, click: checkForUpdatesClick, }, ], diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 2dfd9c55..e5f9180f 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -17,6 +17,7 @@ import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import * as DesktopIpc from "../ipc/DesktopIpc.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; const environmentInput = { @@ -98,6 +99,7 @@ const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), + openPath: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShellShape); @@ -107,6 +109,12 @@ const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { onUpdated: () => Effect.void, } satisfies ElectronTheme.ElectronThemeShape); +const desktopIpcLayer = Layer.succeed(DesktopIpc.DesktopIpc, { + trustWebContents: () => Effect.void, + handle: () => Effect.void, + handleSync: () => Effect.void, +} satisfies DesktopIpc.DesktopIpcShape); + const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( Layer.provide( Layer.mergeAll( @@ -148,6 +156,7 @@ function makeTestLayer(input: { electronShellLayer, electronThemeLayer, electronWindowLayer, + desktopIpcLayer, ), ), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0b00ef97..3d9ea878 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -16,6 +16,7 @@ import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; +import * as DesktopIpc from "../ipc/DesktopIpc.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; const TITLEBAR_HEIGHT = 40; @@ -36,7 +37,8 @@ type DesktopWindowRuntimeServices = | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme - | ElectronWindow.ElectronWindow; + | ElectronWindow.ElectronWindow + | DesktopIpc.DesktopIpc; export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( "DesktopWindowDevServerUrlMissingError", @@ -151,6 +153,7 @@ const make = Effect.gen(function* () { const electronShell = yield* ElectronShell.ElectronShell; const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; + const desktopIpc = yield* DesktopIpc.DesktopIpc; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const state = yield* DesktopState.DesktopState; const context = yield* Effect.context(); @@ -180,6 +183,7 @@ const make = Effect.gen(function* () { sandbox: true, }, }); + yield* desktopIpc.trustWebContents(window.webContents); window.webContents.on("context-menu", (event, params) => { event.preventDefault(); diff --git a/apps/desktop/turbo.jsonc b/apps/desktop/turbo.jsonc index 1f439a3f..66d16534 100644 --- a/apps/desktop/turbo.jsonc +++ b/apps/desktop/turbo.jsonc @@ -7,16 +7,16 @@ "outputs": ["dist-electron/**"], }, "dev": { - "dependsOn": ["cafe-code#build"], + "dependsOn": ["@cafeai/cafe-code#build"], "persistent": true, }, "start": { - "dependsOn": ["build", "@cafecode/web#build", "cafe-code#build"], + "dependsOn": ["build", "@cafecode/web#build", "@cafeai/cafe-code#build"], "cache": false, "persistent": true, }, "smoke-test": { - "dependsOn": ["build", "@cafecode/web#build", "cafe-code#build"], + "dependsOn": ["build", "@cafecode/web#build", "@cafeai/cafe-code#build"], "cache": false, "outputs": [], }, diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 41a20d70..1f417a6c 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -479,6 +479,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter provider, capabilities: { sessionModelSwitch: "in-session", + liveSteer: "unsupported", }, startSession, sendTurn, diff --git a/apps/server/package.json b/apps/server/package.json index 179ad828..8876c97c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { - "name": "cafe-code", - "version": "0.0.24", + "name": "@cafeai/cafe-code", + "version": "0.0.25", "license": "AGPL-3.0-or-later", "repository": { "type": "git", @@ -23,7 +23,8 @@ "test": "vitest run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.111", + "@anthropic-ai/claude-agent-sdk": "^0.3.148", + "@anthropic-ai/sdk": "^0.98.0", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index e2ddeb3d..02168bd2 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -411,7 +411,7 @@ const program = Effect.gen(function* () { status: "completed", rawOutput: { exitCode: 0, - stdout: '{ "name": "cafe-code" }', + stdout: '{ "name": "@cafeai/cafe-code" }', stderr: "", }, }, diff --git a/apps/server/src/auth/Layers/AuthControlPlane.ts b/apps/server/src/auth/Layers/AuthControlPlane.ts index 07f87bdb..184d7739 100644 --- a/apps/server/src/auth/Layers/AuthControlPlane.ts +++ b/apps/server/src/auth/Layers/AuthControlPlane.ts @@ -1,4 +1,4 @@ -import type { AuthClientSession, AuthPairingLink } from "@cafecode/contracts"; +import type { AuthClientSession, AuthPairingLink } from "@cafecode/contracts/auth"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts index 22872e5c..6c21f4a4 100644 --- a/apps/server/src/auth/Layers/BootstrapCredentialService.ts +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts @@ -1,4 +1,4 @@ -import type { AuthPairingLink } from "@cafecode/contracts"; +import type { AuthPairingLink } from "@cafecode/contracts/auth"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index 5b2b4e2f..d76c2f81 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -5,7 +5,7 @@ import { type AuthPairingCredentialResult, type AuthSessionState, type AuthWebSocketTokenResult, -} from "@cafecode/contracts"; +} from "@cafecode/contracts/auth"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts index 3e9204c9..1d57ce58 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -1,4 +1,4 @@ -import type { ServerAuthDescriptor } from "@cafecode/contracts"; +import type { ServerAuthDescriptor } from "@cafecode/contracts/auth"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts index 1a43f729..19272edf 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -2,7 +2,7 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession, -} from "@cafecode/contracts"; +} from "@cafecode/contracts/auth"; import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; diff --git a/apps/server/src/auth/Services/AuthControlPlane.ts b/apps/server/src/auth/Services/AuthControlPlane.ts index 7c87f993..b65a4e73 100644 --- a/apps/server/src/auth/Services/AuthControlPlane.ts +++ b/apps/server/src/auth/Services/AuthControlPlane.ts @@ -1,9 +1,4 @@ -import type { - AuthClientMetadata, - AuthClientSession, - AuthPairingLink, - AuthSessionId, -} from "@cafecode/contracts"; +import type * as AuthContracts from "@cafecode/contracts/auth"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -24,12 +19,12 @@ export interface IssuedPairingLink { } export interface IssuedBearerSession { - readonly sessionId: AuthSessionId; + readonly sessionId: AuthContracts.AuthSessionId; readonly token: string; readonly method: "bearer-session-token"; readonly role: SessionRole; readonly subject: string; - readonly client: AuthClientMetadata; + readonly client: AuthContracts.AuthClientMetadata; readonly expiresAt: DateTime.Utc; } @@ -48,7 +43,7 @@ export interface AuthControlPlaneShape { readonly listPairingLinks: (input?: { readonly role?: SessionRole; readonly excludeSubjects?: ReadonlyArray; - }) => Effect.Effect, AuthControlPlaneError>; + }) => Effect.Effect, AuthControlPlaneError>; readonly revokePairingLink: (id: string) => Effect.Effect; readonly issueSession: (input?: { readonly ttl?: Duration.Duration; @@ -57,14 +52,14 @@ export interface AuthControlPlaneShape { readonly label?: string; }) => Effect.Effect; readonly listSessions: () => Effect.Effect< - ReadonlyArray, + ReadonlyArray, AuthControlPlaneError >; readonly revokeSession: ( - sessionId: AuthSessionId, + sessionId: AuthContracts.AuthSessionId, ) => Effect.Effect; readonly revokeOtherSessionsExcept: ( - sessionId: AuthSessionId, + sessionId: AuthContracts.AuthSessionId, ) => Effect.Effect; } diff --git a/apps/server/src/auth/Services/BootstrapCredentialService.ts b/apps/server/src/auth/Services/BootstrapCredentialService.ts index b8b8f8e4..78d20019 100644 --- a/apps/server/src/auth/Services/BootstrapCredentialService.ts +++ b/apps/server/src/auth/Services/BootstrapCredentialService.ts @@ -1,4 +1,4 @@ -import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@cafecode/contracts"; +import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@cafecode/contracts/auth"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; diff --git a/apps/server/src/auth/Services/ServerAuth.ts b/apps/server/src/auth/Services/ServerAuth.ts index 13bfb760..8bd4aec6 100644 --- a/apps/server/src/auth/Services/ServerAuth.ts +++ b/apps/server/src/auth/Services/ServerAuth.ts @@ -11,7 +11,7 @@ import type { ServerAuthDescriptor, ServerAuthSessionMethod, AuthWebSocketTokenResult, -} from "@cafecode/contracts"; +} from "@cafecode/contracts/auth"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Context from "effect/Context"; diff --git a/apps/server/src/auth/Services/ServerAuthPolicy.ts b/apps/server/src/auth/Services/ServerAuthPolicy.ts index 22aae5f7..a3a1b178 100644 --- a/apps/server/src/auth/Services/ServerAuthPolicy.ts +++ b/apps/server/src/auth/Services/ServerAuthPolicy.ts @@ -1,4 +1,4 @@ -import type { ServerAuthDescriptor } from "@cafecode/contracts"; +import type { ServerAuthDescriptor } from "@cafecode/contracts/auth"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; diff --git a/apps/server/src/auth/Services/SessionCredentialService.ts b/apps/server/src/auth/Services/SessionCredentialService.ts index 6558e893..17ff908a 100644 --- a/apps/server/src/auth/Services/SessionCredentialService.ts +++ b/apps/server/src/auth/Services/SessionCredentialService.ts @@ -1,9 +1,4 @@ -import type { - AuthClientMetadata, - AuthClientSession, - AuthSessionId, - ServerAuthSessionMethod, -} from "@cafecode/contracts"; +import type * as AuthContracts from "@cafecode/contracts/auth"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -14,19 +9,19 @@ import type * as Stream from "effect/Stream"; export type SessionRole = "owner" | "client"; export interface IssuedSession { - readonly sessionId: AuthSessionId; + readonly sessionId: AuthContracts.AuthSessionId; readonly token: string; - readonly method: ServerAuthSessionMethod; - readonly client: AuthClientMetadata; + readonly method: AuthContracts.ServerAuthSessionMethod; + readonly client: AuthContracts.AuthClientMetadata; readonly expiresAt: DateTime.DateTime; readonly role: SessionRole; } export interface VerifiedSession { - readonly sessionId: AuthSessionId; + readonly sessionId: AuthContracts.AuthSessionId; readonly token: string; - readonly method: ServerAuthSessionMethod; - readonly client: AuthClientMetadata; + readonly method: AuthContracts.ServerAuthSessionMethod; + readonly client: AuthContracts.AuthClientMetadata; readonly expiresAt?: DateTime.DateTime; readonly subject: string; readonly role: SessionRole; @@ -35,11 +30,11 @@ export interface VerifiedSession { export type SessionCredentialChange = | { readonly type: "clientUpserted"; - readonly clientSession: AuthClientSession; + readonly clientSession: AuthContracts.AuthClientSession; } | { readonly type: "clientRemoved"; - readonly sessionId: AuthSessionId; + readonly sessionId: AuthContracts.AuthSessionId; }; export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{ @@ -52,13 +47,13 @@ export interface SessionCredentialServiceShape { readonly issue: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; - readonly method?: ServerAuthSessionMethod; + readonly method?: AuthContracts.ServerAuthSessionMethod; readonly role?: SessionRole; - readonly client?: AuthClientMetadata; + readonly client?: AuthContracts.AuthClientMetadata; }) => Effect.Effect; readonly verify: (token: string) => Effect.Effect; readonly issueWebSocketToken: ( - sessionId: AuthSessionId, + sessionId: AuthContracts.AuthSessionId, input?: { readonly ttl?: Duration.Duration; }, @@ -73,16 +68,18 @@ export interface SessionCredentialServiceShape { token: string, ) => Effect.Effect; readonly listActive: () => Effect.Effect< - ReadonlyArray, + ReadonlyArray, SessionCredentialError >; readonly streamChanges: Stream.Stream; - readonly revoke: (sessionId: AuthSessionId) => Effect.Effect; + readonly revoke: ( + sessionId: AuthContracts.AuthSessionId, + ) => Effect.Effect; readonly revokeAllExcept: ( - sessionId: AuthSessionId, + sessionId: AuthContracts.AuthSessionId, ) => Effect.Effect; - readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; - readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; + readonly markConnected: (sessionId: AuthContracts.AuthSessionId) => Effect.Effect; + readonly markDisconnected: (sessionId: AuthContracts.AuthSessionId) => Effect.Effect; } export class SessionCredentialService extends Context.Service< diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 7c946e88..e5dc83f0 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -5,7 +5,7 @@ import { AuthRevokeClientSessionInput, AuthRevokePairingLinkInput, type AuthWebSocketTokenResult, -} from "@cafecode/contracts"; +} from "@cafecode/contracts/auth"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index f4c6b70d..9107188f 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -1,4 +1,4 @@ -import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@cafecode/contracts"; +import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@cafecode/contracts/auth"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as Crypto from "node:crypto"; diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts deleted file mode 100644 index 9d48f818..00000000 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { CheckpointRef, ProjectId, ThreadId, TurnId } from "@cafecode/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { describe, expect, it } from "vitest"; - -import { - ProjectionSnapshotQuery, - type ProjectionThreadCheckpointContext, -} from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { checkpointRefForThreadTurn } from "../Utils.ts"; -import { CheckpointDiffQueryLive } from "./CheckpointDiffQuery.ts"; -import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; -import { CheckpointDiffQuery } from "../Services/CheckpointDiffQuery.ts"; - -function makeThreadCheckpointContext(input: { - readonly projectId: ProjectId; - readonly threadId: ThreadId; - readonly workspaceRoot: string; - readonly worktreePath: string | null; - readonly checkpointTurnCount: number; - readonly checkpointRef: CheckpointRef; -}): ProjectionThreadCheckpointContext { - return { - threadId: input.threadId, - projectId: input.projectId, - workspaceRoot: input.workspaceRoot, - worktreePath: input.worktreePath, - checkpoints: [ - { - turnId: TurnId.make("turn-1"), - checkpointTurnCount: input.checkpointTurnCount, - checkpointRef: input.checkpointRef, - status: "ready", - files: [], - assistantMessageId: null, - completedAt: "2026-01-01T00:00:00.000Z", - }, - ], - }; -} - -describe("CheckpointDiffQueryLive", () => { - it("uses the narrow full-thread context lookup for all-turns diffs", async () => { - const projectId = ProjectId.make("project-full-thread"); - const threadId = ThreadId.make("thread-full-thread"); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 4); - let getThreadCheckpointContextCalls = 0; - let getFullThreadDiffContextCalls = 0; - const diffCheckpointsCalls: Array<{ - readonly fromCheckpointRef: CheckpointRef; - readonly toCheckpointRef: CheckpointRef; - readonly cwd: string; - readonly ignoreWhitespace: boolean; - }> = []; - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(true), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => - Effect.sync(() => { - diffCheckpointsCalls.push({ - fromCheckpointRef, - toCheckpointRef, - cwd, - ignoreWhitespace, - }); - return "full thread diff patch"; - }), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => - Effect.sync(() => { - getThreadCheckpointContextCalls += 1; - return Option.none(); - }), - getFullThreadDiffContext: () => - Effect.sync(() => { - getFullThreadDiffContextCalls += 1; - return Option.some({ - threadId, - projectId, - workspaceRoot: "/tmp/workspace", - worktreePath: "/tmp/worktree", - latestCheckpointTurnCount: 4, - toCheckpointRef, - }); - }), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getFullThreadDiff({ - threadId, - toTurnCount: 4, - ignoreWhitespace: true, - }); - }).pipe(Effect.provide(layer)), - ); - - expect(getThreadCheckpointContextCalls).toBe(0); - expect(getFullThreadDiffContextCalls).toBe(1); - expect(diffCheckpointsCalls).toEqual([ - { - cwd: "/tmp/worktree", - fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), - toCheckpointRef, - ignoreWhitespace: true, - }, - ]); - expect(result).toEqual({ - threadId, - fromTurnCount: 0, - toTurnCount: 4, - diff: "full thread diff patch", - }); - }); - - it("computes diffs using canonical turn-0 checkpoint refs", async () => { - const projectId = ProjectId.make("project-1"); - const threadId = ThreadId.make("thread-1"); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - const diffCheckpointsCalls: Array<{ - readonly fromCheckpointRef: CheckpointRef; - readonly toCheckpointRef: CheckpointRef; - readonly cwd: string; - readonly ignoreWhitespace: boolean; - }> = []; - - const threadCheckpointContext = makeThreadCheckpointContext({ - projectId, - threadId, - workspaceRoot: "/tmp/workspace", - worktreePath: null, - checkpointTurnCount: 1, - checkpointRef: toCheckpointRef, - }); - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(true), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) => - Effect.sync(() => { - diffCheckpointsCalls.push({ - fromCheckpointRef, - toCheckpointRef, - cwd, - ignoreWhitespace, - }); - return "diff patch"; - }), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getTurnDiff({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - ignoreWhitespace: true, - }); - }).pipe(Effect.provide(layer)), - ); - - const expectedFromRef = checkpointRefForThreadTurn(threadId, 0); - expect(diffCheckpointsCalls).toEqual([ - { - cwd: "/tmp/workspace", - fromCheckpointRef: expectedFromRef, - toCheckpointRef, - ignoreWhitespace: true, - }, - ]); - expect(result).toEqual({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - diff: "diff patch", - }); - }); - - it("defaults to hide whitespace changes", async () => { - const projectId = ProjectId.make("project-default-whitespace"); - const threadId = ThreadId.make("thread-default-whitespace"); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - const diffCheckpointsCalls: Array<{ readonly ignoreWhitespace: boolean }> = []; - - const threadCheckpointContext = makeThreadCheckpointContext({ - projectId, - threadId, - workspaceRoot: "/tmp/workspace", - worktreePath: null, - checkpointTurnCount: 1, - checkpointRef: toCheckpointRef, - }); - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(true), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: ({ ignoreWhitespace }) => - Effect.sync(() => { - diffCheckpointsCalls.push({ ignoreWhitespace }); - return "diff patch"; - }), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - await Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getTurnDiff({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - }); - }).pipe(Effect.provide(layer)), - ); - - expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]); - }); - - it("does not preflight checkpoint refs before diffing", async () => { - const projectId = ProjectId.make("project-no-preflight"); - const threadId = ThreadId.make("thread-no-preflight"); - const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); - let hasCheckpointRefCallCount = 0; - - const threadCheckpointContext = makeThreadCheckpointContext({ - projectId, - threadId, - workspaceRoot: "/tmp/workspace", - worktreePath: null, - checkpointTurnCount: 1, - checkpointRef: toCheckpointRef, - }); - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => - Effect.sync(() => { - hasCheckpointRefCallCount += 1; - return true; - }), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: () => Effect.succeed("diff patch"), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - await Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getTurnDiff({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - ignoreWhitespace: true, - }); - }).pipe(Effect.provide(layer)), - ); - - expect(hasCheckpointRefCallCount).toBe(0); - }); - - it("fails when the thread is missing from the snapshot", async () => { - const threadId = ThreadId.make("thread-missing"); - - const checkpointStore: CheckpointStoreShape = { - isGitRepository: () => Effect.succeed(true), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(true), - restoreCheckpoint: () => Effect.succeed(true), - diffCheckpoints: () => Effect.succeed(""), - deleteCheckpointRefs: () => Effect.void, - }; - - const layer = CheckpointDiffQueryLive.pipe( - Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => - Effect.die("CheckpointDiffQuery should not request the command read model"), - getSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), - getShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), - getArchivedShellSnapshot: () => - Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - }), - ), - ); - - await expect( - Effect.runPromise( - Effect.gen(function* () { - const query = yield* CheckpointDiffQuery; - return yield* query.getTurnDiff({ - threadId, - fromTurnCount: 0, - toTurnCount: 1, - }); - }).pipe(Effect.provide(layer)), - ), - ).rejects.toThrow("Thread 'thread-missing' not found."); - }); -}); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts deleted file mode 100644 index 0e827e11..00000000 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { - type CheckpointRef, - OrchestrationGetTurnDiffResult, - type ThreadId, - type OrchestrationGetFullThreadDiffResult, - type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, -} from "@cafecode/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts"; -import { checkpointRefForThreadTurn } from "../Utils.ts"; -import { CheckpointStore } from "../Services/CheckpointStore.ts"; -import { - CheckpointDiffQuery, - type CheckpointDiffQueryShape, -} from "../Services/CheckpointDiffQuery.ts"; - -const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult); - -function buildTurnDiffResult( - input: { - readonly threadId: ThreadId; - readonly fromTurnCount: number; - readonly toTurnCount: number; - }, - diff: string, -): OrchestrationGetTurnDiffResultType { - return { - threadId: input.threadId, - fromTurnCount: input.fromTurnCount, - toTurnCount: input.toTurnCount, - diff, - }; -} - -const make = Effect.gen(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const checkpointStore = yield* CheckpointStore; - - const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")( - function* (input) { - const operation = "CheckpointDiffQuery.getTurnDiff"; - const ignoreWhitespace = input.ignoreWhitespace ?? true; - yield* Effect.annotateCurrentSpan({ - "checkpoint.thread_id": input.threadId, - "checkpoint.from_turn_count": input.fromTurnCount, - "checkpoint.to_turn_count": input.toTurnCount, - "checkpoint.ignore_whitespace": ignoreWhitespace, - }); - - if (input.fromTurnCount === input.toTurnCount) { - const emptyDiff: OrchestrationGetTurnDiffResultType = { - threadId: input.threadId, - fromTurnCount: input.fromTurnCount, - toTurnCount: input.toTurnCount, - diff: "", - }; - if (!isTurnDiffResult(emptyDiff)) { - return yield* new CheckpointInvariantError({ - operation, - detail: "Computed turn diff result does not satisfy contract schema.", - }); - } - return emptyDiff; - } - - const threadContext = yield* projectionSnapshotQuery - .getThreadCheckpointContext(input.threadId) - .pipe(Effect.withSpan("checkpoint.turnDiff.lookupContext")); - if (Option.isNone(threadContext)) { - return yield* new CheckpointInvariantError({ - operation, - detail: `Thread '${input.threadId}' not found.`, - }); - } - - const maxTurnCount = threadContext.value.checkpoints.reduce( - (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), - 0, - ); - if (input.toTurnCount > maxTurnCount) { - return yield* new CheckpointUnavailableError({ - threadId: input.threadId, - turnCount: input.toTurnCount, - detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${maxTurnCount}.`, - }); - } - - const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; - if (!workspaceCwd) { - return yield* new CheckpointInvariantError({ - operation, - detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`, - }); - } - - const fromCheckpointRef = - input.fromTurnCount === 0 - ? checkpointRefForThreadTurn(input.threadId, 0) - : threadContext.value.checkpoints.find( - (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, - )?.checkpointRef; - if (!fromCheckpointRef) { - return yield* new CheckpointUnavailableError({ - threadId: input.threadId, - turnCount: input.fromTurnCount, - detail: `Checkpoint ref is unavailable for turn ${input.fromTurnCount}.`, - }); - } - - const toCheckpointRef = threadContext.value.checkpoints.find( - (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, - )?.checkpointRef; - if (!toCheckpointRef) { - return yield* new CheckpointUnavailableError({ - threadId: input.threadId, - turnCount: input.toTurnCount, - detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, - }); - } - - const diff = yield* checkpointStore - .diffCheckpoints({ - cwd: workspaceCwd, - fromCheckpointRef, - toCheckpointRef, - fallbackFromToHead: false, - ignoreWhitespace, - }) - .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); - - const turnDiff = buildTurnDiffResult(input, diff); - if (!isTurnDiffResult(turnDiff)) { - return yield* new CheckpointInvariantError({ - operation, - detail: "Computed turn diff result does not satisfy contract schema.", - }); - } - - return turnDiff; - }, - ); - - const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = Effect.fn( - "CheckpointDiffQuery.getFullThreadDiff", - )(function* (input) { - const operation = "CheckpointDiffQuery.getFullThreadDiff"; - const ignoreWhitespace = input.ignoreWhitespace ?? true; - yield* Effect.annotateCurrentSpan({ - "checkpoint.thread_id": input.threadId, - "checkpoint.from_turn_count": 0, - "checkpoint.to_turn_count": input.toTurnCount, - "checkpoint.ignore_whitespace": ignoreWhitespace, - "checkpoint.diff_kind": "full-thread", - }); - - if (input.toTurnCount === 0) { - const emptyDiff = buildTurnDiffResult( - { - threadId: input.threadId, - fromTurnCount: 0, - toTurnCount: 0, - }, - "", - ); - if (!isTurnDiffResult(emptyDiff)) { - return yield* new CheckpointInvariantError({ - operation, - detail: "Computed full thread diff result does not satisfy contract schema.", - }); - } - return emptyDiff satisfies OrchestrationGetFullThreadDiffResult; - } - - const threadContext = yield* projectionSnapshotQuery - .getFullThreadDiffContext(input.threadId, input.toTurnCount) - .pipe(Effect.withSpan("checkpoint.fullThread.lookupContext")); - - if (Option.isNone(threadContext)) { - return yield* new CheckpointInvariantError({ - operation, - detail: `Thread '${input.threadId}' not found.`, - }); - } - - if (input.toTurnCount > threadContext.value.latestCheckpointTurnCount) { - return yield* new CheckpointUnavailableError({ - threadId: input.threadId, - turnCount: input.toTurnCount, - detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${threadContext.value.latestCheckpointTurnCount}.`, - }); - } - - const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; - if (!workspaceCwd) { - return yield* new CheckpointInvariantError({ - operation, - detail: `Workspace path missing for thread '${input.threadId}' when computing full thread diff.`, - }); - } - - if (!threadContext.value.toCheckpointRef) { - return yield* new CheckpointUnavailableError({ - threadId: input.threadId, - turnCount: input.toTurnCount, - detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, - }); - } - - const diff = yield* checkpointStore - .diffCheckpoints({ - cwd: workspaceCwd, - fromCheckpointRef: checkpointRefForThreadTurn(input.threadId, 0), - toCheckpointRef: threadContext.value.toCheckpointRef as CheckpointRef, - fallbackFromToHead: false, - ignoreWhitespace, - }) - .pipe(Effect.withSpan("checkpoint.fullThread.diffCheckpoints")); - - const turnDiff = buildTurnDiffResult( - { - threadId: input.threadId, - fromTurnCount: 0, - toTurnCount: input.toTurnCount, - }, - diff, - ); - if (!isTurnDiffResult(turnDiff)) { - return yield* new CheckpointInvariantError({ - operation, - detail: "Computed full thread diff result does not satisfy contract schema.", - }); - } - - return turnDiff satisfies OrchestrationGetFullThreadDiffResult; - }); - - return { - getTurnDiff, - getFullThreadDiff, - } satisfies CheckpointDiffQueryShape; -}); - -export const CheckpointDiffQueryLive = Layer.effect(CheckpointDiffQuery, make); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index 10d77447..b975382a 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -152,6 +152,8 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { "", ].join("\n"), ); + yield* git(tmp, ["add", "Component.tsx"]); + yield* git(tmp, ["commit", "-m", "add component"]); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: fromCheckpointRef, @@ -202,5 +204,39 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { expect(whitespaceIgnoredDiff).not.toContain("+

Title

"); }), ); + + it.effect("does not capture untracked files into hidden checkpoint refs", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore; + const threadId = ThreadId.make("thread-checkpoint-store-untracked"); + const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: fromCheckpointRef, + }); + yield* writeTextFile(path.join(tmp, "README.md"), "# test\n\ntracked change\n"); + yield* writeTextFile(path.join(tmp, "UNTRACKED_SECRET.txt"), "super secret token\n"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: toCheckpointRef, + }); + + const diff = yield* checkpointStore.diffCheckpoints({ + cwd: tmp, + fromCheckpointRef, + toCheckpointRef, + ignoreWhitespace: false, + }); + + expect(diff).toContain("diff --git a/README.md b/README.md"); + expect(diff).toContain("+tracked change"); + expect(diff).not.toContain("UNTRACKED_SECRET"); + expect(diff).not.toContain("super secret token"); + }), + ); }); }); diff --git a/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts deleted file mode 100644 index bb574f45..00000000 --- a/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * CheckpointDiffQuery - Query interface for computed checkpoint diffs. - * - * Provides read-only diff operations across checkpoint snapshots used by - * orchestration APIs. - * - * @module CheckpointDiffQuery - */ -import type { - OrchestrationGetFullThreadDiffInput, - OrchestrationGetFullThreadDiffResult, - OrchestrationGetTurnDiffInput, - OrchestrationGetTurnDiffResult, -} from "@cafecode/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { CheckpointServiceError } from "../Errors.ts"; - -/** - * CheckpointDiffQueryShape - Service API for checkpoint diff queries. - */ -export interface CheckpointDiffQueryShape { - /** - * Read the patch diff for a single turn checkpoint transition. - * - * Verifies checkpoint availability in both projection state and filesystem. - */ - readonly getTurnDiff: ( - input: OrchestrationGetTurnDiffInput, - ) => Effect.Effect; - - /** - * Read the full patch diff across a thread range of checkpoints. - * - * Delegates to turn diff with `fromTurnCount = 0`. - */ - readonly getFullThreadDiff: ( - input: OrchestrationGetFullThreadDiffInput, - ) => Effect.Effect; -} - -/** - * CheckpointDiffQuery - Service tag for checkpoint diff queries. - */ -export class CheckpointDiffQuery extends Context.Service< - CheckpointDiffQuery, - CheckpointDiffQueryShape ->()("cafecode/checkpointing/Services/CheckpointDiffQuery") {} diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 5a97d543..8187cde0 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -1,4 +1,4 @@ -import { AuthSessionId } from "@cafecode/contracts"; +import { AuthSessionId } from "@cafecode/contracts/auth"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index ae33d9bc..3d2224da 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -16,6 +16,7 @@ import { import * as NetService from "@cafecode/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; +import { resolveBaseDir } from "../os-jank.ts"; import { resolveServerConfig } from "./config.ts"; const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); @@ -56,6 +57,15 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { return fd; }); + it.effect("defaults Cafe Code home to ~/.cafe-code", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + + expect(yield* resolveBaseDir(undefined)).toBe(join(NodeOS.homedir(), ".cafe-code")); + expect(yield* resolveBaseDir("")).toBe(join(NodeOS.homedir(), ".cafe-code")); + }), + ); + it.effect("falls back to effect/config values when flags are omitted", () => Effect.gen(function* () { const { join } = yield* Path.Path; @@ -369,7 +379,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { resolved.stateDir, resolved.logsDir, resolved.providerLogsDir, - resolved.terminalLogsDir, resolved.attachmentsDir, resolved.worktreesDir, path.dirname(resolved.serverLogPath), diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts index 6667f38b..645435a8 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -1,4 +1,8 @@ -import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@cafecode/contracts"; +import type { + AuthClientMetadata, + AuthClientSession, + AuthPairingLink, +} from "@cafecode/contracts/auth"; import * as DateTime from "effect/DateTime"; import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts"; diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index bcfc9064..f7d6c4f3 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -38,7 +38,6 @@ export interface ServerDerivedPaths { readonly serverTracePath: string; readonly providerLogsDir: string; readonly providerEventLogPath: string; - readonly terminalLogsDir: string; readonly anonymousIdPath: string; readonly environmentIdPath: string; readonly serverRuntimeStatePath: string; @@ -99,7 +98,6 @@ export const deriveServerPaths = Effect.fn(function* ( serverTracePath: join(logsDir, "server.trace.ndjson"), providerLogsDir, providerEventLogPath: join(providerLogsDir, "events.log"), - terminalLogsDir: join(logsDir, "terminals"), anonymousIdPath: join(stateDir, "anonymous-id"), environmentIdPath: join(stateDir, "environment-id"), serverRuntimeStatePath: join(stateDir, "server-runtime.json"), @@ -116,7 +114,6 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(derivedPaths.stateDir, { recursive: true }), fs.makeDirectory(derivedPaths.logsDir, { recursive: true }), fs.makeDirectory(derivedPaths.providerLogsDir, { recursive: true }), - fs.makeDirectory(derivedPaths.terminalLogsDir, { recursive: true }), fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }), fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index b10ff9aa..631d8af2 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -12,14 +12,9 @@ import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vitest"; -import type { - GitActionProgressEvent, - GitPreparePullRequestThreadInput, - ModelSelection, - ThreadId, -} from "@cafecode/contracts"; - -import { GitCommandError, TextGenerationError } from "@cafecode/contracts"; +import type { GitPreparePullRequestThreadInput, ThreadId } from "@cafecode/contracts"; + +import { GitCommandError } from "@cafecode/contracts"; import { type GitManagerShape } from "./GitManager.ts"; import { GitHubCliError, @@ -27,14 +22,12 @@ import { type GitHubPullRequestSummary, GitHubCli, } from "../sourceControl/GitHubCli.ts"; -import { type TextGenerationShape, TextGeneration } from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../config.ts"; -import { ServerSettingsService } from "../serverSettings.ts"; import { ProjectSetupScriptRunner, ProjectSetupScriptRunnerError, @@ -73,39 +66,6 @@ function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { }; } -interface FakeGitTextGeneration { - generateCommitMessage: (input: { - cwd: string; - branch: string | null; - stagedSummary: string; - stagedPatch: string; - includeBranch?: boolean; - modelSelection: ModelSelection; - }) => Effect.Effect< - { subject: string; body: string; branch?: string | undefined }, - TextGenerationError - >; - generatePrContent: (input: { - cwd: string; - baseBranch: string; - headBranch: string; - commitSummary: string; - diffSummary: string; - diffPatch: string; - modelSelection: ModelSelection; - }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; - generateBranchName: (input: { - cwd: string; - message: string; - modelSelection: ModelSelection; - }) => Effect.Effect<{ branch: string }, TextGenerationError>; - generateThreadTitle: (input: { - cwd: string; - message: string; - modelSelection: ModelSelection; - }) => Effect.Effect<{ title: string }, TextGenerationError>; -} - type FakePullRequest = NonNullable; function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null { @@ -312,78 +272,6 @@ function configureVisibleRemoteUrlWithLocalRewrite( }); } -function createTextGeneration(overrides: Partial = {}): TextGenerationShape { - const implementation: FakeGitTextGeneration = { - generateCommitMessage: (input) => - Effect.succeed({ - subject: "Implement stacked git actions", - body: "", - ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), - }), - generatePrContent: () => - Effect.succeed({ - title: "Add stacked git actions", - body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", - }), - generateBranchName: () => - Effect.succeed({ - branch: "update-workflow", - }), - generateThreadTitle: () => - Effect.succeed({ - title: "Update workflow", - }), - ...overrides, - }; - - return { - generateCommitMessage: (input) => - implementation.generateCommitMessage(input).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: "generateCommitMessage", - detail: "fake text generation failed", - ...(cause !== undefined ? { cause } : {}), - }), - ), - ), - generatePrContent: (input) => - implementation.generatePrContent(input).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: "generatePrContent", - detail: "fake text generation failed", - ...(cause !== undefined ? { cause } : {}), - }), - ), - ), - generateBranchName: (input) => - implementation.generateBranchName(input).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: "generateBranchName", - detail: "fake text generation failed", - ...(cause !== undefined ? { cause } : {}), - }), - ), - ), - generateThreadTitle: (input) => - implementation.generateThreadTitle(input).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: "generateThreadTitle", - detail: "fake text generation failed", - ...(cause !== undefined ? { cause } : {}), - }), - ), - ), - }; -} - function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { service: GitHubCliShape; ghCalls: string[]; @@ -615,27 +503,6 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }; } -function runStackedAction( - manager: GitManagerShape, - input: { - cwd: string; - action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; - actionId?: string; - commitMessage?: string; - featureBranch?: boolean; - filePaths?: readonly string[]; - }, - options?: Parameters[1], -) { - return manager.runStackedAction( - { - ...input, - actionId: input.actionId ?? "test-action-id", - }, - options, - ); -} - function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { return manager.resolvePullRequest(input); } @@ -649,17 +516,13 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; - textGeneration?: Partial; setupScriptRunner?: ProjectSetupScriptRunnerShape; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); - const textGeneration = createTextGeneration(input?.textGeneration); const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-", }); - const serverSettingsLayer = ServerSettingsService.layerTest(); - const vcsDriverLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), @@ -681,7 +544,6 @@ function makeManager(input?: { ); const managerLayer = Layer.mergeAll( - Layer.succeed(TextGeneration, textGeneration), Layer.succeed( ProjectSetupScriptRunner, input?.setupScriptRunner ?? { @@ -689,7 +551,6 @@ function makeManager(input?: { }, ), vcsDriverLayer, - serverSettingsLayer, ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); return makeGitManager().pipe( @@ -1348,1361 +1209,245 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("creates a commit when working tree is dirty", () => + it.effect("resolves pull requests from #number references", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 42, + title: "Resolve PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/resolve-pr", + state: "open", + }, + }, + }); + + const result = yield* resolvePullRequest(manager, { cwd: repoDir, - action: "commit", + reference: "#42", }); - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("created"); - expect(result.push.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("skipped_not_requested"); - expect(result.toast).toMatchObject({ - description: "Implement stacked git actions", - cta: { - kind: "run_action", - label: "Push", - action: { - kind: "push", - }, - }, + expect(result.pullRequest).toEqual({ + number: 42, + title: "Resolve PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseBranch: "main", + headBranch: "feature/resolve-pr", + state: "open", }); - expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/); - expect( - yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("Implement stacked git actions"); + expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true); }), ); - it.effect("uses custom commit message when provided", () => + it.effect("prepares pull request threads in local mode by checking out the PR branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); - let generatedCount = 0; + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); + fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); + yield* runGit(repoDir, ["add", "local.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); - const { manager } = yield* makeManager({ - textGeneration: { - generateCommitMessage: (input) => - Effect.sync(() => { - generatedCount += 1; - return { - subject: "this should not be used", - body: "", - ...(input.includeBranch ? { branch: "feature/unused" } : {}), - }; - }), + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 64, + title: "Local PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/64", + baseRefName: "main", + headRefName: "feature/pr-local", + state: "open", + }, }, }); - const result = yield* runStackedAction(manager, { + + const result = yield* preparePullRequestThread(manager, { cwd: repoDir, - action: "commit", - commitMessage: "feat: custom summary line\n\n- details from user", + reference: "#64", + mode: "local", }); - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("created"); - expect(result.commit.subject).toBe("feat: custom summary line"); - expect(generatedCount).toBe(0); - expect( - yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("feat: custom summary line"); - expect( - yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toContain("- details from user"); + expect(result.branch).toBe("feature/pr-local"); + expect(result.worktreePath).toBeNull(); + const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); + expect(branch).toBe("feature/pr-local"); + expect(ghCalls).toContain("pr checkout 64 --force"); }), ); - it.effect("commits only selected files when filePaths is provided", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n"); - fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n"); + it.effect( + "restores same-repository upstream tracking after local PR checkout without a remote ref", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-upstream"]); + fs.writeFileSync(path.join(repoDir, "upstream.txt"), "upstream\n"); + yield* runGit(repoDir, ["add", "upstream.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local upstream PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-upstream"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-upstream"]); + yield* runGit(repoDir, [ + "update-ref", + "-d", + "refs/remotes/origin/feature/pr-local-upstream", + ]); - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - filePaths: ["a.txt"], - }); + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 65, + title: "Local upstream PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/65", + baseRefName: "main", + headRefName: "feature/pr-local-upstream", + state: "open", + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + repositoryCloneUrls: { + "pingdotgg/codething-mvp": { + url: remoteDir, + sshUrl: remoteDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "65", + mode: "local", + }); - expect(result.commit.status).toBe("created"); + expect(result.worktreePath).toBeNull(); + expect(result.branch).toBe("feature/pr-local-upstream"); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("origin/feature/pr-local-upstream"); + }), + ); - // b.txt should remain in the working tree - const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe( - Effect.map((r) => r.stdout), - ); - expect(statusStdout).toContain("b.txt"); - expect(statusStdout).not.toContain("a.txt"); - }), + it.effect( + "restores same-repository upstream tracking when provider omits head repository metadata", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-no-head-repo"]); + fs.writeFileSync(path.join(repoDir, "no-head-repo.txt"), "upstream\n"); + yield* runGit(repoDir, ["add", "no-head-repo.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local PR branch without repo metadata"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-no-head-repo"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-no-head-repo"]); + yield* runGit(repoDir, [ + "update-ref", + "-d", + "refs/remotes/origin/feature/pr-local-no-head-repo", + ]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 66, + title: "Local upstream PR without repo metadata", + url: "https://github.com/pingdotgg/codething-mvp/pull/66", + baseRefName: "main", + headRefName: "feature/pr-local-no-head-repo", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "66", + mode: "local", + }); + + expect(result.worktreePath).toBeNull(); + expect(result.branch).toBe("feature/pr-local-no-head-repo"); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("origin/feature/pr-local-no-head-repo"); + }), ); - it.effect("creates feature branch, commits, and pushes with featureBranch option", () => + it.effect("prepares pull request threads in worktree mode on the PR head branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); - let generatedCount = 0; + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); + fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); + yield* runGit(repoDir, ["add", "worktree.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]); + yield* runGit(repoDir, ["checkout", "main"]); const { manager } = yield* makeManager({ - textGeneration: { - generateCommitMessage: (input) => - Effect.sync(() => { - generatedCount += 1; - return { - subject: "Implement stacked git actions", - body: "", - ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), - }; - }), + ghScenario: { + pullRequest: { + number: 77, + title: "Worktree PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/77", + baseRefName: "main", + headRefName: "feature/pr-worktree", + state: "open", + }, }, }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push", - featureBranch: true, - }); - expect(result.branch.status).toBe("created"); - expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); - expect(result.commit.status).toBe("created"); - expect(result.push.status).toBe("pushed"); - expect(result.toast).toMatchObject({ - description: "Implement stacked git actions", - cta: { - kind: "run_action", - label: "Create PR", - action: { - kind: "create_pr", - }, - }, + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "77", + mode: "worktree", }); - expect(result.toast.title).toMatch( - /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/, - ); - expect( - yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("feature/implement-stacked-git-actions"); - const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( - Effect.map((r) => r.stdout.trim()), - ); - const mergeBase = yield* runGit(repoDir, ["merge-base", "main", "HEAD"]).pipe( - Effect.map((r) => r.stdout.trim()), - ); - expect(mergeBase).toBe(mainSha); - expect(generatedCount).toBe(1); + expect(result.branch).toBe("feature/pr-worktree"); + expect(result.worktreePath).not.toBeNull(); + expect(fs.existsSync(result.worktreePath as string)).toBe(true); + const worktreeBranch = (yield* runGit(result.worktreePath as string, [ + "branch", + "--show-current", + ])).stdout.trim(); + expect(worktreeBranch).toBe("feature/pr-worktree"); }), ); - it.effect("featureBranch uses custom commit message and derives branch name", () => + it.effect("launches setup only when creating a new PR worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n"); - let generatedCount = 0; + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); + fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n"); + yield* runGit(repoDir, ["add", "setup.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); + yield* runGit(repoDir, ["checkout", "main"]); - const { manager } = yield* makeManager({ - textGeneration: { - generateCommitMessage: (input) => - Effect.sync(() => { - generatedCount += 1; - return { - subject: "unused", - body: "", - ...(input.includeBranch ? { branch: "feature/unused" } : {}), - }; - }), - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - featureBranch: true, - commitMessage: "feat: custom summary line\n\n- details from user", - }); - - expect(result.branch.status).toBe("created"); - expect(result.branch.name).toBe("feature/feat-custom-summary-line"); - expect(result.commit.status).toBe("created"); - expect(result.commit.subject).toBe("feat: custom summary line"); - expect(generatedCount).toBe(0); - - const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( - Effect.map((r) => r.stdout.trim()), - ); - const mergeBase = yield* runGit(repoDir, ["merge-base", "main", result.branch.name!]).pipe( - Effect.map((r) => r.stdout.trim()), - ); - expect(mergeBase).toBe(mainSha); - }), - ); - - it.effect("skips commit when there are no uncommitted changes", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("skipped_no_changes"); - expect(result.push.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("skipped_not_requested"); - }), - ); - - it.effect("featureBranch returns error when worktree is clean", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - - const { manager } = yield* makeManager(); - const errorMessage = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - featureBranch: true, - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - - expect(errorMessage).toContain("no changes to commit"); - }), - ); - - it.effect("commits and pushes with upstream auto-setup when needed", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("created"); - expect(result.push.status).toBe("pushed"); - expect(result.push.setUpstream).toBe(true); - expect( - yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("origin/feature/stacked-flow"); - }), - ); - - it.effect( - "pushes and creates PR from a no-upstream branch when local commits are ahead of base", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - "[]", - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 77, - title: "Add no-upstream PR flow", - url: "https://github.com/pingdotgg/codething-mvp/pull/77", - baseRefName: "main", - headRefName: "feature/no-upstream-pr", - }, - ]), - ], - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("created"); - expect(result.push.status).toBe("pushed"); - expect(result.push.setUpstream).toBe(true); - expect(result.pr.status).toBe("created"); - expect( - yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("origin/feature/no-upstream-pr"); - expect( - ghCalls.some((call) => - call.includes("pr create --base main --head feature/no-upstream-pr"), - ), - ).toBe(true); - }), - ); - - it.effect("skips push when branch is already up to date", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("skipped_no_changes"); - expect(result.push.status).toBe("skipped_up_to_date"); - }), - ); - - it.effect("pushes existing clean commits without rerunning commit logic", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n"); - yield* runGit(repoDir, ["add", "push-only.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "push", - }); - - expect(result.commit.status).toBe("skipped_not_requested"); - expect(result.push.status).toBe("pushed"); - expect(result.pr.status).toBe("skipped_not_requested"); - expect( - yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( - Effect.map((output) => output.stdout.trim()), - ), - ).toBe("origin/feature/push-only"); - }), - ); - - it.effect("pushes existing commits without committing dirty worktree changes", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "push-dirty.txt"), "push dirty\n"); - yield* runGit(repoDir, ["add", "push-dirty.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Push dirty branch"]); - fs.mkdirSync(path.join(repoDir, ".vercel")); - fs.writeFileSync(path.join(repoDir, ".vercel", "project.json"), "{}\n"); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "push", - }); - - expect(result.commit.status).toBe("skipped_not_requested"); - expect(result.push.status).toBe("pushed"); - expect(result.pr.status).toBe("skipped_not_requested"); - expect( - yield* runGit(repoDir, ["status", "--porcelain"]).pipe( - Effect.map((output) => output.stdout.trim()), - ), - ).toContain("?? .vercel/"); - expect( - yield* runGit(remoteDir, ["log", "-1", "--pretty=%s", "feature/push-dirty"]).pipe( - Effect.map((output) => output.stdout.trim()), - ), - ).toBe("Push dirty branch"); - }), - ); - - it.effect("create_pr pushes a clean branch before creating the PR when needed", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n"); - yield* runGit(repoDir, ["add", "create-pr-only.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - "[]", - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 303, - title: "Create PR only branch", - url: "https://github.com/pingdotgg/codething-mvp/pull/303", - baseRefName: "main", - headRefName: "feature/create-pr-only", - }, - ]), - ], - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "create_pr", - }); - - expect(result.commit.status).toBe("skipped_not_requested"); - expect(result.push.status).toBe("pushed"); - expect(result.push.setUpstream).toBe(true); - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(303); - expect( - ghCalls.some((call) => - call.includes("pr create --base main --head feature/create-pr-only"), - ), - ).toBe(true); - }), - ); - - it.effect("create_pr falls back to main when source control provider detection fails", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]); - fs.writeFileSync(path.join(repoDir, "provider-fallback.txt"), "fallback\n"); - yield* runGit(repoDir, ["add", "provider-fallback.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - "[]", - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 404, - title: "Provider fallback", - url: "https://github.com/pingdotgg/codething-mvp/pull/404", - baseRefName: "main", - headRefName: "feature/provider-fallback", - }, - ]), - ], - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "create_pr", - }); - - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(404); - expect( - ghCalls.some((call) => - call.includes("pr create --base main --head feature/provider-fallback"), - ), - ).toBe(true); - }), - ); - - it.effect("returns existing PR metadata for commit/push/pr action", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 42, - title: "Existing PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/existing-pr", - }, - ]), - ], - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(42); - expect(result.toast).toEqual({ - title: "Opened PR #42", - description: "Existing PR", - cta: { - kind: "open_pr", - label: "View PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - }, - }); - expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); - }), - ); - - it.effect( - "returns existing cross-repo PR metadata using the fork owner selector", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork-seed", - "git@github.com:octocat/codething-mvp.git", - forkDir, - ); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 142, - title: "Existing fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/142", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - ], - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(142); - expect( - ghCalls.some((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), - ), - ).toBe(true); - expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); - }), - 20_000, - ); - - it.effect( - "returns the correct existing PR when a slash remote checks out to a synthetic local alias", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const upstreamDir = yield* createBareRemote(); - yield* configureRemote(repoDir, "origin", originDir, "origin"); - yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); - - yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); - yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); - yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "origin", - "git@github.com:pingdotgg/codething-mvp.git", - originDir, - ); - yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "my-org/upstream", - "ssh://git@github.com/pingdotgg/codething-mvp.git", - upstreamDir, - ); - yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); - yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - // @effect-diagnostics-next-line preferSchemaOverJson:off - "effect-atom": JSON.stringify([ - { - number: 1618, - title: "Correct PR", - url: "https://github.com/cafeai/cafe-code/pull/1618", - baseRefName: "main", - headRefName: "effect-atom", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/cafeai/cafe-code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/cafeai/cafe-code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/cafeai/cafe-code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(1618); - expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( - false, - ); - }), - 20_000, - ); - - it.effect( - "prefers owner-qualified selectors before bare branch names for cross-repo PRs", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork-seed", - "git@github.com:octocat/codething-mvp.git", - forkDir, - ); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - // @effect-diagnostics-next-line preferSchemaOverJson:off - "t3code/pr-142/statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: JSON.stringify([ - { - number: 41, - title: "Unrelated same-repo PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/41", - baseRefName: "main", - headRefName: "statemachine", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "octocat:statemachine": JSON.stringify([ - { - number: 142, - title: "Existing fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/142", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": JSON.stringify([]), - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(142); - - const ownerSelectorCallIndex = ghCalls.findIndex((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), - ); - expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); - expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); - }), - 25_000, - ); - - it.effect( - "stops probing head selectors after finding an existing PR", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork-seed", - "git@github.com:octocat/codething-mvp.git", - forkDir, - ); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - // @effect-diagnostics-next-line preferSchemaOverJson:off - "octocat:statemachine": JSON.stringify([ - { - number: 142, - title: "Existing fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/142", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "t3code/pr-142/statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: JSON.stringify([]), - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(142); - - const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); - expect(openLookupCalls).toHaveLength(1); - expect(openLookupCalls[0]).toContain( - "pr list --head octocat:statemachine --state open --limit 1", - ); - }), - 25_000, - ); - - it.effect("creates PR when one does not already exist", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); - yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - "[]", - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 88, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/88", - baseRefName: "main", - headRefName: "feature-create-pr", - }, - ]), - ], - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(88); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); - expect( - ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), - ).toBe(true); - expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); - }), - ); - - it.effect( - "creates a new PR instead of reusing an unrelated fork PR with the same head branch", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 1661, - title: "Fork PR with same branch name", - url: "https://github.com/cafeai/cafe-code/pull/1661", - baseRefName: "main", - headRefName: "feature/no-fork-match", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "lnieuwenhuis/t3code", - }, - headRepositoryOwner: { - login: "lnieuwenhuis", - }, - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 188, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - baseRefName: "main", - headRefName: "feature/no-fork-match", - state: "OPEN", - isCrossRepository: false, - }, - ]), - ], - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(188); - expect(result.toast).toEqual({ - title: "Created PR #188", - description: "Add stacked git actions", - cta: { - kind: "open_pr", - label: "View PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - }, - }); - expect( - ghCalls.some((call) => - call.includes("pr create --base main --head feature/no-fork-match"), - ), - ).toBe(true); - }), - ); - - it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork-seed", - "git@github.com:octocat/codething-mvp.git", - forkDir, - ); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequenceByHeadSelector: { - "octocat:statemachine": [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 188, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - ], - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": [JSON.stringify([])], - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: [JSON.stringify([])], - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(188); - expect( - ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")), - ).toBe(true); - expect( - ghCalls.some((call) => - call.includes("pr create --base statemachine --head octocat:statemachine"), - ), - ).toBe(false); - }), - ); - - it.effect("rejects push/pr actions from detached HEAD", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]); - - const { manager } = yield* makeManager(); - const errorMessage = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push", - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - expect(errorMessage).toContain("detached HEAD"); - }), - ); - - it.effect("surfaces missing gh binary errors", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); - - const { manager } = yield* makeManager({ - ghScenario: { - failWith: new GitHubCliError({ - operation: "execute", - detail: "GitHub CLI (`gh`) is required but not available on PATH.", - }), - }, - }); - - const errorMessage = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - expect(errorMessage).toContain("GitHub CLI (`gh`) is required"); - }), - ); - - it.effect("surfaces gh auth errors with guidance", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); - - const { manager } = yield* makeManager({ - ghScenario: { - failWith: new GitHubCliError({ - operation: "execute", - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", - }), - }, - }); - - const errorMessage = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - expect(errorMessage).toContain("gh auth login"); - }), - ); - - it.effect("resolves pull requests from #number references", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 42, - title: "Resolve PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/resolve-pr", - state: "open", - }, - }, - }); - - const result = yield* resolvePullRequest(manager, { - cwd: repoDir, - reference: "#42", - }); - - expect(result.pullRequest).toEqual({ - number: 42, - title: "Resolve PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseBranch: "main", - headBranch: "feature/resolve-pr", - state: "open", - }); - expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true); - }), - ); - - it.effect("prepares pull request threads in local mode by checking out the PR branch", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); - fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); - yield* runGit(repoDir, ["add", "local.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 64, - title: "Local PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/64", - baseRefName: "main", - headRefName: "feature/pr-local", - state: "open", - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "#64", - mode: "local", - }); - - expect(result.branch).toBe("feature/pr-local"); - expect(result.worktreePath).toBeNull(); - const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); - expect(branch).toBe("feature/pr-local"); - expect(ghCalls).toContain("pr checkout 64 --force"); - }), - ); - - it.effect( - "restores same-repository upstream tracking after local PR checkout without a remote ref", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-upstream"]); - fs.writeFileSync(path.join(repoDir, "upstream.txt"), "upstream\n"); - yield* runGit(repoDir, ["add", "upstream.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Local upstream PR branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-upstream"]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-upstream"]); - yield* runGit(repoDir, [ - "update-ref", - "-d", - "refs/remotes/origin/feature/pr-local-upstream", - ]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 65, - title: "Local upstream PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/65", - baseRefName: "main", - headRefName: "feature/pr-local-upstream", - state: "open", - isCrossRepository: false, - headRepositoryNameWithOwner: "pingdotgg/codething-mvp", - headRepositoryOwnerLogin: "pingdotgg", - }, - repositoryCloneUrls: { - "pingdotgg/codething-mvp": { - url: remoteDir, - sshUrl: remoteDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "65", - mode: "local", - }); - - expect(result.worktreePath).toBeNull(); - expect(result.branch).toBe("feature/pr-local-upstream"); - expect( - (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), - ).toBe("origin/feature/pr-local-upstream"); - }), - ); - - it.effect( - "restores same-repository upstream tracking when provider omits head repository metadata", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-no-head-repo"]); - fs.writeFileSync(path.join(repoDir, "no-head-repo.txt"), "upstream\n"); - yield* runGit(repoDir, ["add", "no-head-repo.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Local PR branch without repo metadata"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-no-head-repo"]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-no-head-repo"]); - yield* runGit(repoDir, [ - "update-ref", - "-d", - "refs/remotes/origin/feature/pr-local-no-head-repo", - ]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 66, - title: "Local upstream PR without repo metadata", - url: "https://github.com/pingdotgg/codething-mvp/pull/66", - baseRefName: "main", - headRefName: "feature/pr-local-no-head-repo", - state: "open", - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "66", - mode: "local", - }); - - expect(result.worktreePath).toBeNull(); - expect(result.branch).toBe("feature/pr-local-no-head-repo"); - expect( - (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), - ).toBe("origin/feature/pr-local-no-head-repo"); - }), - ); - - it.effect("prepares pull request threads in worktree mode on the PR head branch", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); - fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); - yield* runGit(repoDir, ["add", "worktree.txt"]); - yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); - yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]); - yield* runGit(repoDir, ["checkout", "main"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 77, - title: "Worktree PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/77", - baseRefName: "main", - headRefName: "feature/pr-worktree", - state: "open", - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "77", - mode: "worktree", - }); - - expect(result.branch).toBe("feature/pr-worktree"); - expect(result.worktreePath).not.toBeNull(); - expect(fs.existsSync(result.worktreePath as string)).toBe(true); - const worktreeBranch = (yield* runGit(result.worktreePath as string, [ - "branch", - "--show-current", - ])).stdout.trim(); - expect(worktreeBranch).toBe("feature/pr-worktree"); - }), - ); - - it.effect("launches setup only when creating a new PR worktree", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); - fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n"); - yield* runGit(repoDir, ["add", "setup.txt"]); - yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); - yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); - yield* runGit(repoDir, ["checkout", "main"]); - - const setupCalls: ProjectSetupScriptRunnerInput[] = []; + const setupCalls: ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { @@ -3136,7 +1881,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("does not fail PR worktree prep when setup terminal startup fails", () => + it.effect("does not fail PR worktree prep when setup startup fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -3164,7 +1909,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, setupScriptRunner: { runForThread: () => - Effect.fail(new ProjectSetupScriptRunnerError({ message: "terminal start failed" })), + Effect.fail(new ProjectSetupScriptRunnerError({ message: "setup start failed" })), }, }); @@ -3212,196 +1957,4 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(errorMessage).toContain("already checked out in the main repo"); }), ); - - it.effect("emits ordered progress events for commit hooks", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n"); - fs.writeFileSync( - path.join(repoDir, ".git", "hooks", "pre-commit"), - '#!/bin/sh\necho "hook: start" >&2\nsleep 0.05\necho "hook: end" >&2\n', - { mode: 0o755 }, - ); - - const { manager } = yield* makeManager(); - const events: GitActionProgressEvent[] = []; - - const result = yield* runStackedAction( - manager, - { - cwd: repoDir, - action: "commit", - }, - { - actionId: "action-1", - progressReporter: { - publish: (event) => - Effect.sync(() => { - events.push(event); - }), - }, - }, - ); - - expect(result.commit.status).toBe("created"); - expect(events.map((event) => event.kind)).toContain("action_started"); - expect(events).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "phase_started", - phase: "commit", - }), - expect.objectContaining({ - kind: "hook_started", - hookName: "pre-commit", - }), - expect.objectContaining({ - kind: "hook_output", - text: "hook: start", - }), - expect.objectContaining({ - kind: "hook_output", - text: "hook: end", - }), - expect.objectContaining({ - kind: "hook_finished", - hookName: "pre-commit", - }), - expect.objectContaining({ - kind: "action_finished", - }), - ]), - ); - }), - ); - - it.effect("emits action_failed when a commit hook rejects", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "hook-failure.txt"), "broken\n"); - fs.writeFileSync( - path.join(repoDir, ".git", "hooks", "pre-commit"), - '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n', - { mode: 0o755 }, - ); - - const { manager } = yield* makeManager(); - const events: GitActionProgressEvent[] = []; - - const errorMessage = yield* runStackedAction( - manager, - { - cwd: repoDir, - action: "commit", - }, - { - actionId: "action-2", - progressReporter: { - publish: (event) => - Effect.sync(() => { - events.push(event); - }), - }, - }, - ).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - - expect(errorMessage).toContain("hook: fail"); - expect(events).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "hook_started", - hookName: "pre-commit", - }), - expect.objectContaining({ - kind: "action_failed", - phase: "commit", - }), - ]), - ); - }), - ); - - it.effect("create_pr emits only the PR phase when the branch is already pushed", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n"); - yield* runGit(repoDir, ["add", "pr-only.txt"]); - yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 201, - title: "PR only branch", - url: "https://github.com/pingdotgg/codething-mvp/pull/201", - baseRefName: "main", - headRefName: "feature/pr-only-follow-up", - state: "OPEN", - isCrossRepository: false, - }, - ]), - ], - }, - }); - const events: GitActionProgressEvent[] = []; - - const result = yield* runStackedAction( - manager, - { - cwd: repoDir, - action: "create_pr", - }, - { - actionId: "action-pr-only", - progressReporter: { - publish: (event) => - Effect.sync(() => { - events.push(event); - }), - }, - }, - ); - - expect(result.commit.status).toBe("skipped_not_requested"); - expect(result.push.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("created"); - expect( - events.filter( - (event): event is Extract => - event.kind === "phase_started", - ), - ).toEqual([ - expect.objectContaining({ - kind: "phase_started", - phase: "pr", - label: "Preparing PR...", - }), - expect.objectContaining({ - kind: "phase_started", - phase: "pr", - label: "Generating PR content...", - }), - expect.objectContaining({ - kind: "phase_started", - phase: "pr", - label: "Creating pull request...", - }), - ]); - }), - ); }); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 941301a0..2a46ceb8 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,5 +1,3 @@ -import { randomUUID } from "node:crypto"; - import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -11,58 +9,33 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; -import * as Path from "effect/Path"; -import * as Ref from "effect/Ref"; import { - GitActionProgressEvent, - GitActionProgressPhase, GitCommandError, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, GitResolvePullRequestResult, - GitRunStackedActionInput, - GitRunStackedActionResult, - GitStackedAction, VcsStatusInput, type VcsStatusLocalResult, type VcsStatusRemoteResult, VcsStatusResult, - ModelSelection, } from "@cafecode/contracts"; import { detectSourceControlProviderFromGitRemoteUrl, LEGACY_WORKTREE_BRANCH_PREFIX, mergeGitStatusParts, - resolveAutoFeatureBranchName, sanitizeBranchFragment, - sanitizeFeatureBranchName, WORKTREE_BRANCH_PREFIX, } from "@cafecode/shared/git"; -import { - getChangeRequestTerminologyForKind, - type ChangeRequestTerminology, -} from "@cafecode/shared/sourceControl"; import { GitManagerError } from "@cafecode/contracts"; -import { TextGeneration } from "../textGeneration/TextGeneration.ts"; import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; -import { ServerSettingsService } from "../serverSettings.ts"; import type { GitManagerServiceError } from "@cafecode/contracts"; import { GitVcsDriver, type GitStatusDetails } from "../vcs/GitVcsDriver.ts"; import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; import type { ChangeRequest } from "@cafecode/contracts"; -export interface GitActionProgressReporter { - readonly publish: (event: GitActionProgressEvent) => Effect.Effect; -} - -export interface GitRunStackedActionOptions { - readonly actionId?: string; - readonly progressReporter?: GitActionProgressReporter; -} - export interface GitManagerShape { readonly status: ( input: VcsStatusInput, @@ -82,25 +55,14 @@ export interface GitManagerShape { readonly preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Effect.Effect; - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitRunStackedActionOptions, - ) => Effect.Effect; } export class GitManager extends Context.Service()( "cafecode/git/GitManager", ) {} -const COMMIT_TIMEOUT_MS = 10 * 60_000; -const MAX_PROGRESS_TEXT_LENGTH = 500; -const SHORT_SHA_LENGTH = 7; -const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; -type StripProgressContext = T extends any ? Omit : never; -type GitActionProgressPayload = StripProgressContext; -type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; function isNotGitRepositoryError(error: GitCommandError): boolean { return error.message.toLowerCase().includes("not a git repository"); @@ -331,132 +293,6 @@ function gitManagerError(operation: string, detail: string, cause?: unknown): Gi }); } -function limitContext(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}\n\n[truncated]`; -} - -function shortenSha(sha: string | undefined): string | null { - if (!sha) return null; - return sha.slice(0, SHORT_SHA_LENGTH); -} - -function truncateText( - value: string | undefined, - maxLength = TOAST_DESCRIPTION_MAX, -): string | undefined { - if (!value) return undefined; - if (value.length <= maxLength) return value; - if (maxLength <= 3) return "...".slice(0, maxLength); - return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; -} - -function withDescription(title: string, description: string | undefined) { - return description ? { title, description } : { title }; -} - -function summarizeGitActionResult( - result: Pick, - terms: ChangeRequestTerminology, -): { - title: string; - description?: string; -} { - if (result.pr.status === "created" || result.pr.status === "opened_existing") { - const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; - const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`; - return withDescription(title, truncateText(result.pr.title)); - } - - if (result.push.status === "pushed") { - const shortSha = shortenSha(result.commit.commitSha); - const branch = result.push.upstreamBranch ?? result.push.branch; - const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; - const branchPart = branch ? ` to ${branch}` : ""; - return withDescription( - `Pushed${pushedCommitPart}${branchPart}`, - truncateText(result.commit.subject), - ); - } - - if (result.commit.status === "created") { - const shortSha = shortenSha(result.commit.commitSha); - const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; - return withDescription(title, truncateText(result.commit.subject)); - } - - return { title: "Done" }; -} - -function sanitizeCommitMessage(generated: { - subject: string; - body: string; - branch?: string | undefined; -}): { - subject: string; - body: string; - branch?: string | undefined; -} { - const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - const subject = rawSubject.replace(/[.]+$/g, "").trim(); - const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; - return { - subject: safeSubject, - body: generated.body.trim(), - ...(generated.branch !== undefined ? { branch: generated.branch } : {}), - }; -} - -function sanitizeProgressText(value: string): string | null { - const trimmed = value.trim(); - if (trimmed.length === 0) { - return null; - } - if (trimmed.length <= MAX_PROGRESS_TEXT_LENGTH) { - return trimmed; - } - return trimmed.slice(0, MAX_PROGRESS_TEXT_LENGTH).trimEnd(); -} - -interface CommitAndBranchSuggestion { - subject: string; - body: string; - branch?: string | undefined; - commitMessage: string; -} - -function isCommitAction( - action: GitStackedAction, -): action is "commit" | "commit_push" | "commit_push_pr" { - return action === "commit" || action === "commit_push" || action === "commit_push_pr"; -} - -function formatCommitMessage(subject: string, body: string): string { - const trimmedBody = body.trim(); - if (trimmedBody.length === 0) { - return subject; - } - return `${subject}\n\n${trimmedBody}`; -} - -function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { - const normalized = raw.replace(/\r\n/g, "\n").trim(); - if (normalized.length === 0) { - return null; - } - - const [firstLine, ...rest] = normalized.split("\n"); - const subject = firstLine?.trim() ?? ""; - if (subject.length === 0) { - return null; - } - - return { - subject, - body: rest.join("\n").trim(), - }; -} - function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { @@ -532,34 +368,9 @@ function toPullRequestHeadRemoteInfo(pr: { export const makeGitManager = Effect.fn("makeGitManager")(function* () { const gitCore = yield* GitVcsDriver; const sourceControlProviders = yield* SourceControlProviderRegistry; - const textGeneration = yield* TextGeneration; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); - const serverSettingsService = yield* ServerSettingsService; - - const createProgressEmitter = ( - input: { cwd: string; action: GitStackedAction }, - options?: GitRunStackedActionOptions, - ) => { - const actionId = options?.actionId ?? randomUUID(); - const reporter = options?.progressReporter; - - const emit = (event: GitActionProgressPayload) => - reporter - ? reporter.publish({ - actionId, - cwd: input.cwd, - action: input.action, - ...event, - } as GitActionProgressEvent) - : Effect.void; - - return { - actionId, - emit, - }; - }; const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")( function* ( @@ -694,9 +505,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ), ); const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const canonicalizeExistingPath = (value: string) => fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value))); const normalizeStatusCacheKey = canonicalizeExistingPath; @@ -891,45 +700,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } satisfies BranchHeadContext; }); - const findOpenPr = Effect.fn("findOpenPr")(function* ( - cwd: string, - headContext: Pick< - BranchHeadContext, - | "headBranch" - | "headSelectors" - | "headRepositoryNameWithOwner" - | "headRepositoryOwnerLogin" - | "isCrossRepository" - >, - ) { - for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ - cwd, - headSelector, - state: "open", - limit: 1, - }); - const normalizedPullRequests = pullRequests.map(toPullRequestInfo); - - const firstPullRequest = normalizedPullRequests.find((pullRequest) => - matchesBranchHeadContext(pullRequest, headContext), - ); - if (firstPullRequest) { - return { - number: firstPullRequest.number, - title: firstPullRequest.title, - url: firstPullRequest.url, - baseRefName: firstPullRequest.baseRefName, - headRefName: firstPullRequest.headRefName, - state: "open", - updatedAt: Option.none(), - } satisfies PullRequestInfo; - } - } - - return null; - }); - const findLatestPr = Effect.fn("findLatestPr")(function* ( cwd: string, details: { branch: string; upstreamRef: string | null }, @@ -962,390 +732,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return parsed[0] ?? null; }); - const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( - cwd: string, - result: Pick, - ) { - const terms = yield* sourceControlProvider(cwd).pipe( - Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), - Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), - ); - const summary = summarizeGitActionResult(result, terms); - let latestOpenPr: PullRequestInfo | null = null; - let currentBranchIsDefault = false; - let finalBranchContext: { - branch: string; - upstreamRef: string | null; - hasUpstream: boolean; - } | null = null; - - if (result.action !== "commit") { - const finalStatus = yield* gitCore.statusDetails(cwd); - if (finalStatus.branch) { - finalBranchContext = { - branch: finalStatus.branch, - upstreamRef: finalStatus.upstreamRef, - hasUpstream: finalStatus.hasUpstream, - }; - currentBranchIsDefault = finalStatus.isDefaultBranch; - } - } - - const explicitResultPr = - (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url - ? { - url: result.pr.url, - state: "open" as const, - } - : null; - const shouldLookupExistingOpenPr = - (result.action === "commit_push" || result.action === "push") && - result.push.status === "pushed" && - result.branch.status !== "created" && - !currentBranchIsDefault && - explicitResultPr === null && - finalBranchContext?.hasUpstream === true; - - if (shouldLookupExistingOpenPr && finalBranchContext) { - latestOpenPr = yield* resolveBranchHeadContext(cwd, { - branch: finalBranchContext.branch, - upstreamRef: finalBranchContext.upstreamRef, - }).pipe( - Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), - Effect.catch(() => Effect.succeed(null)), - ); - } - - const openPr = latestOpenPr ?? explicitResultPr; - - const cta = - result.action === "commit" && result.commit.status === "created" - ? { - kind: "run_action" as const, - label: "Push", - action: { kind: "push" as const }, - } - : (result.action === "push" || - result.action === "create_pr" || - result.action === "commit_push" || - result.action === "commit_push_pr") && - openPr?.url && - (!currentBranchIsDefault || - result.pr.status === "created" || - result.pr.status === "opened_existing") - ? { - kind: "open_pr" as const, - label: `View ${terms.shortLabel}`, - url: openPr.url, - } - : (result.action === "push" || result.action === "commit_push") && - result.push.status === "pushed" && - !currentBranchIsDefault - ? { - kind: "run_action" as const, - label: `Create ${terms.shortLabel}`, - action: { kind: "create_pr" as const }, - } - : { - kind: "none" as const, - }; - - return { - ...summary, - cta, - }; - }); - - const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( - cwd: string, - branch: string, - upstreamRef: string | null, - headContext: Pick, - ) { - const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); - if (configured) return configured; - - if (upstreamRef && !headContext.isCrossRepository) { - const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { - remoteName: headContext.remoteName, - }); - if (upstreamBranch.length > 0 && upstreamBranch !== branch) { - return upstreamBranch; - } - } - - const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( - Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), - Effect.catch(() => Effect.succeed(null)), - ); - if (defaultFromProvider) { - return defaultFromProvider; - } - - return "main"; - }); - - const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( - function* (input: { - cwd: string; - branch: string | null; - commitMessage?: string; - /** When true, also produce a semantic feature branch name. */ - includeBranch?: boolean; - filePaths?: readonly string[]; - modelSelection: ModelSelection; - }) { - const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); - if (!context) { - return null; - } - - const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); - if (customCommit) { - return { - subject: customCommit.subject, - body: customCommit.body, - ...(input.includeBranch - ? { branch: sanitizeFeatureBranchName(customCommit.subject) } - : {}), - commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), - }; - } - - const generated = yield* textGeneration - .generateCommitMessage({ - cwd: input.cwd, - branch: input.branch, - stagedSummary: limitContext(context.stagedSummary, 8_000), - stagedPatch: limitContext(context.stagedPatch, 50_000), - ...(input.includeBranch ? { includeBranch: true } : {}), - modelSelection: input.modelSelection, - }) - .pipe(Effect.map((result) => sanitizeCommitMessage(result))); - - return { - subject: generated.subject, - body: generated.body, - ...(generated.branch !== undefined ? { branch: generated.branch } : {}), - commitMessage: formatCommitMessage(generated.subject, generated.body), - }; - }, - ); - - const runCommitStep = Effect.fn("runCommitStep")(function* ( - modelSelection: ModelSelection, - cwd: string, - action: "commit" | "commit_push" | "commit_push_pr", - branch: string | null, - commitMessage?: string, - preResolvedSuggestion?: CommitAndBranchSuggestion, - filePaths?: readonly string[], - progressReporter?: GitActionProgressReporter, - actionId?: string, - ) { - const emit = (event: GitActionProgressPayload) => - progressReporter && actionId - ? progressReporter.publish({ - actionId, - cwd, - action, - ...event, - } as GitActionProgressEvent) - : Effect.void; - - let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion; - if (!suggestion) { - const needsGeneration = !commitMessage?.trim(); - if (needsGeneration) { - yield* emit({ - kind: "phase_started", - phase: "commit", - label: "Generating commit message...", - }); - } - suggestion = yield* resolveCommitAndBranchSuggestion({ - cwd, - branch, - ...(commitMessage ? { commitMessage } : {}), - ...(filePaths ? { filePaths } : {}), - modelSelection, - }); - } - if (!suggestion) { - return { status: "skipped_no_changes" as const }; - } - - yield* emit({ - kind: "phase_started", - phase: "commit", - label: "Committing...", - }); - - let currentHookName: string | null = null; - const commitProgress = - progressReporter && actionId - ? { - onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => { - const sanitized = sanitizeProgressText(text); - if (!sanitized) { - return Effect.void; - } - return emit({ - kind: "hook_output", - hookName: currentHookName, - stream, - text: sanitized, - }); - }, - onHookStarted: (hookName: string) => { - currentHookName = hookName; - return emit({ - kind: "hook_started", - hookName, - }); - }, - onHookFinished: ({ - hookName, - exitCode, - durationMs, - }: { - hookName: string; - exitCode: number | null; - durationMs: number | null; - }) => { - if (currentHookName === hookName) { - currentHookName = null; - } - return emit({ - kind: "hook_finished", - hookName, - exitCode, - durationMs, - }); - }, - } - : null; - const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { - timeoutMs: COMMIT_TIMEOUT_MS, - ...(commitProgress ? { progress: commitProgress } : {}), - }); - if (currentHookName !== null) { - yield* emit({ - kind: "hook_finished", - hookName: currentHookName, - exitCode: 0, - durationMs: null, - }); - currentHookName = null; - } - return { - status: "created" as const, - commitSha, - subject: suggestion.subject, - }; - }); - - const runPrStep = Effect.fn("runPrStep")(function* ( - modelSelection: ModelSelection, - cwd: string, - fallbackBranch: string | null, - emit: GitActionProgressEmitter, - ) { - const provider = yield* sourceControlProvider(cwd); - const terms = getChangeRequestTerminologyForKind(provider.kind); - const details = yield* gitCore.statusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - return yield* gitManagerError( - "runPrStep", - "Cannot create a pull request from detached HEAD.", - ); - } - if (!details.hasUpstream) { - return yield* gitManagerError( - "runPrStep", - "Current branch has not been pushed. Push before creating a PR.", - ); - } - - const headContext = yield* resolveBranchHeadContext(cwd, { - branch, - upstreamRef: details.upstreamRef, - }); - - const existing = yield* findOpenPr(cwd, headContext); - if (existing) { - return { - status: "opened_existing" as const, - url: existing.url, - number: existing.number, - baseBranch: existing.baseRefName, - headBranch: existing.headRefName, - title: existing.title, - }; - } - - const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); - yield* emit({ - kind: "phase_started", - phase: "pr", - label: `Generating ${terms.shortLabel} content...`, - }); - const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); - - const generated = yield* textGeneration.generatePrContent({ - cwd, - baseBranch, - headBranch: headContext.headBranch, - commitSummary: limitContext(rangeContext.commitSummary, 20_000), - diffSummary: limitContext(rangeContext.diffSummary, 20_000), - diffPatch: limitContext(rangeContext.diffPatch, 60_000), - modelSelection, - }); - - const bodyFile = path.join(tempDir, `cafecode-pr-body-${process.pid}-${randomUUID()}.md`); - yield* fileSystem - .writeFileString(bodyFile, generated.body) - .pipe( - Effect.mapError((cause) => - gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), - ), - ); - yield* emit({ - kind: "phase_started", - phase: "pr", - label: `Creating ${terms.singular}...`, - }); - yield* provider - .createChangeRequest({ - cwd, - baseRefName: baseBranch, - headSelector: headContext.preferredHeadSelector, - title: generated.title, - bodyFile, - }) - .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); - - const created = yield* findOpenPr(cwd, headContext); - if (!created) { - return { - status: "created" as const, - baseBranch, - headBranch: headContext.headBranch, - title: generated.title, - }; - } - - return { - status: "created" as const, - url: created.url, - number: created.number, - baseBranch: created.baseRefName, - headBranch: created.headRefName, - title: created.title, - }; - }); - const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); return yield* Cache.get(localStatusResultCache, cacheKey); @@ -1563,221 +949,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); }); - const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( - modelSelection: ModelSelection, - cwd: string, - branch: string | null, - commitMessage?: string, - filePaths?: readonly string[], - ) { - const suggestion = yield* resolveCommitAndBranchSuggestion({ - cwd, - branch, - ...(commitMessage ? { commitMessage } : {}), - ...(filePaths ? { filePaths } : {}), - includeBranch: true, - modelSelection, - }); - if (!suggestion) { - return yield* gitManagerError( - "runFeatureBranchStep", - "Cannot create a feature branch because there are no changes to commit.", - ); - } - - const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); - const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); - const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); - - yield* gitCore.createRef({ cwd, refName: resolvedBranch }); - yield* Effect.scoped(gitCore.switchRef({ cwd, refName: resolvedBranch })); - - return { - branchStep: { status: "created" as const, name: resolvedBranch }, - resolvedCommitMessage: suggestion.commitMessage, - resolvedCommitSuggestion: suggestion, - }; - }); - - const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( - function* (input, options) { - const progress = createProgressEmitter(input, options); - const currentPhase = yield* Ref.make>(Option.none()); - - const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return< - GitRunStackedActionResult, - GitManagerServiceError - > { - const initialStatus = yield* gitCore.statusDetails(input.cwd); - const wantsCommit = isCommitAction(input.action); - const wantsPush = - input.action === "push" || - input.action === "commit_push" || - input.action === "commit_push_pr" || - (input.action === "create_pr" && - (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); - const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; - - if (input.featureBranch && !wantsCommit) { - return yield* gitManagerError( - "runStackedAction", - "Feature-branch checkout is only supported for commit actions.", - ); - } - if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { - return yield* gitManagerError( - "runStackedAction", - "Commit local changes before creating a PR.", - ); - } - - const phases: GitActionProgressPhase[] = [ - ...(input.featureBranch ? (["branch"] as const) : []), - ...(wantsCommit ? (["commit"] as const) : []), - ...(wantsPush ? (["push"] as const) : []), - ...(wantsPr ? (["pr"] as const) : []), - ]; - - yield* progress.emit({ - kind: "action_started", - phases, - }); - - if (!input.featureBranch && wantsPush && !initialStatus.branch) { - return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); - } - if (!input.featureBranch && wantsPr && !initialStatus.branch) { - return yield* gitManagerError( - "runStackedAction", - "Cannot create a pull request from detached HEAD.", - ); - } - - let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; - let commitMessageForStep = input.commitMessage; - let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; - - const modelSelection = yield* serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.textGenerationModelSelection), - Effect.mapError((cause) => - gitManagerError("runStackedAction", "Failed to get server settings.", cause), - ), - ); - - if (input.featureBranch) { - yield* Ref.set(currentPhase, Option.some("branch")); - yield* progress.emit({ - kind: "phase_started", - phase: "branch", - label: "Preparing feature branch...", - }); - const result = yield* runFeatureBranchStep( - modelSelection, - input.cwd, - initialStatus.branch, - input.commitMessage, - input.filePaths, - ); - branchStep = result.branchStep; - commitMessageForStep = result.resolvedCommitMessage; - preResolvedCommitSuggestion = result.resolvedCommitSuggestion; - } else { - branchStep = { status: "skipped_not_requested" as const }; - } - - const currentBranch = branchStep.name ?? initialStatus.branch; - const commitAction = isCommitAction(input.action) ? input.action : null; - const changeRequestTerms = wantsPr - ? yield* sourceControlProvider(input.cwd).pipe( - Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), - Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), - ) - : null; - - const commit = commitAction - ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( - Effect.flatMap(() => - runCommitStep( - modelSelection, - input.cwd, - commitAction, - currentBranch, - commitMessageForStep, - preResolvedCommitSuggestion, - input.filePaths, - options?.progressReporter, - progress.actionId, - ), - ), - ) - : { status: "skipped_not_requested" as const }; - - const push = wantsPush - ? yield* progress - .emit({ - kind: "phase_started", - phase: "push", - label: "Pushing...", - }) - .pipe( - Effect.tap(() => Ref.set(currentPhase, Option.some("push"))), - Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)), - ) - : { status: "skipped_not_requested" as const }; - - const pr = wantsPr - ? yield* progress - .emit({ - kind: "phase_started", - phase: "pr", - label: `Preparing ${changeRequestTerms?.shortLabel ?? "PR"}...`, - }) - .pipe( - Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), - Effect.flatMap(() => - runPrStep(modelSelection, input.cwd, currentBranch, progress.emit), - ), - ) - : { status: "skipped_not_requested" as const }; - - const toast = yield* buildCompletionToast(input.cwd, { - action: input.action, - branch: branchStep, - commit, - push, - pr, - }); - - const result = { - action: input.action, - branch: branchStep, - commit, - push, - pr, - toast, - }; - yield* progress.emit({ - kind: "action_finished", - result, - }); - return result; - }); - - return yield* runAction().pipe( - Effect.ensuring(invalidateStatus(input.cwd)), - Effect.tapError((error) => - Effect.flatMap(Ref.get(currentPhase), (phase) => - progress.emit({ - kind: "action_failed", - phase: Option.getOrNull(phase), - message: error.message, - }), - ), - ), - ); - }, - ); - return { localStatus, remoteStatus, @@ -1787,7 +958,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { invalidateStatus, resolvePullRequest, preparePullRequestThread, - runStackedAction, } satisfies GitManagerShape; }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 6f50a492..19d4c524 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -20,15 +20,15 @@ import { type VcsPullResult, type VcsRemoveWorktreeInput, type GitResolvePullRequestResult, - type GitRunStackedActionInput, - type GitRunStackedActionResult, type VcsStatusInput, type VcsStatusLocalResult, type VcsStatusRemoteResult, type VcsStatusResult, + type VcsWorkingTreeDiffInput, + type VcsWorkingTreeDiffResult, } from "@cafecode/contracts"; -import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; +import { GitManager } from "./GitManager.ts"; import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; @@ -42,14 +42,13 @@ export interface GitWorkflowServiceShape { readonly remoteStatus: ( input: VcsStatusInput, ) => Effect.Effect; + readonly workingTreeDiff: ( + input: VcsWorkingTreeDiffInput, + ) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitRunStackedActionOptions, - ) => Effect.Effect; readonly resolvePullRequest: ( input: GitPullRequestRefInput, ) => Effect.Effect; @@ -265,6 +264,35 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { isGitRepository ? gitManager.remoteStatus(input) : Effect.succeed(null), ), ), + workingTreeDiff: (input) => + detectGitRepositoryForStatus("GitWorkflowService.workingTreeDiff", input.cwd).pipe( + Effect.flatMap((isGitRepository) => { + if (!isGitRepository) { + return Effect.succeed({ + cwd: input.cwd, + diff: "", + } satisfies VcsWorkingTreeDiffResult); + } + + return git + .workingTreeDiff(input.cwd, { ignoreWhitespace: input.ignoreWhitespace ?? false }) + .pipe( + Effect.map( + (diff): VcsWorkingTreeDiffResult => ({ + cwd: input.cwd, + diff, + }), + ), + Effect.mapError( + (error) => + new GitManagerError({ + operation: "GitWorkflowService.workingTreeDiff", + detail: error.message, + }), + ), + ); + }), + ), invalidateLocalStatus: gitManager.invalidateLocalStatus, invalidateRemoteStatus: gitManager.invalidateRemoteStatus, invalidateStatus: gitManager.invalidateStatus, @@ -272,10 +300,6 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( Effect.andThen(git.pullCurrentBranch(cwd)), ), - runStackedAction: (input, options) => - ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( - Effect.andThen(gitManager.runStackedAction(input, options)), - ), resolvePullRequest: routeGitManager( "GitWorkflowService.resolvePullRequest", gitManager.resolvePullRequest, diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2c3ce828..72717c6a 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -89,12 +89,12 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.sync(() => { const compiled = compileResolvedKeybindingRule({ key: "mod+d", - command: "terminal.split", - when: "terminalOpen && !terminalFocus", + command: "diff.toggle", + when: "modelPickerOpen && !modelPickerOpen", }); assert.deepEqual(compiled, { - command: "terminal.split", + command: "diff.toggle", shortcut: { key: "d", metaKey: false, @@ -105,10 +105,10 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }, whenAst: { type: "and", - left: { type: "identifier", name: "terminalOpen" }, + left: { type: "identifier", name: "modelPickerOpen" }, right: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, }); @@ -118,7 +118,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("encodes resolved plus-key shortcuts", () => Effect.gen(function* () { const encoded = yield* encodeResolvedKeybindingFromConfig({ - command: "terminal.toggle", + command: "commandPalette.toggle", shortcut: { key: "+", metaKey: false, @@ -130,7 +130,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); assert.equal(encoded.key, "mod++"); - assert.equal(encoded.command, "terminal.toggle"); + assert.equal(encoded.command, "commandPalette.toggle"); }), ); @@ -139,23 +139,23 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.isNull( compileResolvedKeybindingRule({ key: "mod+shift+d+o", - command: "terminal.new", + command: "chat.new", }), ); assert.isNull( compileResolvedKeybindingRule({ key: "mod+d", - command: "terminal.split", - when: "terminalFocus && (", + command: "diff.toggle", + when: "modelPickerOpen && (", }), ); assert.isNull( compileResolvedKeybindingRule({ key: "mod+d", - command: "terminal.split", - when: `${"!".repeat(300)}terminalFocus`, + command: "diff.toggle", + when: `${"!".repeat(300)}modelPickerOpen`, }), ); }), @@ -165,7 +165,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.sync(() => { const result = decodeResolvedKeybindingFromConfigExit({ key: "mod+shift+d+o", - command: "terminal.new", + command: "chat.new", }); if (result._tag !== "Failure") { @@ -207,6 +207,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); + assert.equal(defaultsByCommand.get("composer.submit"), "enter"); + assert.equal(defaultsByCommand.get("composer.steer"), "mod+enter"); }), ); @@ -243,8 +245,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { keybindingsConfigPath, // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+shift+d+o", command: "terminal.new" }, + { key: "mod+j", command: "commandPalette.toggle" }, + { key: "mod+shift+d+o", command: "chat.new" }, { key: "mod+x", command: "invalid.command" }, ]), ); @@ -254,7 +256,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => { return yield* keybindings.loadConfigState; }); - assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle")); + assert.isTrue( + configState.keybindings.some((entry) => entry.command === "commandPalette.toggle"), + ); assert.isFalse( configState.keybindings.some((entry) => String(entry.command) === "invalid.command"), ); @@ -279,7 +283,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+shift+t", command: "terminal.toggle" }, + { key: "mod+shift+t", command: "commandPalette.toggle" }, { key: "mod+shift+r", command: "script.run-tests.run" }, ]); @@ -291,11 +295,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); const byCommand = new Map(persisted.map((entry) => [entry.command, entry])); - const persistedToggle = byCommand.get("terminal.toggle"); + const persistedToggle = byCommand.get("commandPalette.toggle"); assert.isNotNull(persistedToggle); assert.equal(persistedToggle?.key, "mod+shift+t"); assert.isFalse( - persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"), + persisted.some( + (entry) => entry.command === "commandPalette.toggle" && entry.key === "mod+j", + ), ); for (const defaultRule of DEFAULT_KEYBINDINGS) { @@ -314,7 +320,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { return Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "script.custom-action.run" }, + { key: "mod+k", command: "script.custom-action.run" }, ]); yield* Effect.gen(function* () { @@ -323,7 +329,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); - assert.isFalse(persisted.some((entry) => entry.command === "terminal.toggle")); + assert.isFalse(persisted.some((entry) => entry.command === "commandPalette.toggle")); assert.isTrue(persisted.some((entry) => entry.command === "script.custom-action.run")); assert.isTrue( @@ -345,7 +351,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+j", command: "commandPalette.toggle" }, ]); const resolved = yield* Effect.gen(function* () { @@ -360,7 +366,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const persistedView = persisted.map(({ key, command }) => ({ key, command })); assert.deepEqual(persistedView, [ - { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+j", command: "commandPalette.toggle" }, { key: "mod+shift+r", command: "script.run-tests.run" }, ]); assert.isTrue(resolved.some((entry) => entry.command === "script.run-tests.run")); @@ -462,7 +468,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const { keybindingsConfigPath } = yield* ServerConfig; yield* fs.writeFileString( keybindingsConfigPath, - '{"key":"mod+j","command":"terminal.toggle"}', + '{"key":"mod+j","command":"commandPalette.toggle"}', ); const firstResult = yield* Effect.gen(function* () { @@ -491,7 +497,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const { keybindingsConfigPath } = yield* ServerConfig; const { dirname } = yield* Path.Path; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+j", command: "commandPalette.toggle" }, ]); yield* fs.chmod(dirname(keybindingsConfigPath), 0o500); @@ -508,7 +514,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); const persistedView = persisted.map(({ key, command }) => ({ key, command })); - assert.deepEqual(persistedView, [{ key: "mod+j", command: "terminal.toggle" }]); + assert.deepEqual(persistedView, [{ key: "mod+j", command: "commandPalette.toggle" }]); }).pipe(Effect.provide(makeKeybindingsLayer())), ); @@ -516,7 +522,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+j", command: "commandPalette.toggle" }, ]); const [first, second] = yield* Effect.gen(function* () { @@ -527,7 +533,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); assert.deepEqual(first, second); - assert.isTrue(second.some((entry) => entry.command === "terminal.toggle")); + assert.isTrue(second.some((entry) => entry.command === "commandPalette.toggle")); }).pipe(Effect.provide(makeKeybindingsLayer())), ); @@ -535,7 +541,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+j", command: "commandPalette.toggle" }, ]); const loadedAfterUpsert = yield* Effect.gen(function* () { @@ -549,7 +555,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "script.run-tests.run")); - assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "terminal.toggle")); + assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "commandPalette.toggle")); }).pipe(Effect.provide(makeKeybindingsLayer())), ); diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index 886833d6..54a0ca0d 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -66,14 +66,6 @@ export const gitCommandDuration = Metric.timer("t3_git_command_duration", { description: "Git command execution duration.", }); -export const terminalSessionsTotal = Metric.counter("t3_terminal_sessions_total", { - description: "Total terminal sessions started.", -}); - -export const terminalRestartsTotal = Metric.counter("t3_terminal_restarts_total", { - description: "Total terminal restart requests handled.", -}); - export const metricAttributes = ( attributes: Readonly>, ): ReadonlyArray<[string, string]> => Object.entries(compactMetricAttributes(attributes)); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 82a03a7e..210294b6 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -111,7 +111,8 @@ function createProviderServiceHarness( respondToUserInput: () => unsupported(), stopSession: () => unsupported(), listSessions, - getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + getCapabilities: () => + Effect.succeed({ sessionModelSwitch: "in-session", liveSteer: "unsupported" }), getInstanceInfo: (instanceId) => Effect.succeed({ instanceId, @@ -413,6 +414,7 @@ describe("CheckpointReactor", () => { provider, cwd, drain, + checkpointStore, }; } @@ -492,6 +494,72 @@ describe("CheckpointReactor", () => { ).toBe("v2\n"); }); + it("prunes old hidden checkpoint refs after capturing a new turn", async () => { + const harness = await createHarness({ seedFilesystemCheckpoints: false }); + const threadId = ThreadId.make("thread-1"); + const createdAt = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.checkpointStore.captureCheckpoint({ + cwd: harness.cwd, + checkpointRef: checkpointRefForThreadTurn(threadId, 0), + }), + ); + + for (const turnCount of [1, 2, 3] as const) { + fs.writeFileSync(path.join(harness.cwd, "README.md"), `v${turnCount + 1}\n`, "utf8"); + const checkpointRef = checkpointRefForThreadTurn(threadId, turnCount); + await Effect.runPromise( + harness.checkpointStore.captureCheckpoint({ + cwd: harness.cwd, + checkpointRef, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make(`cmd-seed-diff-${turnCount}`), + threadId, + turnId: asTurnId(`turn-${turnCount}`), + completedAt: createdAt, + checkpointRef, + status: "ready", + files: [], + checkpointTurnCount: turnCount, + createdAt, + }), + ); + } + await waitForThread(harness.readModel, (entry) => + entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3), + ); + + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v5\n", "utf8"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-prune"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId: asTurnId("turn-4"), + payload: { state: "completed" }, + }); + + await waitForThread( + harness.readModel, + (entry) => + entry.latestTurn?.turnId === "turn-4" && + entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 4), + ); + await harness.drain(); + + expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 0))).toBe(false); + expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 1))).toBe(false); + expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 2))).toBe(true); + expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 3))).toBe(true); + expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 4))).toBe(true); + }); + it("refreshes local git status state on turn completion using the session cwd", async () => { const gitStatusRefreshCalls: string[] = []; const harness = await createHarness({ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index b1178331..09802e8b 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -1,4 +1,5 @@ import { + type CheckpointRef, CommandId, EventId, MessageId, @@ -34,6 +35,7 @@ import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +const CHECKPOINT_REFS_RETAINED_PER_THREAD = 3; type ReactorInput = | { @@ -196,6 +198,62 @@ const make = Effect.gen(function* () { // Shared tail for both capture paths: creates the git checkpoint ref, diffs // it against the previous turn, then dispatches the domain events to update // the orchestration read model. + const pruneOldCheckpointRefs = Effect.fn("CheckpointReactor.pruneOldCheckpointRefs")( + function* (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly currentTurnCount: number; + readonly checkpoints: ReadonlyArray<{ + readonly checkpointTurnCount: number; + readonly checkpointRef: CheckpointRef; + }>; + }) { + const checkpointCandidates = [ + ...input.checkpoints, + { + checkpointTurnCount: 0, + checkpointRef: checkpointRefForThreadTurn(input.threadId, 0), + }, + ]; + const retainedTurnCounts = new Set( + [ + ...checkpointCandidates.map((checkpoint) => checkpoint.checkpointTurnCount), + input.currentTurnCount, + ] + .toSorted((left, right) => right - left) + .slice(0, CHECKPOINT_REFS_RETAINED_PER_THREAD), + ); + const checkpointRefsToDelete = [ + ...new Set( + checkpointCandidates + .filter((checkpoint) => !retainedTurnCounts.has(checkpoint.checkpointTurnCount)) + .map((checkpoint) => checkpoint.checkpointRef), + ), + ]; + + if (checkpointRefsToDelete.length === 0) { + return; + } + + yield* checkpointStore + .deleteCheckpointRefs({ + cwd: input.cwd, + checkpointRefs: checkpointRefsToDelete, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("failed to prune old checkpoint refs", { + threadId: input.threadId, + cwd: input.cwd, + retainedTurnCounts: [...retainedTurnCounts], + checkpointRefsToDelete, + detail: error.message, + }), + ), + ); + }, + ); + const captureAndDispatchCheckpoint = Effect.fn("captureAndDispatchCheckpoint")(function* (input: { readonly threadId: ThreadId; readonly turnId: TurnId; @@ -205,6 +263,10 @@ const make = Effect.gen(function* () { readonly role: string; readonly turnId: TurnId | null; }>; + readonly checkpoints: ReadonlyArray<{ + readonly checkpointTurnCount: number; + readonly checkpointRef: CheckpointRef; + }>; }; readonly cwd: string; readonly turnCount: number; @@ -327,6 +389,13 @@ const make = Effect.gen(function* () { }, createdAt: input.createdAt, }); + + yield* pruneOldCheckpointRefs({ + threadId: input.threadId, + cwd: input.cwd, + currentTurnCount: input.turnCount, + checkpoints: input.thread.checkpoints, + }); }); // Captures a real git checkpoint when a turn completes via a runtime event. diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 4e8e98e1..06fb7f74 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -190,6 +190,13 @@ describe("OrchestrationEngine", () => { threads: [], updatedAt: projectionSnapshot.updatedAt, }), + getDeletedShellSnapshot: () => + Effect.succeed({ + snapshotSequence: projectionSnapshot.snapshotSequence, + projects: [], + threads: [], + updatedAt: projectionSnapshot.updatedAt, + }), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), @@ -197,7 +204,6 @@ describe("OrchestrationEngine", () => { getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 8ad5cd2a..3de4b7a8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -175,6 +175,129 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { ); }); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-thread-move-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("persists moved thread project ids from thread meta updates", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-01-01T00:00:00.000Z"; + const movedAt = "2026-01-01T00:00:01.000Z"; + + yield* eventStore.append({ + type: "project.created", + eventId: EventId.make("evt-move-project-source"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-source"), + occurredAt: now, + commandId: CommandId.make("cmd-move-project-source"), + causationEventId: null, + correlationId: CommandId.make("cmd-move-project-source"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-source"), + title: "Source", + workspaceRoot: "/tmp/source", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + yield* eventStore.append({ + type: "project.created", + eventId: EventId.make("evt-move-project-target"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-target"), + occurredAt: now, + commandId: CommandId.make("cmd-move-project-target"), + causationEventId: null, + correlationId: CommandId.make("cmd-move-project-target"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-target"), + title: "Target", + workspaceRoot: "/tmp/target", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make("evt-move-thread-create"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-move"), + occurredAt: now, + commandId: CommandId.make("cmd-move-thread-create"), + causationEventId: null, + correlationId: CommandId.make("cmd-move-thread-create"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-move"), + projectId: ProjectId.make("project-source"), + title: "Move Me", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + yield* eventStore.append({ + type: "thread.meta-updated", + eventId: EventId.make("evt-move-thread-meta"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-move"), + occurredAt: movedAt, + commandId: CommandId.make("cmd-move-thread-meta"), + causationEventId: null, + correlationId: CommandId.make("cmd-move-thread-meta"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-move"), + projectId: ProjectId.make("project-target"), + updatedAt: movedAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly threadId: string; + readonly projectId: string; + readonly title: string; + readonly updatedAt: string; + }>` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + updated_at AS "updatedAt" + FROM projection_threads + WHERE thread_id = ${"thread-move"} + `; + assert.deepEqual(rows, [ + { + threadId: "thread-move", + projectId: "project-target", + title: "Move Me", + updatedAt: movedAt, + }, + ]); + }), + ); + }, +); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( "OrchestrationProjectionPipeline", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f03dc4df..51ff2d08 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -17,8 +17,11 @@ import { OrchestrationEventStore } from "../../persistence/Services/Orchestratio import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts"; -import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; -import { type ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; +import { + type ProjectionThreadActivity, + ProjectionThreadActivityRepository, + type ProjectionUserInputActivityAccountingRow, +} from "../../persistence/Services/ProjectionThreadActivities.ts"; import { type ProjectionThreadMessage, ProjectionThreadMessageRepository, @@ -106,8 +109,8 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { ); } -function derivePendingUserInputCountFromActivities( - activities: ReadonlyArray, +function derivePendingUserInputCountFromAccountingRows( + activities: ReadonlyArray, ): number { const openRequestIds = new Set(); const ordered = [...activities].toSorted( @@ -150,33 +153,6 @@ function derivePendingUserInputCountFromActivities( return openRequestIds.size; } -function deriveHasActionableProposedPlan(input: { - readonly latestTurnId: string | null; - readonly proposedPlans: ReadonlyArray; -}): boolean { - const sorted = [...input.proposedPlans].toSorted( - (left, right) => - left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId), - ); - - let latestForTurn: ProjectionThreadProposedPlan | null = null; - if (input.latestTurnId !== null) { - for (let index = sorted.length - 1; index >= 0; index -= 1) { - const plan = sorted[index]; - if (plan?.turnId === input.latestTurnId) { - latestForTurn = plan; - break; - } - } - } - if (latestForTurn !== null) { - return latestForTurn.implementedAt === null; - } - - const latestPlan = sorted.at(-1) ?? null; - return latestPlan !== null && latestPlan.implementedAt === null; -} - function retainProjectionMessagesAfterRevert( messages: ReadonlyArray, turns: ReadonlyArray, @@ -530,32 +506,34 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } - const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ - projectionThreadMessageRepository.listByThreadId({ threadId }), - projectionThreadProposedPlanRepository.listByThreadId({ threadId }), - projectionThreadActivityRepository.listByThreadId({ threadId }), - projectionPendingApprovalRepository.listByThreadId({ threadId }), + const [ + latestUserMessageAt, + latestPlanForTurn, + latestPlan, + userInputActivities, + pendingApprovalCount, + ] = yield* Effect.all([ + projectionThreadMessageRepository.getLatestUserMessageAtByThreadId({ threadId }), + existingRow.value.latestTurnId === null + ? Effect.succeed(Option.none()) + : projectionThreadProposedPlanRepository.getLatestByThreadId({ + threadId, + turnId: existingRow.value.latestTurnId, + }), + projectionThreadProposedPlanRepository.getLatestByThreadId({ threadId }), + projectionThreadActivityRepository.listUserInputAccountingByThreadId({ threadId }), + projectionPendingApprovalRepository.countPendingByThreadId({ threadId }), ]); - const latestUserMessageAt = - messages - .filter((message) => message.role === "user") - .map((message) => message.createdAt) - .toSorted() - .at(-1) ?? null; - - const pendingApprovalCount = pendingApprovals.filter( - (approval) => approval.status === "pending", - ).length; - const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities); - const hasActionableProposedPlan = deriveHasActionableProposedPlan({ - latestTurnId: existingRow.value.latestTurnId, - proposedPlans, - }); + const selectedPlan = Option.isSome(latestPlanForTurn) ? latestPlanForTurn : latestPlan; + const pendingUserInputCount = + derivePendingUserInputCountFromAccountingRows(userInputActivities); + const hasActionableProposedPlan = + Option.isSome(selectedPlan) && selectedPlan.value.implementedAt === null; yield* projectionThreadRepository.upsert({ ...existingRow.value, - latestUserMessageAt, + latestUserMessageAt: Option.getOrNull(latestUserMessageAt), pendingApprovalCount, pendingUserInputCount, hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0, @@ -627,6 +605,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } yield* projectionThreadRepository.upsert({ ...existingRow.value, + ...(event.payload.projectId !== undefined + ? { projectId: event.payload.projectId } + : {}), ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } @@ -686,6 +667,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.restored": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + deletedAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.message-sent": case "thread.proposed-plan-upserted": case "thread.activity-appended": @@ -714,7 +710,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } yield* projectionThreadRepository.upsert({ ...existingRow.value, - latestTurnId: event.payload.session.activeTurnId, + latestTurnId: event.payload.session.activeTurnId ?? existingRow.value.latestTurnId, updatedAt: event.occurredAt, }); yield* refreshThreadShellSummary(event.payload.threadId); @@ -968,6 +964,59 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (event.type !== "thread.session-set") { return; } + const existingSession = yield* projectionThreadSessionRepository.getByThreadId({ + threadId: event.payload.threadId, + }); + if ( + event.payload.session.activeTurnId === null && + (event.payload.session.status === "ready" || + event.payload.session.status === "error" || + event.payload.session.status === "interrupted" || + event.payload.session.status === "stopped") && + Option.isSome(existingSession) && + existingSession.value.activeTurnId !== null + ) { + const turnId = existingSession.value.activeTurnId; + const existingTurn = yield* projectionTurnRepository.getByTurnId({ + threadId: event.payload.threadId, + turnId, + }); + const nextState = + event.payload.session.status === "error" + ? "error" + : event.payload.session.status === "ready" + ? "completed" + : "interrupted"; + if (Option.isSome(existingTurn)) { + yield* projectionTurnRepository.upsertByTurnId({ + ...existingTurn.value, + state: + existingTurn.value.state === "error" || existingTurn.value.state === "interrupted" + ? existingTurn.value.state + : nextState, + completedAt: existingTurn.value.completedAt ?? event.payload.session.updatedAt, + startedAt: existingTurn.value.startedAt ?? event.payload.session.updatedAt, + requestedAt: existingTurn.value.requestedAt ?? event.payload.session.updatedAt, + }); + } else { + yield* projectionTurnRepository.upsertByTurnId({ + threadId: event.payload.threadId, + turnId, + pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: null, + state: nextState, + requestedAt: event.payload.session.updatedAt, + startedAt: event.payload.session.updatedAt, + completedAt: event.payload.session.updatedAt, + checkpointTurnCount: null, + checkpointRef: null, + checkpointStatus: null, + checkpointFiles: [], + }); + } + } yield* projectionThreadSessionRepository.upsert({ threadId: event.payload.threadId, status: event.payload.session.status, @@ -1079,23 +1128,34 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (event.payload.turnId === null || event.payload.role !== "assistant") { return; } + const activeSession = yield* projectionThreadSessionRepository.getByThreadId({ + threadId: event.payload.threadId, + }); + const sessionIsStillRunningThisTurn = + Option.isSome(activeSession) && + activeSession.value.status === "running" && + activeSession.value.activeTurnId === event.payload.turnId; + const shouldHoldTurnOpen = event.payload.streaming || sessionIsStillRunningThisTurn; const existingTurn = yield* projectionTurnRepository.getByTurnId({ threadId: event.payload.threadId, turnId: event.payload.turnId, }); if (Option.isSome(existingTurn)) { + const nextState = shouldHoldTurnOpen + ? existingTurn.value.state === "error" || existingTurn.value.state === "interrupted" + ? existingTurn.value.state + : "running" + : existingTurn.value.state === "interrupted" + ? "interrupted" + : existingTurn.value.state === "error" + ? "error" + : "completed"; yield* projectionTurnRepository.upsertByTurnId({ ...existingTurn.value, assistantMessageId: event.payload.messageId, - state: event.payload.streaming - ? existingTurn.value.state - : existingTurn.value.state === "interrupted" - ? "interrupted" - : existingTurn.value.state === "error" - ? "error" - : "completed", - completedAt: event.payload.streaming - ? existingTurn.value.completedAt + state: nextState, + completedAt: shouldHoldTurnOpen + ? null : (existingTurn.value.completedAt ?? event.payload.updatedAt), startedAt: existingTurn.value.startedAt ?? event.payload.createdAt, requestedAt: existingTurn.value.requestedAt ?? event.payload.createdAt, @@ -1109,10 +1169,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti sourceProposedPlanThreadId: null, sourceProposedPlanId: null, assistantMessageId: event.payload.messageId, - state: event.payload.streaming ? "running" : "completed", + state: shouldHoldTurnOpen ? "running" : "completed", requestedAt: event.payload.createdAt, startedAt: event.payload.createdAt, - completedAt: event.payload.streaming ? null : event.payload.updatedAt, + completedAt: shouldHoldTurnOpen ? null : event.payload.updatedAt, checkpointTurnCount: null, checkpointRef: null, checkpointStatus: null, @@ -1163,7 +1223,27 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti threadId: event.payload.threadId, turnId: event.payload.turnId, }); - const nextState = event.payload.status === "error" ? "error" : "completed"; + const activeSession = yield* projectionThreadSessionRepository.getByThreadId({ + threadId: event.payload.threadId, + }); + const activeSessionStillOwnsTurn = + Option.isSome(activeSession) && + activeSession.value.activeTurnId === event.payload.turnId && + activeSession.value.status !== "error" && + activeSession.value.status !== "interrupted" && + activeSession.value.status !== "stopped"; + const existingTurnStillRunning = + Option.isSome(existingTurn) && + existingTurn.value.state === "running" && + existingTurn.value.completedAt === null; + const shouldHoldTurnOpen = + event.payload.status !== "error" && + (activeSessionStillOwnsTurn || existingTurnStillRunning); + const nextState = shouldHoldTurnOpen + ? "running" + : event.payload.status === "error" + ? "error" + : "completed"; yield* projectionTurnRepository.clearCheckpointTurnConflict({ threadId: event.payload.threadId, turnId: event.payload.turnId, @@ -1181,7 +1261,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti checkpointFiles: event.payload.files, startedAt: existingTurn.value.startedAt ?? event.payload.completedAt, requestedAt: existingTurn.value.requestedAt ?? event.payload.completedAt, - completedAt: event.payload.completedAt, + completedAt: shouldHoldTurnOpen + ? (existingTurn.value.completedAt ?? null) + : event.payload.completedAt, }); return; } @@ -1195,7 +1277,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti state: nextState, requestedAt: event.payload.completedAt, startedAt: event.payload.completedAt, - completedAt: event.payload.completedAt, + completedAt: shouldHoldTurnOpen ? null : event.payload.completedAt, checkpointTurnCount: event.payload.checkpointTurnCount, checkpointRef: event.payload.checkpointRef, checkpointStatus: event.payload.status, @@ -1315,8 +1397,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti // Only approval-requested activities should create pending-approval // rows. Other activity kinds that happen to carry a requestId // (e.g. user-input.requested / user-input.resolved) must not - // pollute this projection — they have their own accounting via - // derivePendingUserInputCountFromActivities. + // pollute this projection; they have their own targeted shell + // summary accounting. if (event.payload.activity.kind !== "approval.requested") { return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index eccc2887..bf0b619a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -356,12 +356,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ], session: { threadId: ThreadId.make("thread-1"), - status: "running", + status: "ready", providerName: "codex", runtimeMode: "approval-required", - activeTurnId: asTurnId("turn-1"), + activeTurnId: null, lastError: null, - updatedAt: "2026-02-24T00:00:07.000Z", + updatedAt: "2026-02-24T00:00:08.000Z", }, }, ]); @@ -419,14 +419,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + deletedAt: null, session: { threadId: ThreadId.make("thread-1"), - status: "running", + status: "ready", providerName: "codex", runtimeMode: "approval-required", - activeTurnId: asTurnId("turn-1"), + activeTurnId: null, lastError: null, - updatedAt: "2026-02-24T00:00:07.000Z", + updatedAt: "2026-02-24T00:00:08.000Z", }, latestUserMessageAt: "2026-02-24T00:00:04.000Z", hasPendingApprovals: true, @@ -533,19 +534,38 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { '2026-04-06T00:00:05.000Z', '2026-04-06T00:00:06.000Z', NULL + ), + ( + 'thread-deleted', + 'project-archive-test', + 'Deleted Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:07.000Z', + '2026-04-06T00:00:08.000Z', + NULL, + '2026-04-06T00:00:09.000Z' ) `; yield* sql` INSERT INTO projection_state (projector, last_applied_sequence, updated_at) VALUES - (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 4, '2026-04-06T00:00:07.000Z'), - (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 4, '2026-04-06T00:00:07.000Z'), - (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 4, '2026-04-06T00:00:07.000Z'), - (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'), - (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'), - (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'), - (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z') + (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 4, '2026-04-06T00:00:10.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 4, '2026-04-06T00:00:10.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 4, '2026-04-06T00:00:10.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:10.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:10.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:10.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:10.000Z') `; const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); @@ -560,6 +580,13 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { [ThreadId.make("thread-archived")], ); assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z"); + + const deletedShellSnapshot = yield* snapshotQuery.getDeletedShellSnapshot(); + assert.deepEqual( + deletedShellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-deleted")], + ); + assert.equal(deletedShellSnapshot.threads[0]?.deletedAt, "2026-04-06T00:00:09.000Z"); }), ); @@ -811,6 +838,22 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'checkpoint-b', 'ready', '[]' + ), + ( + 'thread-context', + 'turn-incomplete', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-03-02T00:00:06.000Z', + '2026-03-02T00:00:06.000Z', + NULL, + 3, + 'checkpoint-incomplete', + 'ready', + '[]' ) `; @@ -846,6 +889,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ], }); } + + const detail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-context")); + assert.equal(detail._tag, "Some"); + if (detail._tag === "Some") { + assert.deepEqual( + detail.value.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount), + [1, 2], + ); + } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e0de1429..a3a27521 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,6 +1,5 @@ import { ChatAttachment, - CheckpointRef, IsoDateTime, MessageId, NonNegativeInt, @@ -50,7 +49,6 @@ import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIde import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, - type ProjectionFullThreadDiffContext, type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, type ProjectionSnapshotQueryShape, @@ -59,6 +57,7 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); +export const THREAD_DETAIL_ACTIVITY_LIMIT = 500; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -124,19 +123,6 @@ const ProjectionThreadCheckpointContextThreadRowSchema = Schema.Struct({ workspaceRoot: Schema.String, worktreePath: Schema.NullOr(Schema.String), }); -const FullThreadDiffContextLookupInput = Schema.Struct({ - threadId: ThreadId, - checkpointTurnCount: NonNegativeInt, -}); -const ProjectionFullThreadDiffContextRowSchema = Schema.Struct({ - threadId: ThreadId, - projectId: ProjectId, - workspaceRoot: Schema.String, - worktreePath: Schema.NullOr(Schema.String), - latestCheckpointTurnCount: Schema.NullOr(NonNegativeInt), - toCheckpointRef: Schema.NullOr(CheckpointRef), -}); - const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.projects, ORCHESTRATION_PROJECTOR_NAMES.threads, @@ -221,6 +207,36 @@ function mapSessionRow( }; } +function reconcileSessionWithLatestTurn( + session: OrchestrationSession, + latestTurn: OrchestrationLatestTurn | null | undefined, +): OrchestrationSession { + if ( + session.status !== "running" || + session.activeTurnId === null || + latestTurn?.turnId !== session.activeTurnId || + latestTurn.state !== "completed" || + latestTurn.completedAt === null + ) { + return session; + } + + return { + ...session, + status: "ready", + activeTurnId: null, + lastError: null, + updatedAt: maxIso(session.updatedAt, latestTurn.completedAt), + }; +} + +function mapSessionRowForThread( + row: Schema.Schema.Type, + latestTurn: OrchestrationLatestTurn | null | undefined, +): OrchestrationSession { + return reconcileSessionWithLatestTurn(mapSessionRow(row), latestTurn); +} + function mapProjectShellRow( row: Schema.Schema.Type, repositoryIdentity: OrchestrationProject["repositoryIdentity"], @@ -401,6 +417,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listDeletedThreadRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE deleted_at IS NOT NULL + ORDER BY project_id ASC, deleted_at DESC, thread_id DESC + `, + }); + const listThreadMessageRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadMessageDbRowSchema, @@ -535,6 +580,30 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listDeletedThreadSessionRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadSessionDbRowSchema, + execute: () => + sql` + SELECT + sessions.thread_id AS "threadId", + sessions.status, + sessions.provider_name AS "providerName", + sessions.provider_instance_id AS "providerInstanceId", + sessions.provider_session_id AS "providerSessionId", + sessions.provider_thread_id AS "providerThreadId", + sessions.runtime_mode AS "runtimeMode", + sessions.active_turn_id AS "activeTurnId", + sessions.last_error AS "lastError", + sessions.updated_at AS "updatedAt" + FROM projection_thread_sessions sessions + INNER JOIN projection_threads threads + ON threads.thread_id = sessions.thread_id + WHERE threads.deleted_at IS NOT NULL + ORDER BY sessions.thread_id ASC + `, + }); + const listCheckpointRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionCheckpointDbRowSchema, @@ -551,6 +620,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { completed_at AS "completedAt" FROM projection_turns WHERE checkpoint_turn_count IS NOT NULL + AND checkpoint_ref IS NOT NULL + AND checkpoint_status IS NOT NULL + AND checkpoint_files_json IS NOT NULL + AND completed_at IS NOT NULL ORDER BY thread_id ASC, checkpoint_turn_count ASC `, }); @@ -631,6 +704,31 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listDeletedLatestTurnRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionLatestTurnDbRowSchema, + execute: () => + sql` + SELECT + turns.thread_id AS "threadId", + turns.turn_id AS "turnId", + turns.state, + turns.requested_at AS "requestedAt", + turns.started_at AS "startedAt", + turns.completed_at AS "completedAt", + turns.assistant_message_id AS "assistantMessageId", + turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + turns.source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_threads threads + JOIN projection_turns turns + ON turns.thread_id = threads.thread_id + AND turns.turn_id = threads.latest_turn_id + WHERE threads.deleted_at IS NOT NULL + AND threads.latest_turn_id IS NOT NULL + ORDER BY turns.thread_id ASC + `, + }); + const listProjectionStateRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionStateDbRowSchema, @@ -810,22 +908,32 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { Result: ProjectionThreadActivityDbRowSchema, execute: ({ threadId }) => sql` - SELECT - activity_id AS "activityId", - thread_id AS "threadId", - turn_id AS "turnId", - tone, - kind, - summary, - payload_json AS "payload", - sequence, - created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} + SELECT * + FROM ( + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END DESC, + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, sequence ASC, - created_at ASC, - activity_id ASC + "createdAt" ASC, + "activityId" ASC `, }); @@ -892,42 +1000,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_turns WHERE thread_id = ${threadId} AND checkpoint_turn_count IS NOT NULL + AND checkpoint_ref IS NOT NULL + AND checkpoint_status IS NOT NULL + AND checkpoint_files_json IS NOT NULL + AND completed_at IS NOT NULL ORDER BY checkpoint_turn_count ASC `, }); - const getFullThreadDiffContextRow = SqlSchema.findOneOption({ - Request: FullThreadDiffContextLookupInput, - Result: ProjectionFullThreadDiffContextRowSchema, - execute: ({ threadId, checkpointTurnCount }) => - sql` - SELECT - threads.thread_id AS "threadId", - threads.project_id AS "projectId", - projects.workspace_root AS "workspaceRoot", - threads.worktree_path AS "worktreePath", - ( - SELECT MAX(turns.checkpoint_turn_count) - FROM projection_turns AS turns - WHERE turns.thread_id = threads.thread_id - AND turns.checkpoint_turn_count IS NOT NULL - ) AS "latestCheckpointTurnCount", - ( - SELECT turns.checkpoint_ref - FROM projection_turns AS turns - WHERE turns.thread_id = threads.thread_id - AND turns.checkpoint_turn_count = ${checkpointTurnCount} - LIMIT 1 - ) AS "toCheckpointRef" - FROM projection_threads AS threads - INNER JOIN projection_projects AS projects - ON projects.project_id = threads.project_id - WHERE threads.thread_id = ${threadId} - AND threads.deleted_at IS NULL - LIMIT 1 - `, - }); - const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => sql .withTransaction( @@ -1139,18 +1219,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { for (const row of sessionRows) { updatedAt = maxIso(updatedAt, row.updatedAt); - sessionsByThread.set(row.threadId, { - threadId: row.threadId, - status: row.status, - providerName: row.providerName, - ...(row.providerInstanceId !== null - ? { providerInstanceId: row.providerInstanceId } - : {}), - runtimeMode: row.runtimeMode, - activeTurnId: row.activeTurnId, - lastError: row.lastError, - updatedAt: row.updatedAt, - }); + sessionsByThread.set( + row.threadId, + mapSessionRowForThread(row, latestTurnByThread.get(row.threadId) ?? null), + ); } const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( @@ -1350,7 +1422,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { if (!row) { continue; } - sessionByThread.set(row.threadId, mapSessionRow(row)); + sessionByThread.set( + row.threadId, + mapSessionRowForThread(row, latestTurnByThread.get(row.threadId) ?? null), + ); } for (let index = 0; index < proposedPlanRows.length; index += 1) { @@ -1483,7 +1558,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), ); const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + sessionRows.map( + (row) => + [ + row.threadId, + mapSessionRowForThread(row, latestTurnByThread.get(row.threadId) ?? null), + ] as const, + ), ); const snapshot = { @@ -1509,6 +1590,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + deletedAt: row.deletedAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1616,7 +1698,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), ); const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + sessionRows.map( + (row) => + [ + row.threadId, + mapSessionRowForThread(row, latestTurnByThread.get(row.threadId) ?? null), + ] as const, + ), ); const snapshot = { @@ -1640,6 +1728,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + deletedAt: row.deletedAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1669,6 +1758,150 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }), ); + const getDeletedShellSnapshot: ProjectionSnapshotQueryShape["getDeletedShellSnapshot"] = () => + sql + .withTransaction( + Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getDeletedShellSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getDeletedShellSnapshot:listProjects:decodeRows", + ), + ), + ), + listDeletedThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getDeletedShellSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getDeletedShellSnapshot:listThreads:decodeRows", + ), + ), + ), + listDeletedThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getDeletedShellSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getDeletedShellSnapshot:listThreadSessions:decodeRows", + ), + ), + ), + listDeletedLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getDeletedShellSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getDeletedShellSnapshot:listLatestTurns:decodeRows", + ), + ), + ), + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getDeletedShellSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getDeletedShellSnapshot:listProjectionState:decodeRows", + ), + ), + ), + ]), + ) + .pipe( + Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + if (row.deletedAt !== null) { + updatedAt = maxIso(updatedAt, row.deletedAt); + } + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); + const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( + projectRows.filter((row) => activeProjectIds.has(row.projectId)), + { includeDeleted: true }, + ); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map( + (row) => + [ + row.threadId, + mapSessionRowForThread(row, latestTurnByThread.get(row.threadId) ?? null), + ] as const, + ), + ); + + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: projectRows + .filter((row) => activeProjectIds.has(row.projectId)) + .map((row) => + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ), + threads: threadRows.map( + (row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + deletedAt: row.deletedAt, + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + }), + ), + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; + + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getDeletedShellSnapshot:decodeShellSnapshot", + ), + ), + ); + }), + ), + Effect.mapError((error) => { + if (isPersistenceError(error)) { + return error; + } + return toPersistenceSqlError("ProjectionSnapshotQuery.getDeletedShellSnapshot:query")( + error, + ); + }), + ); + const getSnapshotSequence: ProjectionSnapshotQueryShape["getSnapshotSequence"] = () => listProjectionStateRows(undefined).pipe( Effect.mapError( @@ -1805,35 +2038,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); }); - const getFullThreadDiffContext: NonNullable< - ProjectionSnapshotQueryShape["getFullThreadDiffContext"] - > = (threadId, toTurnCount) => - Effect.gen(function* () { - const row = yield* getFullThreadDiffContextRow({ - threadId, - checkpointTurnCount: toTurnCount, - }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getFullThreadDiffContext:query", - "ProjectionSnapshotQuery.getFullThreadDiffContext:decodeRow", - ), - ), - ); - if (Option.isNone(row)) { - return Option.none(); - } - - return Option.some({ - threadId: row.value.threadId, - projectId: row.value.projectId, - workspaceRoot: row.value.workspaceRoot, - worktreePath: row.value.worktreePath, - latestCheckpointTurnCount: row.value.latestCheckpointTurnCount ?? 0, - toCheckpointRef: row.value.toCheckpointRef, - }); - }); - const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => Effect.gen(function* () { const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ @@ -1880,7 +2084,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, - session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + deletedAt: threadRow.value.deletedAt, + session: Option.isSome(sessionRow) + ? mapSessionRowForThread( + sessionRow.value, + Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + ) + : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, hasPendingUserInput: threadRow.value.pendingUserInputCount > 0, @@ -2015,7 +2225,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { assistantMessageId: row.assistantMessageId, completedAt: row.completedAt, })), - session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + session: Option.isSome(sessionRow) + ? mapSessionRowForThread( + sessionRow.value, + Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + ) + : null, }; return Option.some( @@ -2032,13 +2247,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getSnapshot, getShellSnapshot, getArchivedShellSnapshot, + getDeletedShellSnapshot, getSnapshotSequence, getCounts, getActiveProjectByWorkspaceRoot, getProjectShellById, getFirstActiveThreadIdByProjectId, getThreadCheckpointContext, - getFullThreadDiffContext, getThreadShellById, getThreadDetailById, } satisfies ProjectionSnapshotQueryShape; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 63f6a05c..46fb6bf7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -142,6 +142,7 @@ describe("ProviderCommandReactor", () => { readonly baseDir?: string; readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; + readonly liveSteer?: "supported" | "unsupported"; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); @@ -293,6 +294,7 @@ describe("ProviderCommandReactor", () => { getCapabilities: (_provider) => Effect.succeed({ sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", + liveSteer: input?.liveSteer ?? "unsupported", }), getInstanceInfo: (instanceId) => { const raw = String(instanceId); @@ -1550,6 +1552,101 @@ describe("ProviderCommandReactor", () => { }); }); + it("routes live steer requests to providers that support steering", async () => { + const harness = await createHarness({ liveSteer: "supported" }); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-steer"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.steer", + commandId: CommandId.make("cmd-turn-steer"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-steer"), + role: "user", + text: "adjust course", + attachments: [], + }, + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + input: "adjust course", + }); + }); + + it("does not route steer requests to providers without live steering support", async () => { + const harness = await createHarness({ liveSteer: "unsupported" }); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-steer-unsupported"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.steer", + commandId: CommandId.make("cmd-turn-steer-unsupported"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-steer-unsupported"), + role: "user", + text: "adjust course", + attachments: [], + }, + createdAt: now, + }), + ); + + await harness.drain(); + expect(harness.sendTurn.mock.calls.length).toBe(0); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.activities.at(-1)).toMatchObject({ + kind: "provider.turn.steer.failed", + payload: { + detail: "The active provider does not support live steering.", + }, + }); + }); + it("starts a fresh session when only projected session state exists", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 2fc21f33..eef79a7e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -53,6 +53,7 @@ type ProviderIntentEvent = Extract< | "thread.runtime-mode-set" | "thread.turn-start-requested" | "thread.turn-interrupt-requested" + | "thread.turn-steer-requested" | "thread.approval-response-requested" | "thread.user-input-response-requested" | "thread.session-stop-requested"; @@ -209,6 +210,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly kind: | "provider.turn.start.failed" + | "provider.turn.steer.failed" | "provider.turn.interrupt.failed" | "provider.approval.respond.failed" | "provider.user-input.respond.failed" @@ -817,6 +819,95 @@ const make = Effect.gen(function* () { yield* providerService.interruptTurn({ threadId: event.payload.threadId }); }); + const processTurnSteerRequested = Effect.fn("processTurnSteerRequested")(function* ( + event: Extract, + ) { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + const message = thread.messages.find((entry) => entry.id === event.payload.messageId); + if (!message || message.role !== "user") { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.steer.failed", + summary: "Provider steer failed", + detail: `User message '${event.payload.messageId}' was not found for steer request.`, + turnId: thread.session?.activeTurnId ?? null, + createdAt: event.payload.createdAt, + }); + } + if (thread.session?.status !== "running") { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.steer.failed", + summary: "Provider steer failed", + detail: "No active running provider turn is bound to this thread.", + turnId: thread.session?.activeTurnId ?? null, + createdAt: event.payload.createdAt, + }); + } + const providerInstanceId = thread.session.providerInstanceId; + if (providerInstanceId === undefined) { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.steer.failed", + summary: "Provider steer failed", + detail: "The active provider session is missing a provider instance id.", + turnId: thread.session.activeTurnId, + createdAt: event.payload.createdAt, + }); + } + const capabilities = yield* providerService.getCapabilities(providerInstanceId); + if (capabilities.liveSteer !== "supported") { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.steer.failed", + summary: "Provider steer failed", + detail: "The active provider does not support live steering.", + turnId: thread.session.activeTurnId, + createdAt: event.payload.createdAt, + }); + } + + const sendTurnRequest = yield* buildSendTurnRequestForThread({ + threadId: event.payload.threadId, + messageText: message.text, + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + createdAt: event.payload.createdAt, + }).pipe( + Effect.map(Option.some), + Effect.catchCause((cause) => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.steer.failed", + summary: "Provider steer failed", + detail: formatFailureDetail(cause), + turnId: thread.session?.activeTurnId ?? null, + createdAt: event.payload.createdAt, + }).pipe(Effect.as(Option.none())), + ), + ); + + if (Option.isNone(sendTurnRequest)) { + return; + } + + yield* providerService.sendTurn(sendTurnRequest.value).pipe( + Effect.catchCause((cause) => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.steer.failed", + summary: "Provider steer failed", + detail: formatFailureDetail(cause), + turnId: thread.session?.activeTurnId ?? null, + createdAt: event.payload.createdAt, + }), + ), + Effect.forkScoped, + ); + }); + const processApprovalResponseRequested = Effect.fn("processApprovalResponseRequested")(function* ( event: Extract, ) { @@ -967,6 +1058,9 @@ const make = Effect.gen(function* () { case "thread.turn-interrupt-requested": yield* processTurnInterruptRequested(event); return; + case "thread.turn-steer-requested": + yield* processTurnSteerRequested(event); + return; case "thread.approval-response-requested": yield* processApprovalResponseRequested(event); return; @@ -1000,6 +1094,7 @@ const make = Effect.gen(function* () { event.type === "thread.runtime-mode-set" || event.type === "thread.turn-start-requested" || event.type === "thread.turn-interrupt-requested" || + event.type === "thread.turn-steer-requested" || event.type === "thread.approval-response-requested" || event.type === "thread.user-input-response-requested" || event.type === "thread.session-stop-requested" diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 41042589..d9bb4851 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -105,7 +105,8 @@ function createProviderServiceHarness() { respondToUserInput: () => unsupported(), stopSession: () => unsupported(), listSessions: () => Effect.succeed([...runtimeSessions]), - getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + getCapabilities: () => + Effect.succeed({ sessionModelSwitch: "in-session", liveSteer: "unsupported" }), getInstanceInfo: (instanceId) => { const driverKind = ProviderDriverKind.make(String(instanceId)); return Effect.succeed({ @@ -792,6 +793,101 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("separates assistant item streams that overlap within one provider turn", async () => { + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: false } }); + const now = "2026-01-01T00:00:00.000Z"; + const turnId = asTurnId("turn-overlapping-assistant-items"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-overlapping-items"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + }); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-overlapping-item-a-delta"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId: asItemId("item-overlap-a"), + payload: { + streamKind: "assistant_text", + delta: "first assistant item", + }, + }); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-overlapping-item-b-delta"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId: asItemId("item-overlap-b"), + payload: { + streamKind: "assistant_text", + delta: "second assistant item", + }, + }); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-overlapping-item-a-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId: asItemId("item-overlap-a"), + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-overlapping-item-b-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId, + itemId: asItemId("item-overlap-b"), + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-overlap-a" && + message.text === "first assistant item" && + !message.streaming, + ) && + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-overlap-b" && + message.text === "second assistant item" && + !message.streaming, + ), + ); + + expect( + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.text === "first assistant itemsecond assistant item", + ), + ).toBe(false); + }); + it("preserves completed tool metadata on projected tool activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -845,7 +941,7 @@ describe("ProviderRuntimeIngestion", () => { expect(payload?.detail).toBeUndefined(); expect(data?.toolCallId).toBe("tool-read-1"); expect(data?.kind).toBe("read"); - expect(rawOutput?.content).toBe('import * as Effect from "effect/Effect"\n'); + expect(rawOutput?.content).toMatch(/^\[content omitted: \d+ chars, \d+ lines\]$/); }); it("normalizes command execution activities to ran-command summaries", async () => { @@ -1533,8 +1629,8 @@ describe("ProviderRuntimeIngestion", () => { expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second"); }); - it("buffers assistant deltas by default until completion", async () => { - const harness = await createHarness(); + it("buffers assistant deltas when assistant streaming is disabled", async () => { + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: false } }); const now = "2026-01-01T00:00:00.000Z"; harness.emit({ @@ -1602,7 +1698,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("flushes and completes buffered assistant text when an approval request opens", async () => { - const harness = await createHarness(); + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: false } }); const now = "2026-01-01T00:00:00.000Z"; harness.emit({ @@ -1662,7 +1758,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("flushes and completes buffered assistant text when user input is requested", async () => { - const harness = await createHarness(); + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: false } }); const now = "2026-01-01T00:00:00.000Z"; harness.emit({ @@ -1729,7 +1825,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("does not create assistant segments for whitespace-only buffered text at approval boundaries", async () => { - const harness = await createHarness(); + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: false } }); const startedAt = "2026-03-28T06:28:00.000Z"; const pausedAt = "2026-03-28T06:28:01.000Z"; @@ -1789,7 +1885,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("starts a new buffered assistant message segment after approval and completes without duplication", async () => { - const harness = await createHarness(); + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: false } }); const startedAt = "2026-03-28T06:07:00.000Z"; const pausedAt = "2026-03-28T06:07:01.000Z"; const resumedAt = "2026-03-28T06:07:02.000Z"; @@ -2120,7 +2216,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("spills oversized buffered deltas and still finalizes full assistant text", async () => { - const harness = await createHarness(); + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: false } }); const now = "2026-01-01T00:00:00.000Z"; const oversizedText = "x".repeat(40_000); @@ -2266,6 +2362,177 @@ describe("ProviderRuntimeIngestion", () => { expect(completionEvents).toHaveLength(1); }); + it("keeps a turn running after assistant message completion until provider thread idle", async () => { + const harness = await createHarness(); + const turnId = asTurnId("turn-provider-idle"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-provider-idle"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId, + }); + + await waitForThread( + harness.readModel, + (thread) => thread.latestTurn?.state === "running" && thread.session?.status === "running", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-assistant-delta-provider-idle"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:01.000Z", + threadId: asThreadId("thread-1"), + turnId, + itemId: asItemId("item-provider-idle"), + payload: { + streamKind: "assistant_text", + delta: "I am still working.", + }, + }); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-assistant-completed-provider-idle"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:02.000Z", + threadId: asThreadId("thread-1"), + turnId, + itemId: asItemId("item-provider-idle"), + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const stillRunning = await waitForThread( + harness.readModel, + (thread) => + thread.latestTurn?.turnId === turnId && + thread.latestTurn.state === "running" && + thread.latestTurn.completedAt === null && + thread.session?.status === "running" && + thread.session.activeTurnId === turnId && + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-provider-idle" && !message.streaming, + ), + ); + expect(stillRunning.latestTurn?.state).toBe("running"); + expect(stillRunning.latestTurn?.completedAt).toBeNull(); + expect(stillRunning.session?.status).toBe("running"); + + harness.emit({ + type: "thread.state.changed", + eventId: asEventId("evt-thread-idle-provider-idle"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:03.000Z", + threadId: asThreadId("thread-1"), + payload: { + state: "idle", + }, + }); + + const readyAfterIdle = await waitForThread( + harness.readModel, + (thread) => + thread.latestTurn?.turnId === turnId && + thread.latestTurn.state === "running" && + thread.session?.status === "ready" && + thread.session.activeTurnId === turnId, + ); + expect(readyAfterIdle.session?.status).toBe("ready"); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-provider-idle"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:04.000Z", + threadId: asThreadId("thread-1"), + turnId, + payload: { + state: "completed", + }, + }); + + const completed = await waitForThread( + harness.readModel, + (thread) => + thread.latestTurn?.turnId === turnId && + thread.latestTurn.state === "completed" && + thread.latestTurn.completedAt === "2026-01-01T00:00:04.000Z" && + thread.session?.status === "ready", + ); + expect(completed.latestTurn?.state).toBe("completed"); + }); + + it("reopens active turn state when provider content arrives after a stale idle transition", async () => { + const harness = await createHarness(); + const turnId = asTurnId("turn-reopen-after-idle"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-reopen-after-idle"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId, + }); + + await waitForThread( + harness.readModel, + (thread) => + thread.latestTurn?.state === "running" && + thread.session?.status === "running" && + thread.session.activeTurnId === turnId, + ); + + harness.emit({ + type: "thread.state.changed", + eventId: asEventId("evt-thread-idle-reopen-after-idle"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:01.000Z", + threadId: asThreadId("thread-1"), + payload: { + state: "idle", + }, + }); + + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "ready" && thread.session.activeTurnId === turnId, + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-content-reopen-after-idle"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:02.000Z", + threadId: asThreadId("thread-1"), + turnId, + itemId: asItemId("item-reopen-after-idle"), + payload: { + streamKind: "assistant_text", + delta: "Still streaming.", + }, + }); + + const reopened = await waitForThread( + harness.readModel, + (thread) => + thread.latestTurn?.turnId === turnId && + thread.latestTurn.state === "running" && + thread.latestTurn.completedAt === null && + thread.session?.status === "running" && + thread.session.activeTurnId === turnId, + ); + expect(reopened.session?.status).toBe("running"); + expect(reopened.latestTurn?.state).toBe("running"); + }); + it("maps canonical request events into approval activities with requestKind", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2390,7 +2657,7 @@ describe("ProviderRuntimeIngestion", () => { expect(activityPayload?.message).toBe("runtime activity exploded"); }); - it("keeps the session running when a runtime.warning arrives during an active turn", async () => { + it("keeps the session running and suppresses transient retry warnings during an active turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2418,20 +2685,25 @@ describe("ProviderRuntimeIngestion", () => { }, }, }); + await harness.drain(); const thread = await waitForThread( harness.readModel, (entry) => entry.session?.status === "running" && entry.session?.activeTurnId === "turn-warning" && - entry.activities.some( - (activity: ProviderRuntimeTestActivity) => - activity.id === "evt-warning-runtime" && activity.kind === "runtime.warning", + !entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-warning-runtime", ), ); expect(thread.session?.status).toBe("running"); expect(thread.session?.activeTurnId).toBe("turn-warning"); expect(thread.session?.lastError).toBeNull(); + expect( + thread.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-warning-runtime", + ), + ).toBe(false); }); it("maps session/thread lifecycle and item.started into session/activity projections", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a395ae4e..eb36c7ce 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -37,6 +37,7 @@ import { ProviderRuntimeIngestionService, type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; +import { sanitizeProviderToolData } from "@cafecode/shared/activityPayloadSanitizer"; import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; @@ -200,6 +201,10 @@ function assistantSegmentBaseKeyFromEvent(event: ProviderRuntimeEvent): string { return String(event.itemId ?? event.turnId ?? event.eventId); } +function assistantSegmentHasStableItemKey(event: ProviderRuntimeEvent): boolean { + return event.itemId !== undefined; +} + function assistantSegmentMessageId(baseKey: string, segmentIndex: number): MessageId { return MessageId.make( segmentIndex === 0 ? `assistant:${baseKey}` : `assistant:${baseKey}:segment:${segmentIndex}`, @@ -248,6 +253,39 @@ function orchestrationSessionStatusFromRuntimeState( } } +function orchestrationSessionStatusFromRuntimeThreadState( + state: "active" | "idle" | "closed" | "error", +): "running" | "ready" | "stopped" | "error" { + switch (state) { + case "active": + return "running"; + case "idle": + return "ready"; + case "closed": + return "stopped"; + case "error": + return "error"; + } +} + +function runtimeThreadStateAffectsSession( + state: string, +): state is "active" | "idle" | "closed" | "error" { + return state === "active" || state === "idle" || state === "closed" || state === "error"; +} + +function runtimeEventCarriesActiveTurnWork(event: ProviderRuntimeEvent): boolean { + switch (event.type) { + case "content.delta": + case "turn.proposed.delta": + case "item.started": + case "item.updated": + return true; + default: + return false; + } +} + function requestKindFromCanonicalRequestType( requestType: string | undefined, ): "command" | "file-read" | "file-change" | undefined { @@ -348,6 +386,16 @@ function runtimeEventToActivities( } case "runtime.warning": { + const detail = event.payload.detail; + if ( + detail !== null && + typeof detail === "object" && + !Array.isArray(detail) && + "willRetry" in detail && + detail.willRetry === true + ) { + return []; + } return [ { id: event.eventId, @@ -539,6 +587,9 @@ function runtimeEventToActivities( if (!isToolLifecycleItemType(event.payload.itemType)) { return []; } + const sanitizedData = sanitizeProviderToolData(event.payload.data, { + itemType: event.payload.itemType, + }); return [ { id: event.eventId, @@ -550,7 +601,7 @@ function runtimeEventToActivities( itemType: event.payload.itemType, ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), - ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), + ...(sanitizedData !== undefined ? { data: sanitizedData } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -562,6 +613,9 @@ function runtimeEventToActivities( if (!isToolLifecycleItemType(event.payload.itemType)) { return []; } + const sanitizedData = sanitizeProviderToolData(event.payload.data, { + itemType: event.payload.itemType, + }); return [ { id: event.eventId, @@ -572,7 +626,7 @@ function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), - ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), + ...(sanitizedData !== undefined ? { data: sanitizedData } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -720,6 +774,29 @@ const make = Effect.gen(function* () { ), ); + const getMatchingActiveAssistantMessageIdForEvent = ( + threadId: ThreadId, + turnId: TurnId, + event: ProviderRuntimeEvent, + ) => + getAssistantSegmentStateForTurn(threadId, turnId).pipe( + Effect.map((state) => + Option.flatMap(state, (entry) => { + if (!entry.activeMessageId) { + return Option.none(); + } + + if (!assistantSegmentHasStableItemKey(event)) { + return Option.some(entry.activeMessageId); + } + + return entry.baseKey === assistantSegmentBaseKeyFromEvent(event) + ? Option.some(entry.activeMessageId) + : Option.none(); + }), + ), + ); + const startAssistantSegmentForTurn = (input: { threadId: ThreadId; turnId: TurnId; @@ -765,7 +842,15 @@ const make = Effect.gen(function* () { input.turnId, ); if (Option.isSome(activeMessageId)) { - return activeMessageId.value; + const state = yield* getAssistantSegmentStateForTurn(input.threadId, input.turnId); + const incomingBaseKey = assistantSegmentBaseKeyFromEvent(input.event); + if ( + Option.isSome(state) && + (!assistantSegmentHasStableItemKey(input.event) || + state.value.baseKey === incomingBaseKey) + ) { + return activeMessageId.value; + } } return yield* startAssistantSegmentForTurn({ @@ -981,6 +1066,42 @@ const make = Effect.gen(function* () { } }); + const finalizeActiveAssistantSegmentBeforeItemSwitch = (input: { + event: ProviderRuntimeEvent; + threadId: ThreadId; + turnId: TurnId; + createdAt: string; + }) => + Effect.gen(function* () { + if (!assistantSegmentHasStableItemKey(input.event)) { + return; + } + + const state = yield* getAssistantSegmentStateForTurn(input.threadId, input.turnId); + if ( + Option.isNone(state) || + !state.value.activeMessageId || + state.value.baseKey === assistantSegmentBaseKeyFromEvent(input.event) + ) { + return; + } + + const detailedThread = yield* resolveThreadDetail(input.threadId); + const activeMessageId = state.value.activeMessageId; + yield* finalizeAssistantMessage({ + event: input.event, + threadId: input.threadId, + messageId: activeMessageId, + turnId: input.turnId, + createdAt: input.createdAt, + commandTag: "assistant-complete-on-item-switch", + finalDeltaCommandTag: "assistant-delta-finalize-on-item-switch", + hasProjectedMessage: + detailedThread?.messages.some((message) => message.id === activeMessageId) ?? false, + }); + yield* forgetAssistantMessageId(input.threadId, input.turnId, activeMessageId); + }); + const upsertProposedPlan = (input: { event: ProviderRuntimeEvent; threadId: ThreadId; @@ -1211,6 +1332,8 @@ const make = Effect.gen(function* () { case "session.started": case "thread.started": return true; + case "thread.state.changed": + return eventTurnId === undefined || !conflictsWithActiveTurn; case "turn.started": return !conflictsWithActiveTurn; case "turn.aborted": @@ -1239,12 +1362,25 @@ const make = Effect.gen(function* () { event.type === "turn.started" && shouldApplyThreadLifecycle ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId) : null; + const sessionRelevantThreadState = + event.type === "thread.state.changed" && + runtimeThreadStateAffectsSession(event.payload.state) + ? event.payload.state + : undefined; + const eventCarriesActiveTurnWork = + activeTurnId !== null && + eventTurnId !== undefined && + sameId(activeTurnId, eventTurnId) && + !conflictsWithActiveTurn && + runtimeEventCarriesActiveTurnWork(event); if ( event.type === "session.started" || event.type === "session.state.changed" || event.type === "session.exited" || event.type === "thread.started" || + sessionRelevantThreadState !== undefined || + eventCarriesActiveTurnWork || event.type === "turn.started" || event.type === "turn.aborted" || event.type === "turn.completed" @@ -1252,15 +1388,32 @@ const make = Effect.gen(function* () { const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) - : event.type === "turn.aborted" || - event.type === "turn.completed" || - event.type === "session.exited" - ? null - : activeTurnId; + : eventCarriesActiveTurnWork + ? (eventTurnId ?? null) + : event.type === "thread.state.changed" + ? sessionRelevantThreadState === "active" + ? (eventTurnId ?? + activeTurnId ?? + (thread.latestTurn?.state === "running" ? thread.latestTurn.turnId : null)) + : sessionRelevantThreadState === "idle" + ? activeTurnId + : null + : event.type === "turn.aborted" || + event.type === "turn.completed" || + event.type === "session.exited" + ? null + : activeTurnId; const status = (() => { switch (event.type) { case "session.state.changed": return orchestrationSessionStatusFromRuntimeState(event.payload.state); + case "thread.state.changed": + return orchestrationSessionStatusFromRuntimeThreadState(sessionRelevantThreadState!); + case "content.delta": + case "turn.proposed.delta": + case "item.started": + case "item.updated": + return "running"; case "turn.started": return "running"; case "session.exited": @@ -1277,18 +1430,21 @@ const make = Effect.gen(function* () { // active turn; preserve turn-running state in that case. return activeTurnId !== null ? "running" : "ready"; } + return thread.session?.status ?? "ready"; })(); const lastError = event.type === "session.state.changed" && event.payload.state === "error" ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") - : event.type === "turn.aborted" - ? event.payload.reason - : event.type === "turn.completed" && - normalizeRuntimeTurnState(event.payload.state) === "failed" - ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed") - : status === "ready" - ? null - : (thread.session?.lastError ?? null); + : event.type === "thread.state.changed" && sessionRelevantThreadState === "error" + ? (thread.session?.lastError ?? "Provider thread error") + : event.type === "turn.aborted" + ? event.payload.reason + : event.type === "turn.completed" && + normalizeRuntimeTurnState(event.payload.state) === "failed" + ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed") + : status === "ready" + ? null + : (thread.session?.lastError ?? null); if (shouldApplyThreadLifecycle) { if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { @@ -1341,6 +1497,14 @@ const make = Effect.gen(function* () { if (assistantDelta && assistantDelta.length > 0) { const turnId = toTurnId(event.turnId); + if (turnId) { + yield* finalizeActiveAssistantSegmentBeforeItemSwitch({ + event, + threadId: thread.id, + turnId, + createdAt: now, + }); + } const assistantMessageId = yield* getOrCreateAssistantMessageId({ threadId: thread.id, event, @@ -1453,10 +1617,8 @@ const make = Effect.gen(function* () { const messages = detailedThread?.messages ?? []; const turnId = toTurnId(event.turnId); const activeAssistantMessageId = turnId - ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) + ? yield* getMatchingActiveAssistantMessageIdForEvent(thread.id, turnId, event) : Option.none(); - const hasAssistantMessagesForTurn = - turnId !== undefined ? hasAssistantMessageForTurn(messages, turnId) : false; const assistantMessageId = Option.getOrElse( activeAssistantMessageId, () => assistantCompletion.messageId, @@ -1468,7 +1630,7 @@ const make = Effect.gen(function* () { const shouldSkipRedundantCompletion = Option.isNone(activeAssistantMessageId) && turnId !== undefined && - hasAssistantMessagesForTurn && + existingAssistantMessage !== undefined && (assistantCompletion.fallbackText?.trim().length ?? 0) === 0; if (!shouldSkipRedundantCompletion) { @@ -1496,7 +1658,16 @@ const make = Effect.gen(function* () { } if (turnId) { - yield* clearAssistantSegmentStateForTurn(thread.id, turnId); + const state = yield* getAssistantSegmentStateForTurn(thread.id, turnId); + if ( + Option.isSome(state) && + state.value.baseKey === assistantSegmentBaseKeyFromEvent(event) + ) { + yield* setAssistantSegmentStateForTurn(thread.id, turnId, { + ...state.value, + activeMessageId: null, + }); + } } } diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index f453bc46..539c7086 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -6,7 +6,6 @@ import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; -import { TerminalManager } from "../../terminal/Services/Manager.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor, @@ -39,7 +38,6 @@ export const logCleanupCauseUnlessInterrupted = ({ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; - const terminalManager = yield* TerminalManager; const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => logCleanupCauseUnlessInterrupted({ @@ -48,19 +46,11 @@ const make = Effect.gen(function* () { threadId, }); - const closeThreadTerminals = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => - logCleanupCauseUnlessInterrupted({ - effect: terminalManager.close({ threadId, deleteHistory: true }), - message: "thread deletion cleanup skipped terminal close", - threadId, - }); - const processThreadDeleted = Effect.fn("processThreadDeleted")(function* ( event: ThreadDeletedEvent, ) { const { threadId } = event.payload; yield* stopProviderSession(threadId); - yield* closeThreadTerminals(threadId); }); const processThreadDeletedSafely = (event: ThreadDeletedEvent) => diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 2c496306..85b4ff87 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -65,7 +65,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => } satisfies OrchestrationCommand; } - if (command.type !== "thread.turn.start") { + if (command.type !== "thread.turn.start" && command.type !== "thread.turn.steer") { return command as OrchestrationCommand; } diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 6dd70b89..8755dd77 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -8,6 +8,7 @@ import { ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, + ThreadRestoredPayload as ContractsThreadRestoredPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, @@ -33,6 +34,7 @@ export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; +export const ThreadRestoredPayload = ContractsThreadRestoredPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index d9f0ed05..bbea696f 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -7,7 +7,6 @@ * @module ProjectionSnapshotQuery */ import type { - CheckpointRef, OrchestrationCheckpointSummary, OrchestrationProject, OrchestrationProjectShell, @@ -41,15 +40,6 @@ export interface ProjectionThreadCheckpointContext { readonly checkpoints: ReadonlyArray; } -export interface ProjectionFullThreadDiffContext { - readonly threadId: ThreadId; - readonly projectId: ProjectId; - readonly workspaceRoot: string; - readonly worktreePath: string | null; - readonly latestCheckpointTurnCount: number; - readonly toCheckpointRef: CheckpointRef | null; -} - /** * ProjectionSnapshotQueryShape - Service API for read-model snapshots. */ @@ -93,6 +83,16 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** + * Read soft-deleted thread shell summaries for the Recently Deleted page. + * + * Deleted threads stay outside normal navigation and archive snapshots. + */ + readonly getDeletedShellSnapshot: () => Effect.Effect< + OrchestrationShellSnapshot, + ProjectionRepositoryError + >; + /** * Read the latest projection snapshot sequence without hydrating read-model * entities. @@ -135,15 +135,6 @@ export interface ProjectionSnapshotQueryShape { threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; - /** - * Read only the narrow context needed to compute a full-thread diff from - * checkpoint 0 to a specific turn count. - */ - readonly getFullThreadDiffContext: ( - threadId: ThreadId, - toTurnCount: number, - ) => Effect.Effect, ProjectionRepositoryError>; - /** * Read a single active thread shell row by id. */ diff --git a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts index 6aa68c1e..cf90cf8d 100644 --- a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts @@ -2,7 +2,7 @@ * ThreadDeletionReactor - Thread deletion cleanup reactor service interface. * * Owns background workers that react to thread deletion domain events and - * perform best-effort runtime cleanup for provider sessions and terminals. + * perform best-effort runtime cleanup for provider sessions. * * @module ThreadDeletionReactor */ diff --git a/apps/server/src/orchestration/decider.threadMove.test.ts b/apps/server/src/orchestration/decider.threadMove.test.ts new file mode 100644 index 00000000..9b262031 --- /dev/null +++ b/apps/server/src/orchestration/decider.threadMove.test.ts @@ -0,0 +1,170 @@ +import { CommandId, EventId, ProjectId, ProviderInstanceId, ThreadId } from "@cafecode/contracts"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asThreadId = (value: string): ThreadId => ThreadId.make(value); + +async function makeReadModelWithMoveFixture() { + const now = "2026-01-01T00:00:00.000Z"; + const initial = createEmptyReadModel(now); + const withSourceProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: EventId.make("event-project-source"), + aggregateKind: "project", + aggregateId: asProjectId("project-source"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-source"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-source"), + metadata: {}, + payload: { + projectId: asProjectId("project-source"), + title: "Source", + workspaceRoot: "/tmp/source", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + const withTargetProject = await Effect.runPromise( + projectEvent(withSourceProject, { + sequence: 2, + eventId: EventId.make("event-project-target"), + aggregateKind: "project", + aggregateId: asProjectId("project-target"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-target"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-target"), + metadata: {}, + payload: { + projectId: asProjectId("project-target"), + title: "Target", + workspaceRoot: "/tmp/target", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + return Effect.runPromise( + projectEvent(withTargetProject, { + sequence: 3, + eventId: EventId.make("event-thread"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-1"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.make("cmd-thread"), + causationEventId: null, + correlationId: CommandId.make("cmd-thread"), + metadata: {}, + payload: { + threadId: asThreadId("thread-1"), + projectId: asProjectId("project-source"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/tmp/source/worktree", + createdAt: now, + updatedAt: now, + }, + }), + ); +} + +describe("thread move decider", () => { + it("emits a thread meta update with the target project id and clears stale worktree overrides", async () => { + const readModel = await makeReadModelWithMoveFixture(); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-move"), + threadId: asThreadId("thread-1"), + projectId: asProjectId("project-target"), + }, + readModel, + }), + ); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("thread.meta-updated"); + expect( + (event.payload as { projectId?: ProjectId; worktreePath?: string | null }).projectId, + ).toBe("project-target"); + expect( + (event.payload as { projectId?: ProjectId; worktreePath?: string | null }).worktreePath, + ).toBeNull(); + }); + + it("rejects moves to missing projects", async () => { + const readModel = await makeReadModelWithMoveFixture(); + + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-move-missing"), + threadId: asThreadId("thread-1"), + projectId: asProjectId("project-missing"), + }, + readModel, + }), + ), + ).rejects.toThrow("does not exist"); + }); + + it("rejects moves to deleted projects", async () => { + const readModel = await makeReadModelWithMoveFixture(); + const withDeletedTarget = await Effect.runPromise( + projectEvent(readModel, { + sequence: 4, + eventId: EventId.make("event-project-target-deleted"), + aggregateKind: "project", + aggregateId: asProjectId("project-target"), + type: "project.deleted", + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: CommandId.make("cmd-project-target-deleted"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-target-deleted"), + metadata: {}, + payload: { + projectId: asProjectId("project-target"), + deletedAt: "2026-01-01T00:00:01.000Z", + }, + }), + ); + + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-move-deleted"), + threadId: asThreadId("thread-1"), + projectId: asProjectId("project-target"), + }, + readModel: withDeletedTarget, + }), + ), + ).rejects.toThrow("has been deleted"); + }); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 034ae691..178a8a7a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -246,6 +246,34 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.restore": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.deletedAt === null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' is not in the Recycle Bin.`, + }); + } + const occurredAt = yield* nowIso; + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), + type: "thread.restored", + payload: { + threadId: command.threadId, + updatedAt: occurredAt, + }, + }; + } + case "thread.archive": { yield* requireThreadNotArchived({ readModel, @@ -292,11 +320,27 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.meta.update": { - yield* requireThread({ + const existingThread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + let clearWorktreePathForProjectMove = false; + if (command.projectId !== undefined) { + const targetProject = yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + if (targetProject.deletedAt !== null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Project '${command.projectId}' has been deleted and cannot receive moved threads.`, + }); + } + clearWorktreePathForProjectMove = + existingThread.projectId !== command.projectId && command.worktreePath === undefined; + } const occurredAt = yield* nowIso; return { ...withEventBase({ @@ -308,12 +352,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.meta-updated", payload: { threadId: command.threadId, + ...(command.projectId !== undefined ? { projectId: command.projectId } : {}), ...(command.title !== undefined ? { title: command.title } : {}), ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), ...(command.branch !== undefined ? { branch: command.branch } : {}), - ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(command.worktreePath !== undefined + ? { worktreePath: command.worktreePath } + : clearWorktreePathForProjectMove + ? { worktreePath: null } + : {}), updatedAt: occurredAt, }, }; @@ -462,6 +511,56 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.turn.steer": { + const targetThread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if (targetThread.session?.status !== "running") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' does not have a running provider turn to steer.`, + }); + } + const userMessageEvent: Omit = { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.message.messageId, + role: "user", + text: command.message.text, + attachments: command.message.attachments, + turnId: targetThread.session.activeTurnId, + streaming: false, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + const turnSteerRequestedEvent: Omit = { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + causationEventId: userMessageEvent.eventId, + type: "thread.turn-steer-requested", + payload: { + threadId: command.threadId, + messageId: command.message.messageId, + createdAt: command.createdAt, + }, + }; + return [userMessageEvent, turnSteerRequestedEvent]; + } + case "thread.approval.respond": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 71701f87..a39f4baa 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -99,6 +99,62 @@ describe("orchestration projector", () => { ]); }); + it("moves threads between projects on thread.meta-updated events", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const later = "2026-01-01T00:00:01.000Z"; + const created = await Effect.runPromise( + projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-thread-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + + const moved = await Effect.runPromise( + projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.meta-updated", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: later, + commandId: "cmd-thread-move", + payload: { + threadId: "thread-1", + projectId: "project-2", + updatedAt: later, + }, + }), + ), + ); + + expect(moved.threads[0]?.projectId).toBe("project-2"); + expect(moved.threads[0]?.title).toBe("demo"); + expect(moved.threads[0]?.updatedAt).toBe(later); + }); + it("fails when event payload cannot be decoded by runtime schema", async () => { const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 16adf195..7b2a5785 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -18,6 +18,7 @@ import { ThreadArchivedPayload, ThreadCreatedPayload, ThreadDeletedPayload, + ThreadRestoredPayload, ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, @@ -28,7 +29,7 @@ import { ThreadTurnDiffCompletedPayload, } from "./Schemas.ts"; -type ThreadPatch = Partial>; +type ThreadPatch = Partial>; const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; @@ -292,6 +293,17 @@ export function projectEvent( })), ); + case "thread.restored": + return decodeForEvent(ThreadRestoredPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + deletedAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.archived": return decodeForEvent(ThreadArchivedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ @@ -319,6 +331,7 @@ export function projectEvent( Effect.map((payload) => ({ ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { + ...(payload.projectId !== undefined ? { projectId: payload.projectId } : {}), ...(payload.title !== undefined ? { title: payload.title } : {}), ...(payload.modelSelection !== undefined ? { modelSelection: payload.modelSelection } diff --git a/apps/server/src/orchestration/threadHardDelete.test.ts b/apps/server/src/orchestration/threadHardDelete.test.ts new file mode 100644 index 00000000..eaaada47 --- /dev/null +++ b/apps/server/src/orchestration/threadHardDelete.test.ts @@ -0,0 +1,573 @@ +import { CheckpointRef, ProjectId, ThreadId } from "@cafecode/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { + CheckpointStore, + type DeleteCheckpointRefsInput, +} from "../checkpointing/Services/CheckpointStore.ts"; +import { ServerConfig } from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolver } from "../project/Services/RepositoryIdentityResolver.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import { hardDeleteThreadLocalData } from "./threadHardDelete.ts"; + +const checkpointDeleteCalls: Array = []; + +const checkpointStoreLayer = Layer.succeed(CheckpointStore, { + isGitRepository: () => Effect.succeed(true), + captureCheckpoint: () => Effect.void, + hasCheckpointRef: () => Effect.succeed(true), + restoreCheckpoint: () => Effect.succeed(true), + diffCheckpoints: () => Effect.succeed(""), + deleteCheckpointRefs: (input) => + Effect.sync(() => { + checkpointDeleteCalls.push(input); + }), +}); + +const repositoryIdentityResolverLayer = Layer.succeed(RepositoryIdentityResolver, { + resolve: () => Effect.succeed(null), +}); + +const testLayer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provideMerge(repositoryIdentityResolverLayer), + Layer.provideMerge(checkpointStoreLayer), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "cafe-hard-delete-" })), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), +); + +const exists = (filePath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const fileInfo = yield* Effect.result(fileSystem.stat(filePath)); + return fileInfo._tag === "Success"; + }); + +it.layer(Layer.fresh(testLayer))("hardDeleteThreadLocalData", (it) => { + it.effect("removes local thread data and preserves unrelated rows", () => + Effect.gen(function* () { + checkpointDeleteCalls.length = 0; + + const sql = yield* SqlClient.SqlClient; + const snapshotQuery = yield* ProjectionSnapshotQuery; + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const targetThreadId = ThreadId.make("hard-delete-thread"); + const survivorThreadId = ThreadId.make("survivor-thread"); + const projectId = ProjectId.make("project-hard-delete"); + const now = "2026-05-22T00:00:00.000Z"; + const deletedAt = "2026-05-22T00:01:00.000Z"; + const modelSelectionJson = '{"instanceId":"codex","model":"gpt-5-codex"}'; + const attachmentId = "hard-delete-thread-00000000-0000-4000-8000-000000000001"; + const survivorAttachmentId = "survivor-thread-00000000-0000-4000-8000-000000000002"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`); + const survivorAttachmentPath = path.join( + config.attachmentsDir, + `${survivorAttachmentId}.png`, + ); + + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "delete me"); + yield* fileSystem.writeFileString(survivorAttachmentPath, "keep me"); + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${projectId}, + 'Project Hard Delete', + '/tmp/project-hard-delete', + ${modelSelectionJson}, + '[]', + ${now}, + ${now}, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + deleted_at + ) + VALUES + ( + ${targetThreadId}, + ${projectId}, + 'Target Deleted Thread', + ${modelSelectionJson}, + 'full-access', + 'default', + NULL, + '/tmp/project-hard-delete/worktree', + 'turn-hard-delete', + ${now}, + 1, + 0, + 0, + ${now}, + ${now}, + ${deletedAt} + ), + ( + ${survivorThreadId}, + ${projectId}, + 'Survivor Thread', + ${modelSelectionJson}, + 'full-access', + 'default', + NULL, + NULL, + 'turn-survivor', + ${now}, + 0, + 0, + 0, + ${now}, + ${now}, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES + ( + 'message-hard-delete', + ${targetThreadId}, + 'turn-hard-delete', + 'assistant', + 'erase this', + '[{"type":"image","id":"hard-delete-thread-00000000-0000-4000-8000-000000000001","name":"delete.png","mimeType":"image/png","sizeBytes":9}]', + 0, + ${now}, + ${now} + ), + ( + 'message-survivor', + ${survivorThreadId}, + 'turn-survivor', + 'assistant', + 'keep this', + '[]', + 0, + ${now}, + ${now} + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + created_at + ) + VALUES + ( + 'activity-hard-delete', + ${targetThreadId}, + 'turn-hard-delete', + 'info', + 'runtime.note', + 'erase this activity', + '{"secret":"remove"}', + ${now} + ), + ( + 'activity-survivor', + ${survivorThreadId}, + 'turn-survivor', + 'info', + 'runtime.note', + 'keep this activity', + '{}', + ${now} + ) + `; + + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_instance_id, + provider_session_id, + provider_thread_id, + runtime_mode, + active_turn_id, + last_error, + updated_at + ) + VALUES + ( + ${targetThreadId}, + 'idle', + 'codex', + 'codex', + 'provider-session-hard-delete', + 'provider-thread-hard-delete', + 'full-access', + NULL, + NULL, + ${now} + ), + ( + ${survivorThreadId}, + 'idle', + 'codex', + 'codex', + 'provider-session-survivor', + 'provider-thread-survivor', + 'full-access', + NULL, + NULL, + ${now} + ) + `; + + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES + ( + ${targetThreadId}, + 'codex', + 'codex', + 'codex', + 'full-access', + 'idle', + ${now}, + '{"cursor":"erase"}', + '{"payload":"erase"}' + ), + ( + ${survivorThreadId}, + 'codex', + 'codex', + 'codex', + 'full-access', + 'idle', + ${now}, + '{"cursor":"keep"}', + '{"payload":"keep"}' + ) + `; + + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES + ( + 'approval-hard-delete', + ${targetThreadId}, + 'turn-hard-delete', + 'pending', + NULL, + ${now}, + NULL + ), + ( + 'approval-survivor', + ${survivorThreadId}, + 'turn-survivor', + 'pending', + NULL, + ${now}, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, + thread_id, + turn_id, + plan_markdown, + implemented_at, + implementation_thread_id, + created_at, + updated_at + ) + VALUES + ( + 'plan-hard-delete', + ${targetThreadId}, + 'turn-hard-delete', + '# Erase', + NULL, + NULL, + ${now}, + ${now} + ), + ( + 'plan-survivor', + ${survivorThreadId}, + 'turn-survivor', + '# Keep', + ${now}, + ${targetThreadId}, + ${now}, + ${now} + ) + `; + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES + ( + ${targetThreadId}, + 'turn-hard-delete', + NULL, + NULL, + NULL, + 'message-hard-delete', + 'completed', + ${now}, + ${now}, + ${now}, + 1, + 'checkpoint-hard-delete', + 'ready', + '[{"path":"README.md","kind":"modified","additions":1,"deletions":0}]' + ), + ( + ${survivorThreadId}, + 'turn-survivor', + NULL, + ${targetThreadId}, + 'plan-hard-delete', + 'message-survivor', + 'completed', + ${now}, + ${now}, + ${now}, + 1, + 'checkpoint-survivor', + 'ready', + '[]' + ) + `; + + yield* sql` + INSERT INTO checkpoint_diff_blobs ( + thread_id, + from_turn_count, + to_turn_count, + diff, + created_at + ) + VALUES + (${targetThreadId}, 0, 1, 'secret diff', ${now}), + (${survivorThreadId}, 0, 1, 'survivor diff', ${now}) + `; + + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES + ( + 'event-hard-delete', + 'thread', + ${targetThreadId}, + 1, + 'thread.message-sent', + ${now}, + 'command-hard-delete', + NULL, + 'command-hard-delete', + 'system', + '{"text":"erase"}', + '{}' + ), + ( + 'event-survivor', + 'thread', + ${survivorThreadId}, + 1, + 'thread.message-sent', + ${now}, + 'command-survivor', + NULL, + 'command-survivor', + 'system', + '{"text":"keep"}', + '{}' + ) + `; + + yield* sql` + INSERT INTO orchestration_command_receipts ( + command_id, + aggregate_kind, + aggregate_id, + accepted_at, + result_sequence, + status, + error + ) + VALUES + ('command-hard-delete', 'thread', ${targetThreadId}, ${now}, 1, 'accepted', NULL), + ('command-survivor', 'thread', ${survivorThreadId}, ${now}, 2, 'accepted', NULL) + `; + + const deletedBefore = yield* snapshotQuery.getDeletedShellSnapshot(); + assert.deepEqual( + deletedBefore.threads.map((thread) => thread.id), + [targetThreadId], + ); + assert.isTrue(yield* exists(attachmentPath)); + assert.isTrue(yield* exists(survivorAttachmentPath)); + + const result = yield* hardDeleteThreadLocalData({ threadId: targetThreadId }); + assert.deepEqual(result, { deleted: true }); + + const deletedAfter = yield* snapshotQuery.getDeletedShellSnapshot(); + assert.deepEqual(deletedAfter.threads, []); + assert.isFalse(yield* exists(attachmentPath)); + assert.isTrue(yield* exists(survivorAttachmentPath)); + + const survivorDetail = yield* snapshotQuery.getThreadDetailById(survivorThreadId); + assert.isTrue(Option.isSome(survivorDetail)); + assert.equal(survivorDetail.pipe(Option.getOrThrow).messages[0]?.text, "keep this"); + + const targetProjectionRows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS "count" + FROM projection_threads + WHERE thread_id = ${targetThreadId} + `; + const targetDetailRows = yield* sql<{ readonly count: number }>` + SELECT + (SELECT COUNT(*) FROM projection_thread_messages WHERE thread_id = ${targetThreadId}) + + (SELECT COUNT(*) FROM projection_thread_activities WHERE thread_id = ${targetThreadId}) + + (SELECT COUNT(*) FROM projection_thread_sessions WHERE thread_id = ${targetThreadId}) + + (SELECT COUNT(*) FROM provider_session_runtime WHERE thread_id = ${targetThreadId}) + + (SELECT COUNT(*) FROM projection_pending_approvals WHERE thread_id = ${targetThreadId}) + + (SELECT COUNT(*) FROM projection_thread_proposed_plans WHERE thread_id = ${targetThreadId}) + + (SELECT COUNT(*) FROM projection_turns WHERE thread_id = ${targetThreadId}) + + (SELECT COUNT(*) FROM checkpoint_diff_blobs WHERE thread_id = ${targetThreadId}) + + (SELECT COUNT(*) FROM orchestration_events WHERE aggregate_kind = 'thread' AND stream_id = ${targetThreadId}) + + (SELECT COUNT(*) FROM orchestration_command_receipts WHERE aggregate_kind = 'thread' AND aggregate_id = ${targetThreadId}) + AS "count" + `; + assert.equal(targetProjectionRows[0]?.count, 0); + assert.equal(targetDetailRows[0]?.count, 0); + + const survivorPlanRows = yield* sql<{ readonly implementationThreadId: string | null }>` + SELECT implementation_thread_id AS "implementationThreadId" + FROM projection_thread_proposed_plans + WHERE plan_id = 'plan-survivor' + `; + const survivorTurnRows = yield* sql<{ + readonly sourceProposedPlanThreadId: string | null; + readonly sourceProposedPlanId: string | null; + }>` + SELECT + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_turns + WHERE thread_id = ${survivorThreadId} + `; + assert.equal(survivorPlanRows[0]?.implementationThreadId, null); + assert.equal(survivorTurnRows[0]?.sourceProposedPlanThreadId, null); + assert.equal(survivorTurnRows[0]?.sourceProposedPlanId, null); + + assert.deepEqual(checkpointDeleteCalls, [ + { + cwd: "/tmp/project-hard-delete/worktree", + checkpointRefs: [CheckpointRef.make("checkpoint-hard-delete")], + }, + ]); + }), + ); +}); diff --git a/apps/server/src/orchestration/threadHardDelete.ts b/apps/server/src/orchestration/threadHardDelete.ts new file mode 100644 index 00000000..c5fe415f --- /dev/null +++ b/apps/server/src/orchestration/threadHardDelete.ts @@ -0,0 +1,184 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { CheckpointRef, ThreadId } from "@cafecode/contracts"; + +import { + parseAttachmentIdFromRelativePath, + parseThreadSegmentFromAttachmentId, + toSafeThreadAttachmentSegment, +} from "../attachmentStore.ts"; +import { CheckpointStore } from "../checkpointing/Services/CheckpointStore.ts"; +import { ServerConfig } from "../config.ts"; + +export const deleteThreadAttachments = Effect.fn("deleteThreadAttachments")(function* ( + threadId: ThreadId, +) { + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const threadSegment = toSafeThreadAttachmentSegment(threadId); + if (!threadSegment) { + yield* Effect.logWarning("skipping hard-delete attachment cleanup for unsafe thread id", { + threadId, + }); + return; + } + + const entries = yield* fileSystem + .readDirectory(config.attachmentsDir, { recursive: false }) + .pipe(Effect.catch(() => Effect.succeed([] as Array))); + + yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const relativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + if (relativePath.length === 0 || relativePath.includes("/")) { + return; + } + const attachmentId = parseAttachmentIdFromRelativePath(relativePath); + if (!attachmentId) { + return; + } + const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId); + if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { + return; + } + yield* fileSystem.remove(path.join(config.attachmentsDir, relativePath), { + force: true, + }); + }), + { concurrency: 1 }, + ); +}); + +const loadThreadHardDeleteMetadata = Effect.fn("loadThreadHardDeleteMetadata")(function* ( + threadId: ThreadId, +) { + const sql = yield* SqlClient.SqlClient; + const [threadRow] = yield* sql<{ + readonly worktreePath: string | null; + readonly workspaceRoot: string | null; + }>` + SELECT + thread.worktree_path AS "worktreePath", + project.workspace_root AS "workspaceRoot" + FROM projection_threads AS thread + LEFT JOIN projection_projects AS project + ON project.project_id = thread.project_id + WHERE thread.thread_id = ${threadId} + LIMIT 1 + `; + + const checkpointRows = yield* sql<{ readonly checkpointRef: string }>` + SELECT DISTINCT checkpoint_ref AS "checkpointRef" + FROM projection_turns + WHERE thread_id = ${threadId} + AND checkpoint_ref IS NOT NULL + `; + + return { + cwd: threadRow?.worktreePath ?? threadRow?.workspaceRoot ?? null, + checkpointRefs: checkpointRows.map((row) => CheckpointRef.make(row.checkpointRef)), + }; +}); + +const deleteThreadCheckpointRefs = Effect.fn("deleteThreadCheckpointRefs")(function* ( + threadId: ThreadId, +) { + const checkpointStore = yield* CheckpointStore; + const metadata = yield* loadThreadHardDeleteMetadata(threadId); + if (!metadata.cwd || metadata.checkpointRefs.length === 0) { + return; + } + yield* checkpointStore + .deleteCheckpointRefs({ + cwd: metadata.cwd, + checkpointRefs: metadata.checkpointRefs, + }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("failed to delete thread checkpoint refs during hard delete", { + threadId, + cwd: metadata.cwd, + cause, + }), + ), + ); +}); + +export const hardDeleteThreadLocalData = Effect.fn("hardDeleteThreadLocalData")(function* (input: { + readonly threadId: ThreadId; +}) { + const sql = yield* SqlClient.SqlClient; + + yield* deleteThreadCheckpointRefs(input.threadId); + yield* deleteThreadAttachments(input.threadId); + + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql` + DELETE FROM provider_session_runtime + WHERE thread_id = ${input.threadId} + `; + yield* sql` + DELETE FROM projection_thread_sessions + WHERE thread_id = ${input.threadId} + `; + yield* sql` + DELETE FROM projection_pending_approvals + WHERE thread_id = ${input.threadId} + `; + yield* sql` + DELETE FROM projection_thread_activities + WHERE thread_id = ${input.threadId} + `; + yield* sql` + DELETE FROM projection_thread_messages + WHERE thread_id = ${input.threadId} + `; + yield* sql` + UPDATE projection_thread_proposed_plans + SET implementation_thread_id = NULL + WHERE implementation_thread_id = ${input.threadId} + `; + yield* sql` + DELETE FROM projection_thread_proposed_plans + WHERE thread_id = ${input.threadId} + `; + yield* sql` + UPDATE projection_turns + SET + source_proposed_plan_thread_id = NULL, + source_proposed_plan_id = NULL + WHERE source_proposed_plan_thread_id = ${input.threadId} + `; + yield* sql` + DELETE FROM projection_turns + WHERE thread_id = ${input.threadId} + `; + yield* sql` + DELETE FROM checkpoint_diff_blobs + WHERE thread_id = ${input.threadId} + `; + yield* sql` + DELETE FROM orchestration_command_receipts + WHERE aggregate_kind = 'thread' + AND aggregate_id = ${input.threadId} + `; + yield* sql` + DELETE FROM orchestration_events + WHERE aggregate_kind = 'thread' + AND stream_id = ${input.threadId} + `; + yield* sql` + DELETE FROM projection_threads + WHERE thread_id = ${input.threadId} + `; + }), + ); + + return { deleted: true }; +}); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 6fb6348d..8af1de17 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,6 +1,5 @@ import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { readPathFromLoginShell, @@ -98,17 +97,8 @@ export const expandHomePath = Effect.fn(function* (input: string) { export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) { const { join, resolve } = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; if (!raw || raw.trim().length === 0) { - const cafeHome = join(NodeOS.homedir(), ".cafecode"); - const legacyHome = join(NodeOS.homedir(), ".t3"); - if (yield* fileSystem.exists(cafeHome).pipe(Effect.orElseSucceed(() => false))) { - return cafeHome; - } - if (yield* fileSystem.exists(legacyHome).pipe(Effect.orElseSucceed(() => false))) { - return legacyHome; - } - return cafeHome; + return join(NodeOS.homedir(), ".cafe-code"); } return resolve(yield* expandHomePath(raw.trim())); }); diff --git a/apps/server/src/persistence/Layers/AuthSessions.ts b/apps/server/src/persistence/Layers/AuthSessions.ts index 0fd459f2..791bec00 100644 --- a/apps/server/src/persistence/Layers/AuthSessions.ts +++ b/apps/server/src/persistence/Layers/AuthSessions.ts @@ -1,4 +1,4 @@ -import { AuthSessionId } from "@cafecode/contracts"; +import { AuthSessionId } from "@cafecode/contracts/auth"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; diff --git a/apps/server/src/persistence/Layers/ProjectionCheckpoints.test.ts b/apps/server/src/persistence/Layers/ProjectionCheckpoints.test.ts new file mode 100644 index 00000000..8ec9e595 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionCheckpoints.test.ts @@ -0,0 +1,85 @@ +import { CheckpointRef, ThreadId, TurnId } from "@cafecode/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ProjectionCheckpointRepository } from "../Services/ProjectionCheckpoints.ts"; +import { ProjectionCheckpointRepositoryLive } from "./ProjectionCheckpoints.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; + +const layer = it.layer( + ProjectionCheckpointRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("ProjectionCheckpointRepository", (it) => { + it.effect("ignores incomplete checkpoint projection rows", () => + Effect.gen(function* () { + const repository = yield* ProjectionCheckpointRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-incomplete-checkpoint"); + + yield* repository.upsert({ + threadId, + turnId: TurnId.make("turn-complete"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint-complete"), + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-03-18T00:00:01.000Z", + }); + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${threadId}, + 'turn-incomplete', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-03-18T00:00:02.000Z', + '2026-03-18T00:00:02.000Z', + NULL, + 2, + 'checkpoint-incomplete', + 'ready', + '[]' + ) + `; + + const rows = yield* repository.listByThreadId({ threadId }); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.checkpointTurnCount, 1); + + const complete = yield* repository.getByThreadAndTurnCount({ + threadId, + checkpointTurnCount: 1, + }); + assert.equal(complete._tag, "Some"); + + const incomplete = yield* repository.getByThreadAndTurnCount({ + threadId, + checkpointTurnCount: 2, + }); + assert.equal(incomplete._tag, "None"); + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts index 9e56c532..1e3373d2 100644 --- a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts +++ b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts @@ -109,6 +109,10 @@ const makeProjectionCheckpointRepository = Effect.gen(function* () { FROM projection_turns WHERE thread_id = ${threadId} AND checkpoint_turn_count IS NOT NULL + AND checkpoint_ref IS NOT NULL + AND checkpoint_status IS NOT NULL + AND checkpoint_files_json IS NOT NULL + AND completed_at IS NOT NULL ORDER BY checkpoint_turn_count ASC `, }); @@ -130,6 +134,10 @@ const makeProjectionCheckpointRepository = Effect.gen(function* () { FROM projection_turns WHERE thread_id = ${threadId} AND checkpoint_turn_count = ${checkpointTurnCount} + AND checkpoint_ref IS NOT NULL + AND checkpoint_status IS NOT NULL + AND checkpoint_files_json IS NOT NULL + AND completed_at IS NOT NULL `, }); diff --git a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts index 253f6e13..850772c5 100644 --- a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts @@ -2,6 +2,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -15,6 +16,9 @@ import { const makeProjectionPendingApprovalRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const CountRow = Schema.Struct({ + count: Schema.Number, + }); const upsertProjectionPendingApprovalRow = SqlSchema.void({ Request: ProjectionPendingApproval, @@ -49,6 +53,18 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { `, }); + const countProjectionPendingApprovalRows = SqlSchema.findOne({ + Request: ListProjectionPendingApprovalsInput, + Result: CountRow, + execute: ({ threadId }) => + sql` + SELECT COUNT(*) AS "count" + FROM projection_pending_approvals + WHERE thread_id = ${threadId} + AND status = 'pending' + `, + }); + const listProjectionPendingApprovalRows = SqlSchema.findAll({ Request: ListProjectionPendingApprovalsInput, Result: ProjectionPendingApproval, @@ -107,6 +123,15 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { ), ); + const countPendingByThreadId: ProjectionPendingApprovalRepositoryShape["countPendingByThreadId"] = + (input) => + countProjectionPendingApprovalRows(input).pipe( + Effect.map((row) => Math.max(0, Math.floor(row.count))), + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.countPendingByThreadId:query"), + ), + ); + const getByRequestId: ProjectionPendingApprovalRepositoryShape["getByRequestId"] = (input) => getProjectionPendingApprovalRow(input).pipe( Effect.mapError( @@ -126,6 +151,7 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { return { upsert, listByThreadId, + countPendingByThreadId, getByRequestId, deleteByRequestId, } satisfies ProjectionPendingApprovalRepositoryShape; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index f8e71d8c..6b4e205a 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -13,6 +13,7 @@ import { ListProjectionThreadActivitiesInput, ProjectionThreadActivity, ProjectionThreadActivityRepository, + ProjectionUserInputActivityAccountingRow, type ProjectionThreadActivityRepositoryShape, } from "../Services/ProjectionThreadActivities.ts"; @@ -23,6 +24,13 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( }), ); +const ProjectionUserInputActivityAccountingDbRowSchema = + ProjectionUserInputActivityAccountingRow.mapFields( + Struct.assign({ + payload: Schema.fromJsonString(Schema.Unknown), + }), + ); + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown) => Schema.isSchemaError(cause) @@ -97,6 +105,29 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { `, }); + const listProjectionUserInputAccountingRows = SqlSchema.findAll({ + Request: ListProjectionThreadActivitiesInput, + Result: ProjectionUserInputActivityAccountingDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + kind, + payload_json AS "payload", + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ORDER BY + created_at ASC, + activity_id ASC + `, + }); + const deleteProjectionThreadActivityRows = SqlSchema.void({ Request: DeleteProjectionThreadActivitiesInput, execute: ({ threadId }) => @@ -139,6 +170,17 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { ), ); + const listUserInputAccountingByThreadId: ProjectionThreadActivityRepositoryShape["listUserInputAccountingByThreadId"] = + (input) => + listProjectionUserInputAccountingRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.listUserInputAccountingByThreadId:query", + "ProjectionThreadActivityRepository.listUserInputAccountingByThreadId:decodeRows", + ), + ), + ); + const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadActivityRows(input).pipe( Effect.mapError( @@ -149,6 +191,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { return { upsert, listByThreadId, + listUserInputAccountingByThreadId, deleteByThreadId, } satisfies ProjectionThreadActivityRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 8f794cf1..f1603148 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -111,4 +111,44 @@ layer("ProjectionThreadMessageRepository", (it) => { assert.deepEqual(rows[0]?.attachments, []); }), ); + + it.effect("keeps messages with the same provider id isolated by thread", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const firstThreadId = ThreadId.make("thread-shared-message-id-a"); + const secondThreadId = ThreadId.make("thread-shared-message-id-b"); + const messageId = MessageId.make("message-shared-provider-id"); + const createdAt = "2026-02-28T20:00:00.000Z"; + + yield* repository.upsert({ + messageId, + threadId: firstThreadId, + turnId: null, + role: "assistant", + text: "first thread", + isStreaming: false, + createdAt, + updatedAt: "2026-02-28T20:00:01.000Z", + }); + + yield* repository.upsert({ + messageId, + threadId: secondThreadId, + turnId: null, + role: "assistant", + text: "second thread", + isStreaming: false, + createdAt, + updatedAt: "2026-02-28T20:00:02.000Z", + }); + + const firstRows = yield* repository.listByThreadId({ threadId: firstThreadId }); + const secondRows = yield* repository.listByThreadId({ threadId: secondThreadId }); + + assert.equal(firstRows.length, 1); + assert.equal(firstRows[0]?.text, "first thread"); + assert.equal(secondRows.length, 1); + assert.equal(secondRows[0]?.text, "second thread"); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 45885df7..39bd31cb 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ChatAttachment } from "@cafecode/contracts"; +import { ChatAttachment, IsoDateTime } from "@cafecode/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -42,6 +42,9 @@ function toProjectionThreadMessage( const makeProjectionThreadMessageRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const LatestUserMessageAtRow = Schema.Struct({ + latestUserMessageAt: IsoDateTime, + }); const upsertProjectionThreadMessageRow = SqlSchema.void({ Request: ProjectionThreadMessage, @@ -71,16 +74,16 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ( SELECT attachments_json FROM projection_thread_messages - WHERE message_id = ${row.messageId} + WHERE thread_id = ${row.threadId} + AND message_id = ${row.messageId} ) ), ${row.isStreaming ? 1 : 0}, ${row.createdAt}, ${row.updatedAt} ) - ON CONFLICT (message_id) + ON CONFLICT (thread_id, message_id) DO UPDATE SET - thread_id = excluded.thread_id, turn_id = excluded.turn_id, role = excluded.role, text = excluded.text, @@ -137,6 +140,20 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { `, }); + const getLatestUserMessageAtRow = SqlSchema.findOneOption({ + Request: ListProjectionThreadMessagesInput, + Result: LatestUserMessageAtRow, + execute: ({ threadId }) => + sql` + SELECT created_at AS "latestUserMessageAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND role = 'user' + ORDER BY created_at DESC, message_id DESC + LIMIT 1 + `, + }); + const deleteProjectionThreadMessageRows = SqlSchema.void({ Request: DeleteProjectionThreadMessagesInput, execute: ({ threadId }) => @@ -167,6 +184,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.map((rows) => rows.map(toProjectionThreadMessage)), ); + const getLatestUserMessageAtByThreadId: ProjectionThreadMessageRepositoryShape["getLatestUserMessageAtByThreadId"] = + (input) => + getLatestUserMessageAtRow(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionThreadMessageRepository.getLatestUserMessageAtByThreadId:query", + ), + ), + Effect.map(Option.map((row) => row.latestUserMessageAt)), + ); + const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadMessageRows(input).pipe( Effect.mapError( @@ -178,6 +206,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { upsert, getByMessageId, listByThreadId, + getLatestUserMessageAtByThreadId, deleteByThreadId, } satisfies ProjectionThreadMessageRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index 63aed1a1..7170ee12 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -6,6 +6,7 @@ import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionThreadProposedPlansInput, + GetLatestProjectionThreadProposedPlanInput, ListProjectionThreadProposedPlansInput, ProjectionThreadProposedPlan, ProjectionThreadProposedPlanRepository, @@ -69,6 +70,47 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { `, }); + const getLatestProjectionThreadProposedPlanRow = SqlSchema.findOneOption({ + Request: GetLatestProjectionThreadProposedPlanInput, + Result: ProjectionThreadProposedPlan, + execute: ({ threadId, turnId }) => { + if (turnId !== undefined && turnId !== null) { + return sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + AND turn_id = ${turnId} + ORDER BY updated_at DESC, plan_id DESC + LIMIT 1 + `; + } + + return sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + ORDER BY updated_at DESC, plan_id DESC + LIMIT 1 + `; + }, + }); + const deleteProjectionThreadProposedPlanRows = SqlSchema.void({ Request: DeleteProjectionThreadProposedPlansInput, execute: ({ threadId }) => sql` @@ -89,6 +131,15 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { ), ); + const getLatestByThreadId: ProjectionThreadProposedPlanRepositoryShape["getLatestByThreadId"] = ( + input, + ) => + getLatestProjectionThreadProposedPlanRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.getLatestByThreadId:query"), + ), + ); + const deleteByThreadId: ProjectionThreadProposedPlanRepositoryShape["deleteByThreadId"] = ( input, ) => @@ -101,6 +152,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { return { upsert, listByThreadId, + getLatestByThreadId, deleteByThreadId, } satisfies ProjectionThreadProposedPlanRepositoryShape; }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index cc5024d5..31e010e9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -43,6 +43,9 @@ import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts" import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; +import Migration0031 from "./Migrations/031_ProjectionThreadMessageThreadScopedIdentity.ts"; +import Migration0032 from "./Migrations/032_ReconcileCompletedThreadSessions.ts"; +import Migration0033 from "./Migrations/033_ReopenActiveTurnsWithPostCompletionActivity.ts"; /** * Migration loader with all migrations defined inline. @@ -85,6 +88,9 @@ export const migrationEntries = [ [28, "ProjectionThreadSessionInstanceId", Migration0028], [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], [30, "ProjectionThreadShellArchiveIndexes", Migration0030], + [31, "ProjectionThreadMessageThreadScopedIdentity", Migration0031], + [32, "ReconcileCompletedThreadSessions", Migration0032], + [33, "ReopenActiveTurnsWithPostCompletionActivity", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/031_ProjectionThreadMessageThreadScopedIdentity.test.ts b/apps/server/src/persistence/Migrations/031_ProjectionThreadMessageThreadScopedIdentity.test.ts new file mode 100644 index 00000000..863397dc --- /dev/null +++ b/apps/server/src/persistence/Migrations/031_ProjectionThreadMessageThreadScopedIdentity.test.ts @@ -0,0 +1,83 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("031_ProjectionThreadMessageThreadScopedIdentity", (it) => { + it.effect("rebuilds projection messages with a thread-scoped primary key", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 30 }); + yield* runMigrations({ toMigrationInclusive: 31 }); + + const columns = yield* sql<{ + readonly name: string; + readonly pk: number; + }>` + PRAGMA table_info(projection_thread_messages) + `; + const primaryKeyColumns = columns + .filter((column) => column.pk > 0) + .toSorted((left, right) => left.pk - right.pk) + .map((column) => column.name); + assert.deepStrictEqual(primaryKeyColumns, ["thread_id", "message_id"]); + + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES + ( + 'message-shared-provider-id', + 'thread-a', + NULL, + 'assistant', + 'first thread', + NULL, + 0, + '2026-02-28T20:00:00.000Z', + '2026-02-28T20:00:01.000Z' + ), + ( + 'message-shared-provider-id', + 'thread-b', + NULL, + 'assistant', + 'second thread', + NULL, + 0, + '2026-02-28T20:00:00.000Z', + '2026-02-28T20:00:02.000Z' + ) + `; + + const rows = yield* sql<{ + readonly threadId: string; + readonly text: string; + }>` + SELECT thread_id AS "threadId", text + FROM projection_thread_messages + WHERE message_id = 'message-shared-provider-id' + ORDER BY thread_id ASC + `; + assert.deepStrictEqual(rows, [ + { threadId: "thread-a", text: "first thread" }, + { threadId: "thread-b", text: "second thread" }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/031_ProjectionThreadMessageThreadScopedIdentity.ts b/apps/server/src/persistence/Migrations/031_ProjectionThreadMessageThreadScopedIdentity.ts new file mode 100644 index 00000000..4fbe3962 --- /dev/null +++ b/apps/server/src/persistence/Migrations/031_ProjectionThreadMessageThreadScopedIdentity.ts @@ -0,0 +1,73 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + DROP INDEX IF EXISTS idx_projection_thread_messages_thread_created + `; + + yield* sql` + DROP INDEX IF EXISTS idx_projection_thread_messages_thread_created_id + `; + + yield* sql` + CREATE TABLE projection_thread_messages_next ( + message_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + turn_id TEXT, + role TEXT NOT NULL, + text TEXT NOT NULL, + attachments_json TEXT, + is_streaming INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (thread_id, message_id) + ) + `; + + yield* sql` + INSERT INTO projection_thread_messages_next ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + SELECT + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + FROM projection_thread_messages + `; + + yield* sql` + DROP TABLE projection_thread_messages + `; + + yield* sql` + ALTER TABLE projection_thread_messages_next + RENAME TO projection_thread_messages + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_messages_thread_created + ON projection_thread_messages(thread_id, created_at) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_messages_thread_created_id + ON projection_thread_messages(thread_id, created_at, message_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/032_ReconcileCompletedThreadSessions.ts b/apps/server/src/persistence/Migrations/032_ReconcileCompletedThreadSessions.ts new file mode 100644 index 00000000..53743542 --- /dev/null +++ b/apps/server/src/persistence/Migrations/032_ReconcileCompletedThreadSessions.ts @@ -0,0 +1,49 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE projection_thread_sessions + SET + status = 'ready', + active_turn_id = NULL, + last_error = NULL, + updated_at = COALESCE(( + SELECT CASE + WHEN completed_at > projection_thread_sessions.updated_at + THEN completed_at + ELSE projection_thread_sessions.updated_at + END + FROM projection_turns + WHERE projection_turns.thread_id = projection_thread_sessions.thread_id + AND projection_turns.turn_id = projection_thread_sessions.active_turn_id + AND projection_turns.state = 'completed' + AND projection_turns.completed_at IS NOT NULL + LIMIT 1 + ), projection_thread_sessions.updated_at) + WHERE projection_thread_sessions.status = 'running' + AND projection_thread_sessions.active_turn_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM projection_turns + WHERE projection_turns.thread_id = projection_thread_sessions.thread_id + AND projection_turns.turn_id = projection_thread_sessions.active_turn_id + AND projection_turns.state = 'completed' + AND projection_turns.completed_at IS NOT NULL + ) + AND NOT EXISTS ( + SELECT 1 + FROM projection_turns + JOIN projection_thread_activities + ON projection_thread_activities.thread_id = projection_turns.thread_id + AND projection_thread_activities.turn_id = projection_turns.turn_id + AND projection_thread_activities.created_at > projection_turns.completed_at + WHERE projection_turns.thread_id = projection_thread_sessions.thread_id + AND projection_turns.turn_id = projection_thread_sessions.active_turn_id + AND projection_turns.state = 'completed' + AND projection_turns.completed_at IS NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Migrations/033_ReopenActiveTurnsWithPostCompletionActivity.ts b/apps/server/src/persistence/Migrations/033_ReopenActiveTurnsWithPostCompletionActivity.ts new file mode 100644 index 00000000..7228be53 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ReopenActiveTurnsWithPostCompletionActivity.ts @@ -0,0 +1,109 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + WITH active_completed_turns AS ( + SELECT + s.thread_id, + s.active_turn_id AS turn_id, + MAX(a.created_at) AS latest_activity_at + FROM projection_thread_sessions s + JOIN projection_turns t + ON t.thread_id = s.thread_id + AND t.turn_id = s.active_turn_id + JOIN projection_thread_activities a + ON a.thread_id = s.thread_id + AND a.turn_id = s.active_turn_id + AND a.created_at > t.completed_at + WHERE s.status = 'running' + AND s.active_turn_id IS NOT NULL + AND t.state = 'completed' + AND t.completed_at IS NOT NULL + GROUP BY s.thread_id, s.active_turn_id + ) + UPDATE projection_turns + SET + state = 'running', + completed_at = NULL + WHERE EXISTS ( + SELECT 1 + FROM active_completed_turns active + WHERE active.thread_id = projection_turns.thread_id + AND active.turn_id = projection_turns.turn_id + ) + `; + + yield* sql` + WITH active_completed_turns AS ( + SELECT + s.thread_id, + s.active_turn_id AS turn_id, + MAX(a.created_at) AS latest_activity_at + FROM projection_thread_sessions s + JOIN projection_turns t + ON t.thread_id = s.thread_id + AND t.turn_id = s.active_turn_id + JOIN projection_thread_activities a + ON a.thread_id = s.thread_id + AND a.turn_id = s.active_turn_id + WHERE s.status = 'running' + AND s.active_turn_id IS NOT NULL + AND t.state = 'running' + AND t.completed_at IS NULL + GROUP BY s.thread_id, s.active_turn_id + ) + UPDATE projection_thread_sessions + SET + updated_at = COALESCE(( + SELECT CASE + WHEN active.latest_activity_at > projection_thread_sessions.updated_at + THEN active.latest_activity_at + ELSE projection_thread_sessions.updated_at + END + FROM active_completed_turns active + WHERE active.thread_id = projection_thread_sessions.thread_id + AND active.turn_id = projection_thread_sessions.active_turn_id + LIMIT 1 + ), projection_thread_sessions.updated_at) + WHERE EXISTS ( + SELECT 1 + FROM active_completed_turns active + WHERE active.thread_id = projection_thread_sessions.thread_id + AND active.turn_id = projection_thread_sessions.active_turn_id + ) + `; + + yield* sql` + UPDATE projection_thread_sessions + SET + status = 'ready', + active_turn_id = NULL, + last_error = NULL, + updated_at = COALESCE(( + SELECT CASE + WHEN completed_at > projection_thread_sessions.updated_at + THEN completed_at + ELSE projection_thread_sessions.updated_at + END + FROM projection_turns + WHERE projection_turns.thread_id = projection_thread_sessions.thread_id + AND projection_turns.turn_id = projection_thread_sessions.active_turn_id + AND projection_turns.state = 'completed' + AND projection_turns.completed_at IS NOT NULL + LIMIT 1 + ), projection_thread_sessions.updated_at) + WHERE projection_thread_sessions.status = 'running' + AND projection_thread_sessions.active_turn_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM projection_turns + WHERE projection_turns.thread_id = projection_thread_sessions.thread_id + AND projection_turns.turn_id = projection_thread_sessions.active_turn_id + AND projection_turns.state = 'completed' + AND projection_turns.completed_at IS NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Services/AuthSessions.ts b/apps/server/src/persistence/Services/AuthSessions.ts index 74fdf42e..ca5faecc 100644 --- a/apps/server/src/persistence/Services/AuthSessions.ts +++ b/apps/server/src/persistence/Services/AuthSessions.ts @@ -1,4 +1,4 @@ -import { AuthClientMetadataDeviceType, AuthSessionId } from "@cafecode/contracts"; +import { AuthClientMetadataDeviceType, AuthSessionId } from "@cafecode/contracts/auth"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; diff --git a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts index e37a299e..f5ae95bb 100644 --- a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts @@ -69,6 +69,13 @@ export interface ProjectionPendingApprovalRepositoryShape { input: ListProjectionPendingApprovalsInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Count unresolved approvals for a thread without loading all rows. + */ + readonly countPendingByThreadId: ( + input: ListProjectionPendingApprovalsInput, + ) => Effect.Effect; + /** * Read a pending approval row by request id. */ diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index f4448c39..dbf63218 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -38,6 +38,15 @@ export const ListProjectionThreadActivitiesInput = Schema.Struct({ }); export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; +export const ProjectionUserInputActivityAccountingRow = Schema.Struct({ + activityId: EventId, + kind: Schema.String, + payload: Schema.Unknown, + createdAt: IsoDateTime, +}); +export type ProjectionUserInputActivityAccountingRow = + typeof ProjectionUserInputActivityAccountingRow.Type; + export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ threadId: ThreadId, }); @@ -67,6 +76,20 @@ export interface ProjectionThreadActivityRepositoryShape { input: ListProjectionThreadActivitiesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * List only activity rows that can affect pending user-input accounting. + * + * This intentionally avoids loading full tool activity history while still + * preserving the set-based request accounting semantics used by thread shell + * summaries. + */ + readonly listUserInputAccountingByThreadId: ( + input: ListProjectionThreadActivitiesInput, + ) => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + /** * Delete projected thread activity rows by thread. */ diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index acf784b6..903c8cfc 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -56,7 +56,7 @@ export interface ProjectionThreadMessageRepositoryShape { /** * Insert or replace a projected thread message row. * - * Upserts by `messageId`. + * Upserts by `threadId` and `messageId`. */ readonly upsert: ( message: ProjectionThreadMessage, @@ -78,6 +78,14 @@ export interface ProjectionThreadMessageRepositoryShape { input: ListProjectionThreadMessagesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read the latest user-authored message timestamp for summary projections + * without loading message bodies. + */ + readonly getLatestUserMessageAtByThreadId: ( + input: ListProjectionThreadMessagesInput, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Delete projected thread messages by thread. */ diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index 1474e84f..a5dbfb77 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -5,6 +5,7 @@ import { TrimmedNonEmptyString, TurnId, } from "@cafecode/contracts"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; @@ -29,6 +30,13 @@ export const ListProjectionThreadProposedPlansInput = Schema.Struct({ export type ListProjectionThreadProposedPlansInput = typeof ListProjectionThreadProposedPlansInput.Type; +export const GetLatestProjectionThreadProposedPlanInput = Schema.Struct({ + threadId: ThreadId, + turnId: Schema.optional(Schema.NullOr(TurnId)), +}); +export type GetLatestProjectionThreadProposedPlanInput = + typeof GetLatestProjectionThreadProposedPlanInput.Type; + export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ threadId: ThreadId, }); @@ -42,6 +50,9 @@ export interface ProjectionThreadProposedPlanRepositoryShape { readonly listByThreadId: ( input: ListProjectionThreadProposedPlansInput, ) => Effect.Effect, ProjectionRepositoryError>; + readonly getLatestByThreadId: ( + input: GetLatestProjectionThreadProposedPlanInput, + ) => Effect.Effect, ProjectionRepositoryError>; readonly deleteByThreadId: ( input: DeleteProjectionThreadProposedPlansInput, ) => Effect.Effect; diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 151600c4..583a160a 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -18,6 +18,7 @@ import { resolveAvailableEditors, resolveBrowserLaunch, resolveEditorLaunch, + resolveEditorProcessLaunch, } from "./externalLauncher.ts"; function encodeUtf16LeBase64(input: string): string { @@ -632,13 +633,10 @@ it.layer(NodeServices.layer)("launchEditorProcess", (it) => { assertSuccess(result, undefined); assert.ok(spawnedCommand); assert.equal(spawnedCommand.command, process.execPath); - assert.deepEqual( - spawnedCommand.args, - process.platform === "win32" ? expectedArgs.map((arg) => `"${arg}"`) : expectedArgs, - ); + assert.deepEqual(spawnedCommand.args, expectedArgs); assert.deepEqual(spawnedCommand.options, { detached: true, - shell: process.platform === "win32", + shell: false, stdin: "ignore", stdout: "ignore", stderr: "ignore", @@ -659,6 +657,35 @@ it.layer(NodeServices.layer)("launchEditorProcess", (it) => { ); }); +it("resolveEditorProcessLaunch keeps hostile Windows paths as argv data", () => { + const hostilePaths = [ + String.raw`C:\work\file" & calc.exe & ".ts`, + String.raw`C:\work\%COMSPEC%\project^name\file.ts`, + "C:\\work\\newline\n& whoami\nfile.ts", + String.raw`\\server\share\folder & powershell -nop -c calc\file.ts`, + ]; + + for (const hostilePath of hostilePaths) { + const launch = resolveEditorProcessLaunch( + { + command: "code", + args: ["--goto", hostilePath], + }, + "win32", + ); + + assert.equal(launch.command, "code"); + assert.deepEqual(launch.args, ["--goto", hostilePath]); + assert.deepEqual(launch.options, { + detached: true, + shell: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + } +}); + it.layer(NodeServices.layer)("isCommandAvailable", (it) => { it.effect("resolves win32 commands with PATHEXT", () => Effect.gen(function* () { diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 40b87335..e135befa 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -297,6 +297,23 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; }); +export function resolveEditorProcessLaunch( + launch: EditorLaunch, + _platform: NodeJS.Platform = process.platform, +): ProcessLaunch { + return { + command: launch.command, + args: [...launch.args], + options: { + detached: true, + shell: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + }; +} + const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, errorMessage: string, @@ -327,21 +344,7 @@ export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProce }); } - const isWin32 = process.platform === "win32"; - yield* launchAndUnref( - { - command: launch.command, - args: isWin32 ? launch.args.map((arg) => `"${arg}"`) : [...launch.args], - options: { - detached: true, - shell: isWin32, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }, - }, - "failed to spawn detached process", - ); + yield* launchAndUnref(resolveEditorProcessLaunch(launch), "failed to spawn detached process"); }); const make = Effect.gen(function* () { diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts deleted file mode 100644 index a21f68b5..00000000 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { ProjectId, type OrchestrationProject } from "@cafecode/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { describe, expect, it, vi } from "vitest"; - -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { TerminalManager } from "../../terminal/Services/Manager.ts"; -import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; -import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts"; - -const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({ - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/repo/project", - defaultModelSelection: null, - scripts, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - deletedAt: null, -}); - -const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => Effect.die("unused"), - getSnapshot: () => Effect.die("unused"), - getShellSnapshot: () => Effect.die("unused"), - getArchivedShellSnapshot: () => Effect.die("unused"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), - getCounts: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: (workspaceRoot) => - Effect.succeed( - workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), - ), - getProjectShellById: (projectId) => - Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.die("unused"), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.die("unused"), - getThreadDetailById: () => Effect.die("unused"), - }); - -describe("ProjectSetupScriptRunner", () => { - it("returns no-script when no setup script exists", async () => { - const open = vi.fn(); - const write = vi.fn(); - const project = makeProject([]); - const runner = await Effect.runPromise( - Effect.service(ProjectSetupScriptRunner).pipe( - Effect.provide( - ProjectSetupScriptRunnerLive.pipe( - Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), - Layer.provideMerge( - Layer.succeed(TerminalManager, { - open, - write, - resize: () => Effect.void, - clear: () => Effect.void, - restart: () => Effect.die(new Error("unused")), - close: () => Effect.void, - subscribe: () => Effect.succeed(() => undefined), - }), - ), - ), - ), - ), - ); - - const result = await Effect.runPromise( - runner.runForThread({ - threadId: "thread-1", - projectId: "project-1", - worktreePath: "/repo/worktrees/a", - }), - ); - - expect(result).toEqual({ status: "no-script" }); - expect(open).not.toHaveBeenCalled(); - expect(write).not.toHaveBeenCalled(); - }); - - it("opens the deterministic setup terminal with worktree env and writes the command", async () => { - const open = vi.fn(() => - Effect.succeed({ - threadId: "thread-1", - terminalId: "setup-setup", - cwd: "/repo/worktrees/a", - worktreePath: "/repo/worktrees/a", - status: "running" as const, - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: "2026-01-01T00:00:00.000Z", - }), - ); - const write = vi.fn(() => Effect.void); - const project = makeProject([ - { - id: "setup", - name: "Setup", - command: "bun install", - icon: "configure", - runOnWorktreeCreate: true, - }, - ]); - const runner = await Effect.runPromise( - Effect.service(ProjectSetupScriptRunner).pipe( - Effect.provide( - ProjectSetupScriptRunnerLive.pipe( - Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), - Layer.provideMerge( - Layer.succeed(TerminalManager, { - open, - write, - resize: () => Effect.void, - clear: () => Effect.void, - restart: () => Effect.die(new Error("unused")), - close: () => Effect.void, - subscribe: () => Effect.succeed(() => undefined), - }), - ), - ), - ), - ), - ); - - const result = await Effect.runPromise( - runner.runForThread({ - threadId: "thread-1", - projectCwd: "/repo/project", - worktreePath: "/repo/worktrees/a", - }), - ); - - expect(result).toEqual({ - status: "started", - scriptId: "setup", - scriptName: "Setup", - terminalId: "setup-setup", - cwd: "/repo/worktrees/a", - }); - expect(open).toHaveBeenCalledWith({ - threadId: "thread-1", - terminalId: "setup-setup", - cwd: "/repo/worktrees/a", - worktreePath: "/repo/worktrees/a", - env: { - CAFE_CODE_PROJECT_ROOT: "/repo/project", - CAFE_CODE_WORKTREE_PATH: "/repo/worktrees/a", - }, - }); - expect(write).toHaveBeenCalledWith({ - threadId: "thread-1", - terminalId: "setup-setup", - data: "bun install\r", - }); - }); -}); diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts index 6c356598..a8c6f918 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts @@ -1,101 +1,17 @@ -import { ProjectId } from "@cafecode/contracts"; -import { projectScriptRuntimeEnv, setupProjectScript } from "@cafecode/shared/projectScripts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { TerminalManager } from "../../terminal/Services/Manager.ts"; import { type ProjectSetupScriptRunnerShape, ProjectSetupScriptRunner, - ProjectSetupScriptRunnerError, } from "../Services/ProjectSetupScriptRunner.ts"; -const makeProjectSetupScriptRunner = Effect.gen(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const terminalManager = yield* TerminalManager; - - const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => - Effect.gen(function* () { - const project = - (input.projectId - ? yield* projectionSnapshotQuery - .getProjectShellById(ProjectId.make(input.projectId)) - .pipe(Effect.map(Option.getOrUndefined)) - : null) ?? - (input.projectCwd - ? yield* projectionSnapshotQuery - .getActiveProjectByWorkspaceRoot(input.projectCwd) - .pipe(Effect.map(Option.getOrUndefined)) - : null) ?? - null; - - if (!project) { - return yield* new ProjectSetupScriptRunnerError({ - message: "Project was not found for setup script execution.", - }); - } - - const script = setupProjectScript(project.scripts); - if (!script) { - return { - status: "no-script", - } as const; - } - - const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; - const cwd = input.worktreePath; - const env = projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: input.worktreePath, - }); - - yield* terminalManager.open({ - threadId: input.threadId, - terminalId, - cwd, - worktreePath: input.worktreePath, - env, - }); - yield* terminalManager.write({ - threadId: input.threadId, - terminalId, - data: `${script.command}\r`, - }); - - return { - status: "started", - scriptId: script.id, - scriptName: script.name, - terminalId, - cwd, - } as const; - }).pipe( - Effect.mapError((cause) => { - if ( - typeof cause === "object" && - cause !== null && - "_tag" in cause && - cause._tag === "ProjectSetupScriptRunnerError" - ) { - return cause as ProjectSetupScriptRunnerError; - } - const message = - typeof cause === "object" && - cause !== null && - "message" in cause && - typeof cause.message === "string" - ? cause.message - : String(cause); - return new ProjectSetupScriptRunnerError({ message }); - }), - ); - - return { - runForThread, - } satisfies ProjectSetupScriptRunnerShape; -}); +const makeProjectSetupScriptRunner = Effect.succeed({ + runForThread: () => + Effect.succeed({ + status: "no-script", + } as const), +} satisfies ProjectSetupScriptRunnerShape); export const ProjectSetupScriptRunnerLive = Layer.effect( ProjectSetupScriptRunner, diff --git a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts index 05d031c1..8ff1505d 100644 --- a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts @@ -6,24 +6,13 @@ export interface ProjectSetupScriptRunnerResultNoScript { readonly status: "no-script"; } -export interface ProjectSetupScriptRunnerResultStarted { - readonly status: "started"; - readonly scriptId: string; - readonly scriptName: string; - readonly terminalId: string; - readonly cwd: string; -} - -export type ProjectSetupScriptRunnerResult = - | ProjectSetupScriptRunnerResultNoScript - | ProjectSetupScriptRunnerResultStarted; +export type ProjectSetupScriptRunnerResult = ProjectSetupScriptRunnerResultNoScript; export interface ProjectSetupScriptRunnerInput { readonly threadId: string; readonly projectId?: string; readonly projectCwd?: string; readonly worktreePath: string; - readonly preferredTerminalId?: string; } export class ProjectSetupScriptRunnerError extends Data.TaggedError( diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 1a3239ab..b5c4120d 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -97,6 +97,7 @@ const withInstanceIdentity = ...(input.displayName ? { displayName: input.displayName } : {}), ...(input.accentColor ? { accentColor: input.accentColor } : {}), continuation: { groupKey: input.continuationGroupKey }, + runtimeCapabilities: { ...snapshot.runtimeCapabilities, liveSteer: "supported" }, }); export const CodexDriver: ProviderDriver = { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index a31660d6..6c008a63 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1720,6 +1720,76 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("uses the selected Claude context window over conflicting model usage metadata", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const modelSelection = createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-7", + [{ id: "contextWindow", value: "200k" }], + ); + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + modelSelection, + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-result-usage-selected-window", + usage: { + total_tokens: 250000, + }, + modelUsage: { + "claude-opus-4-7[1m]": { + contextWindow: 1000000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated"); + assert.equal(usageEvent?.type, "thread.token-usage.updated"); + if (usageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(usageEvent.payload, { + usage: { + usedTokens: 200000, + lastUsedTokens: 200000, + totalProcessedTokens: 250000, + maxTokens: 200000, + }, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect( "preserves oversized Claude result totals after task progress snapshots are recorded", () => { @@ -2736,7 +2806,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("uses an app-generated Claude session id for fresh sessions", () => { + it.effect("uses an app-generated Claude session id without persisting a zero-turn cursor", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -2748,20 +2818,108 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - const sessionResumeCursor = session.resumeCursor as { - threadId?: string; - resume?: string; - turnCount?: number; - }; - assert.equal(sessionResumeCursor.threadId, THREAD_ID); - assert.equal(typeof sessionResumeCursor.resume, "string"); - assert.equal(sessionResumeCursor.turnCount, 0); + assert.equal(session.resumeCursor, undefined); assert.match( - sessionResumeCursor.resume ?? "", + String(createInput?.options.sessionId ?? ""), /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, ); assert.equal(createInput?.options.resume, undefined); - assert.equal(createInput?.options.sessionId, sessionResumeCursor.resume); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("ignores stale zero-turn Claude resume cursors on startup", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: { + threadId: RESUME_THREAD_ID, + resume: "550e8400-e29b-41d4-a716-446655440000", + turnCount: 0, + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(session.resumeCursor, undefined); + assert.equal(createInput?.options.resume, undefined); + assert.match( + String(createInput?.options.sessionId ?? ""), + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("emits a durable Claude resume cursor after the first completed turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + const completedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + const sessionId = String(harness.getLastCreateQueryInput()?.options.sessionId); + + harness.query.emit({ + type: "assistant", + session_id: sessionId, + uuid: "assistant-first-turn", + parent_tool_use_id: null, + message: { + id: "assistant-message-first-turn", + content: [{ type: "text", text: "Hi" }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: sessionId, + uuid: "result-first-turn", + } as unknown as SDKMessage); + + const completed = yield* Fiber.join(completedFiber); + assert.equal(completed._tag, "Some"); + if (completed._tag !== "Some" || completed.value.type !== "turn.completed") { + return; + } + const payload = completed.value.payload as { + readonly resumeCursor?: { + readonly threadId?: string; + readonly resume?: string; + readonly resumeSessionAt?: string; + readonly turnCount?: number; + }; + }; + assert.equal(payload.resumeCursor?.threadId, THREAD_ID); + assert.equal(payload.resumeCursor?.resume, sessionId); + assert.equal(payload.resumeCursor?.resumeSessionAt, "assistant-first-turn"); + assert.equal(payload.resumeCursor?.turnCount, 1); + + const activeSessions = yield* adapter.listSessions(); + assert.deepEqual(activeSessions[0]?.resumeCursor, payload.resumeCursor); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f6bf142c..c766e255 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -72,6 +72,7 @@ import { getClaudeModelCapabilities, normalizeClaudeCliEffort, resolveClaudeApiModelId, + resolveClaudeSelectedContextWindowTokens, resolveClaudeEffort, } from "./ClaudeProvider.ts"; import { @@ -166,7 +167,10 @@ interface ClaudeSessionContext { readonly startedAt: string; readonly basePermissionMode: PermissionMode | undefined; currentApiModelId: string | undefined; + selectedContextWindowTokens: number | undefined; resumeSessionId: string | undefined; + resumeCursorDurable: boolean; + resumeBaseTurnCount: number; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; readonly turns: Array<{ @@ -432,6 +436,204 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef }; } +function isDurableClaudeResumeState( + resumeState: ClaudeResumeState | undefined, +): resumeState is ClaudeResumeState & { readonly resume: string } { + if (!resumeState?.resume) { + return false; + } + return Boolean(resumeState.resumeSessionAt) || (resumeState.turnCount ?? 0) > 0; +} + +function claudeProjectDirectoryName(path: Path.Path, cwd: string): string { + return path.resolve(cwd).replaceAll(path.sep, "-"); +} + +function resolveClaudeConfigDirectory(path: Path.Path, env: NodeJS.ProcessEnv): string { + const configDir = env.CLAUDE_CONFIG_DIR?.trim(); + if (configDir) { + return path.resolve(configDir); + } + const homePath = env.HOME?.trim(); + return homePath ? path.join(path.resolve(homePath), ".claude") : path.resolve(".claude"); +} + +function pathExists( + fileSystem: FileSystem.FileSystem, + filePath: string, +): Effect.Effect { + return fileSystem.exists(filePath).pipe(Effect.catch(() => Effect.succeed(false))); +} + +function copyRegularFileIfMissing(input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly sourcePath: string; + readonly targetPath: string; +}): Effect.Effect { + return Effect.gen(function* () { + if (yield* pathExists(input.fileSystem, input.targetPath)) { + return false; + } + const sourceInfo = yield* input.fileSystem + .stat(input.sourcePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (sourceInfo?.type !== "File") { + return false; + } + yield* input.fileSystem + .makeDirectory(input.path.dirname(input.targetPath), { recursive: true }) + .pipe(Effect.catch(() => Effect.void)); + return yield* input.fileSystem + .copy(input.sourcePath, input.targetPath, { + overwrite: false, + preserveTimestamps: true, + }) + .pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ); + }); +} + +function copyDirectoryIfMissing(input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly sourcePath: string; + readonly targetPath: string; +}): Effect.Effect { + return Effect.gen(function* () { + if (yield* pathExists(input.fileSystem, input.targetPath)) { + return false; + } + const sourceInfo = yield* input.fileSystem + .stat(input.sourcePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (sourceInfo?.type !== "Directory") { + return false; + } + yield* input.fileSystem + .makeDirectory(input.path.dirname(input.targetPath), { recursive: true }) + .pipe(Effect.catch(() => Effect.void)); + return yield* input.fileSystem + .copy(input.sourcePath, input.targetPath, { + overwrite: false, + preserveTimestamps: true, + }) + .pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ); + }); +} + +function isDirectory( + fileSystem: FileSystem.FileSystem, + filePath: string, +): Effect.Effect { + return fileSystem.stat(filePath).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.catch(() => Effect.succeed(false)), + ); +} + +const ensureClaudeResumeArtifactsForCwd = Effect.fn( + "ClaudeAdapter.ensureClaudeResumeArtifactsForCwd", +)(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly env: NodeJS.ProcessEnv; + readonly cwd: string | undefined; + readonly resumeSessionId: string | undefined; +}): Effect.fn.Return { + if (!input.cwd || !input.resumeSessionId) { + return; + } + + const { fileSystem, path } = input; + const resumeSessionId = input.resumeSessionId; + const projectsDirectory = path.join(resolveClaudeConfigDirectory(path, input.env), "projects"); + if (!(yield* pathExists(fileSystem, projectsDirectory))) { + return; + } + + const targetProjectDirectory = path.join( + projectsDirectory, + claudeProjectDirectoryName(path, input.cwd), + ); + const targetSessionFile = path.join(targetProjectDirectory, `${resumeSessionId}.jsonl`); + const targetSessionDirectory = path.join(targetProjectDirectory, resumeSessionId); + + const result = yield* Effect.gen(function* () { + const targetSessionFileExists = yield* pathExists(fileSystem, targetSessionFile); + const targetSessionDirectoryExists = yield* pathExists(fileSystem, targetSessionDirectory); + if (targetSessionFileExists && targetSessionDirectoryExists) { + return undefined; + } + + const projectEntries = yield* fileSystem.readDirectory(projectsDirectory); + for (const entryName of projectEntries) { + const sourceProjectDirectory = path.join(projectsDirectory, entryName); + if (sourceProjectDirectory === targetProjectDirectory) { + continue; + } + if (!(yield* isDirectory(fileSystem, sourceProjectDirectory))) { + continue; + } + + const sourceSessionFile = path.join(sourceProjectDirectory, `${resumeSessionId}.jsonl`); + const sourceSessionDirectory = path.join(sourceProjectDirectory, resumeSessionId); + const sourceSessionFileExists = yield* pathExists(fileSystem, sourceSessionFile); + const sourceSessionDirectoryExists = yield* pathExists(fileSystem, sourceSessionDirectory); + if (!sourceSessionFileExists && !sourceSessionDirectoryExists) { + continue; + } + + const copiedFile = sourceSessionFileExists + ? yield* copyRegularFileIfMissing({ + fileSystem, + path, + sourcePath: sourceSessionFile, + targetPath: targetSessionFile, + }) + : false; + const copiedDirectory = sourceSessionDirectoryExists + ? yield* copyDirectoryIfMissing({ + fileSystem, + path, + sourcePath: sourceSessionDirectory, + targetPath: targetSessionDirectory, + }) + : false; + if (!copiedFile && !copiedDirectory) { + return undefined; + } + + return { copiedFile, copiedDirectory, sourceProjectDirectory, targetProjectDirectory }; + } + return undefined; + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("claude.resume.artifacts.copy-failed", { + sessionId: input.resumeSessionId, + cwd: input.cwd, + cause: Cause.pretty(cause), + }).pipe(Effect.as(undefined)), + ), + ); + + if (!result) { + return; + } + yield* Effect.logInfo("claude.resume.artifacts.copied-for-cwd", { + sessionId: input.resumeSessionId, + sourceProjectDirectory: result.sourceProjectDirectory, + targetProjectDirectory: result.targetProjectDirectory, + copiedFile: result.copiedFile, + copiedDirectory: result.copiedDirectory, + }); +}); + function classifyToolItemType(toolName: string): CanonicalItemType { const normalized = toolName.toLowerCase(); if (normalized.includes("agent")) { @@ -1092,12 +1294,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) { const threadId = context.session.threadId; if (!threadId) return; + if (!context.resumeCursorDurable) return; const resumeCursor = { threadId, ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), - turnCount: context.turns.length, + turnCount: context.resumeBaseTurnCount + context.turns.length, }; context.session = { @@ -1426,8 +1629,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( result?: SDKResultMessage, ) { const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); - if (resultContextWindow !== undefined) { - context.lastKnownContextWindow = resultContextWindow; + const effectiveContextWindow = + context.selectedContextWindowTokens ?? resultContextWindow ?? context.lastKnownContextWindow; + if (effectiveContextWindow !== undefined) { + context.lastKnownContextWindow = effectiveContextWindow; } // The SDK result.usage contains *accumulated* totals across all API calls @@ -1435,14 +1640,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // This does NOT represent the current context window size. // Instead, use the last known context-window-accurate usage from task_progress // events and treat the accumulated total as totalProcessedTokens. - const accumulatedSnapshot = normalizeClaudeTokenUsage( - result?.usage, - resultContextWindow ?? context.lastKnownContextWindow, - ); + const accumulatedSnapshot = normalizeClaudeTokenUsage(result?.usage, effectiveContextWindow); const accumulatedTotalProcessedTokens = accumulatedSnapshot?.totalProcessedTokens ?? accumulatedSnapshot?.usedTokens; const lastGoodUsage = context.lastKnownTokenUsage; - const maxTokens = resultContextWindow ?? context.lastKnownContextWindow; + const maxTokens = effectiveContextWindow; const usageSnapshot: ThreadTokenUsageSnapshot | undefined = lastGoodUsage ? { ...lastGoodUsage, @@ -1485,6 +1687,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( threadId: context.session.threadId, payload: { state: status, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), ...(result?.usage ? { usage: result.usage } : {}), ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), @@ -1544,6 +1749,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( id: turnState.turnId, items: [...turnState.items], }); + context.resumeCursorDurable = true; + yield* updateResumeCursor(context); if (usageSnapshot) { const usageStamp = yield* makeEventStamp(); @@ -1571,6 +1778,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( turnId: turnState.turnId, payload: { state: status, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), ...(result?.usage ? { usage: result.usage } : {}), ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), @@ -2166,7 +2376,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (message.usage) { const normalizedUsage = normalizeClaudeTokenUsage( message.usage, - context.lastKnownContextWindow, + context.selectedContextWindowTokens ?? context.lastKnownContextWindow, ); if (normalizedUsage) { context.lastKnownTokenUsage = normalizedUsage; @@ -2198,7 +2408,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (message.usage) { const normalizedUsage = normalizeClaudeTokenUsage( message.usage, - context.lastKnownContextWindow, + context.selectedContextWindowTokens ?? context.lastKnownContextWindow, ); if (normalizedUsage) { context.lastKnownTokenUsage = normalizedUsage; @@ -2550,11 +2760,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const startedAt = yield* nowIso; const resumeState = readClaudeResumeState(input.resumeCursor); + const durableResumeState = isDurableClaudeResumeState(resumeState) ? resumeState : undefined; const threadId = input.threadId; - const existingResumeSessionId = resumeState?.resume; + const existingResumeSessionId = durableResumeState?.resume; const newSessionId = existingResumeSessionId === undefined ? yield* Random.nextUUIDv4 : undefined; const sessionId = existingResumeSessionId ?? newSessionId; + const resumeBaseTurnCount = durableResumeState?.turnCount ?? 0; const runtimeContext = yield* Effect.context(); const runFork = Effect.runForkWith(runtimeContext); @@ -2873,6 +3085,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const caps = getClaudeModelCapabilities(modelSelection?.model); const descriptors = getProviderOptionDescriptors({ caps }); const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; + const selectedContextWindowTokens = resolveClaudeSelectedContextWindowTokens(modelSelection); const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); const effort = resolveClaudeEffort(caps, rawEffort) ?? null; const fastModeSupported = descriptors.some( @@ -2930,10 +3143,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "provider.runtime_mode": input.runtimeMode, "claude.resume.source": existingResumeSessionId !== undefined ? "resume-session" : "generated-session", - "claude.resume.thread_id": resumeState?.threadId ?? "", + "claude.resume.thread_id": durableResumeState?.threadId ?? "", "claude.resume.session_id": existingResumeSessionId ?? "", - "claude.resume.session_at": resumeState?.resumeSessionAt ?? "", - "claude.resume.turn_count": resumeState?.turnCount ?? -1, + "claude.resume.session_at": durableResumeState?.resumeSessionAt ?? "", + "claude.resume.turn_count": durableResumeState?.turnCount ?? -1, "claude.query.cwd": input.cwd ?? "", "claude.query.model": apiModelId ?? "", "claude.query.effort": effectiveEffort ?? "", @@ -2949,6 +3162,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.query.path_to_executable": claudeBinaryPath, }); + yield* ensureClaudeResumeArtifactsForCwd({ + fileSystem, + path, + env: claudeEnvironment, + cwd: input.cwd, + resumeSessionId: existingResumeSessionId, + }); + const queryRuntime = yield* Effect.try({ try: () => createQuery({ @@ -2964,6 +3185,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }), }); + const initialResumeCursor = + existingResumeSessionId !== undefined + ? { + ...(threadId ? { threadId } : {}), + resume: existingResumeSessionId, + ...(durableResumeState?.resumeSessionAt + ? { resumeSessionAt: durableResumeState.resumeSessionAt } + : {}), + turnCount: resumeBaseTurnCount, + } + : undefined; const session: ProviderSession = { threadId, provider: PROVIDER, @@ -2973,12 +3205,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(input.cwd ? { cwd: input.cwd } : {}), ...(modelSelection?.model ? { model: modelSelection.model } : {}), ...(threadId ? { threadId } : {}), - resumeCursor: { - ...(threadId ? { threadId } : {}), - ...(sessionId ? { resume: sessionId } : {}), - ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), - turnCount: resumeState?.turnCount ?? 0, - }, + ...(initialResumeCursor !== undefined ? { resumeCursor: initialResumeCursor } : {}), createdAt: startedAt, updatedAt: startedAt, }; @@ -2991,15 +3218,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( startedAt, basePermissionMode: permissionMode, currentApiModelId: apiModelId, + selectedContextWindowTokens, resumeSessionId: sessionId, + resumeCursorDurable: existingResumeSessionId !== undefined, + resumeBaseTurnCount, pendingApprovals, pendingUserInputs, turns: [], inFlightTools, turnState: undefined, - lastKnownContextWindow: undefined, + lastKnownContextWindow: selectedContextWindowTokens, lastKnownTokenUsage: undefined, - lastAssistantUuid: resumeState?.resumeSessionAt, + lastAssistantUuid: durableResumeState?.resumeSessionAt, lastThreadStartedId: undefined, stopped: false, }; @@ -3091,6 +3321,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (modelSelection?.model) { const apiModelId = resolveClaudeApiModelId(modelSelection); + const selectedContextWindowTokens = resolveClaudeSelectedContextWindowTokens(modelSelection); if (context.currentApiModelId !== apiModelId) { yield* Effect.tryPromise({ try: () => context.query.setModel(apiModelId), @@ -3098,6 +3329,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); context.currentApiModelId = apiModelId; } + context.selectedContextWindowTokens = selectedContextWindowTokens; + if (selectedContextWindowTokens !== undefined) { + context.lastKnownContextWindow = selectedContextWindowTokens; + } context.session = { ...context.session, model: modelSelection.model, @@ -3276,6 +3511,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + liveSteer: "unsupported", }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 95fd85a8..07ff7102 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -14,7 +14,6 @@ import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities, - getModelSelectionStringOptionValue, getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@cafecode/shared/model"; @@ -218,6 +217,34 @@ export function resolveClaudeEffort( return typeof value === "string" ? value : undefined; } +export function resolveClaudeContextWindowOption( + modelSelection: ModelSelection | null | undefined, +): "200k" | "1m" | undefined { + const caps = getClaudeModelCapabilities(modelSelection?.model); + const descriptors = getProviderOptionDescriptors({ + caps, + selections: modelSelection?.options, + }); + const contextWindowDescriptor = descriptors.find( + (descriptor) => descriptor.id === "contextWindow", + ); + const value = getProviderOptionCurrentValue(contextWindowDescriptor); + return value === "200k" || value === "1m" ? value : undefined; +} + +export function resolveClaudeSelectedContextWindowTokens( + modelSelection: ModelSelection | null | undefined, +): number | undefined { + switch (resolveClaudeContextWindowOption(modelSelection)) { + case "200k": + return 200_000; + case "1m": + return 1_000_000; + default: + return undefined; + } +} + /** * Normalize a resolved Claude effort value into one suitable for the Claude * CLI's `--effort` flag. @@ -239,7 +266,7 @@ export function normalizeClaudeCliEffort(effort: string | null | undefined): str } export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { - switch (getModelSelectionStringOptionValue(modelSelection, "contextWindow")) { + switch (resolveClaudeContextWindowOption(modelSelection)) { case "1m": return `${modelSelection.model}[1m]`; default: diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 0c5b0580..351fd2a2 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -601,6 +601,35 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("retires the active session when the Codex runtime exits", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + const event: ProviderEvent = { + id: asEventId("evt-session-exited"), + kind: "session", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "session/exited", + message: "Codex App Server exited with code 1.", + }; + + yield* runtime.emit(event); + const firstEvent = yield* Fiber.join(firstEventFiber); + yield* Effect.yieldNow; + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "session.exited"); + assert.equal(yield* adapter.hasSession(asThreadId("thread-1")), false); + assert.equal(runtime.closeImpl.mock.calls.length, 1); + }), + ); + it.effect("maps retryable Codex error notifications to runtime.warning", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index e018dc2e..47a8cd09 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1362,6 +1362,29 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const runtimeEventQueue = yield* Queue.unbounded(); const sessions = new Map(); + const retireExitedSession = Effect.fn("CodexAdapter.retireExitedSession")(function* ( + threadId: ThreadId, + reason: string, + ) { + const session = sessions.get(threadId); + if (!session || session.stopped) { + return; + } + + session.stopped = true; + sessions.delete(threadId); + yield* Effect.logWarning("codex.session.retired-after-runtime-exit", { + threadId, + reason, + }); + + yield* session.runtime.close.pipe(Effect.ignore); + yield* Effect.ignore(Scope.close(session.scope, Exit.void)); + yield* Effect.forkChild( + Effect.yieldNow.pipe(Effect.andThen(Fiber.interrupt(session.eventFiber).pipe(Effect.ignore))), + ); + }); + const startSession: CodexAdapterShape["startSession"] = (input) => Effect.scoped( Effect.gen(function* () { @@ -1431,6 +1454,12 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( return; } yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); + if (event.method === "session/exited" || event.method === "session/closed") { + yield* retireExitedSession( + event.threadId, + event.message ?? `${event.method} received from Codex runtime`, + ); + } }), ).pipe(Effect.forkChild); @@ -1673,6 +1702,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + liveSteer: "supported", }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 67ec702b..ce5852a7 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1065,7 +1065,7 @@ export function makeCursorAdapter( return { provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", liveSteer: "unsupported" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 7e66fa6d..4ae8ead0 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1415,6 +1415,7 @@ export function makeOpenCodeAdapter( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + liveSteer: "unsupported", }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index a98ff466..3fe1814b 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -29,7 +29,7 @@ const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); const fakeCodexAdapter: CodexAdapterShape = { provider: CODEX_DRIVER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", liveSteer: "unsupported" }, startSession: vi.fn(), sendTurn: vi.fn(), interruptTurn: vi.fn(), @@ -46,7 +46,7 @@ const fakeCodexAdapter: CodexAdapterShape = { const fakeClaudeAdapter: ClaudeAdapterShape = { provider: CLAUDE_AGENT_DRIVER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", liveSteer: "unsupported" }, startSession: vi.fn(), sendTurn: vi.fn(), interruptTurn: vi.fn(), @@ -63,7 +63,7 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { const fakeOpenCodeAdapter: OpenCodeAdapterShape = { provider: OPENCODE_DRIVER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", liveSteer: "unsupported" }, startSession: vi.fn(), sendTurn: vi.fn(), interruptTurn: vi.fn(), @@ -80,7 +80,7 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = { const fakeCursorAdapter: CursorAdapterShape = { provider: CURSOR_DRIVER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "in-session", liveSteer: "unsupported" }, startSession: vi.fn(), sendTurn: vi.fn(), interruptTurn: vi.fn(), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 4b672115..39ae803e 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -207,6 +207,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { provider, capabilities: { sessionModelSwitch: "in-session", + liveSteer: "unsupported", }, startSession, sendTurn, @@ -1225,6 +1226,47 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("persists resume cursors emitted by runtime lifecycle events", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const threadId = asThreadId("thread-runtime-resume-cursor"); + const session = yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* advanceTestClock(50); + + const resumeCursor = { + threadId, + resume: "550e8400-e29b-41d4-a716-446655440000", + resumeSessionAt: "assistant-runtime-resume", + turnCount: 1, + }; + routing.claude.emit({ + type: "turn.completed", + eventId: asEventId("evt-runtime-resume-cursor"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: "2026-01-01T00:00:10.000Z", + threadId: session.threadId, + turnId: asTurnId("turn-runtime-resume-cursor"), + payload: { + state: "completed", + resumeCursor, + }, + }); + yield* advanceTestClock(50); + + const persisted = yield* runtimeRepository.getByThreadId({ threadId: session.threadId }); + assert.equal(Option.isSome(persisted), true); + if (Option.isSome(persisted)) { + assert.deepEqual(persisted.value.resumeCursor, resumeCursor); + } + }), + ); + it.effect("reuses persisted resume cursor when startSession is called after a restart", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-start-")); @@ -1400,6 +1442,51 @@ routing.layer("ProviderServiceLive routing", (it) => { const fanout = makeProviderServiceLayer(); fanout.layer("ProviderServiceLive fanout", (it) => { + it.effect("persists stopped runtime state when an adapter session exits", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const threadId = asThreadId("thread-session-exited"); + const session = yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + + yield* provider.sendTurn({ + threadId: session.threadId, + input: "before exit", + attachments: [], + }); + yield* advanceTestClock(50); + + fanout.codex.emit({ + type: "session.exited", + eventId: asEventId("evt-session-exited"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:10.000Z", + threadId: session.threadId, + payload: { + reason: "Codex App Server exited with code 1.", + }, + }); + yield* advanceTestClock(50); + + const persisted = yield* runtimeRepository.getByThreadId({ threadId: session.threadId }); + assert.equal(Option.isSome(persisted), true); + if (Option.isNone(persisted)) { + return; + } + + assert.equal(persisted.value.status, "stopped"); + const runtimePayload = persisted.value.runtimePayload as Record; + assert.equal(runtimePayload.activeTurnId, null); + assert.equal(runtimePayload.lastRuntimeEvent, "session.exited"); + assert.equal(runtimePayload.lastRuntimeEventAt, "2026-01-01T00:00:10.000Z"); + }), + ); + it.effect("fans out adapter turn completion events", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 518e260f..7ee12f92 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -19,6 +19,8 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, + type TurnId, + type ProviderSessionRuntimeStatus, type ProviderInstanceId, type ProviderDriverKind, type ProviderRuntimeEvent, @@ -160,6 +162,81 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function runtimeStatusFromEvent( + event: ProviderRuntimeEvent, +): ProviderSessionRuntimeStatus | undefined { + const payload: Record = isRecord(event.payload) ? event.payload : {}; + const state = typeof payload.state === "string" ? payload.state : undefined; + + switch (event.type) { + case "session.state.changed": + if (state === "starting") return "starting"; + if (state === "error") return "error"; + return "running"; + case "session.started": + case "thread.started": + case "turn.started": + return "running"; + case "turn.completed": + return state === "failed" ? "error" : "running"; + case "turn.aborted": + return "running"; + case "session.exited": + return "stopped"; + case "runtime.error": + return "error"; + default: + return undefined; + } +} + +function runtimeActiveTurnIdFromEvent(event: ProviderRuntimeEvent): TurnId | null | undefined { + switch (event.type) { + case "turn.started": + return event.turnId ?? null; + case "turn.completed": + case "turn.aborted": + case "session.exited": + return null; + case "runtime.error": + return event.turnId ?? null; + default: + return undefined; + } +} + +function runtimeLastErrorFromEvent(event: ProviderRuntimeEvent): string | null | undefined { + const payload: Record = isRecord(event.payload) ? event.payload : {}; + switch (event.type) { + case "session.state.changed": + if (payload.state !== "error") return undefined; + return typeof payload.reason === "string" && payload.reason.trim().length > 0 + ? payload.reason + : "Provider session error"; + case "turn.completed": + if (payload.state !== "failed") return null; + return typeof payload.errorMessage === "string" && payload.errorMessage.trim().length > 0 + ? payload.errorMessage + : "Turn failed"; + case "turn.aborted": + return typeof payload.reason === "string" && payload.reason.trim().length > 0 + ? payload.reason + : "Turn aborted"; + case "runtime.error": + return typeof payload.message === "string" && payload.message.trim().length > 0 + ? payload.message + : "Provider runtime error"; + case "session.exited": + return null; + default: + return undefined; + } +} + const dieOnMissingBindingInstanceId = ( operation: string, payload: { @@ -276,13 +353,57 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ): Effect.Effect => Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe( Effect.flatMap((canonicalEvent) => - increment(providerRuntimeEventsTotal, { - provider: canonicalEvent.provider, - eventType: canonicalEvent.type, - }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), + persistRuntimeLifecycleEvent(canonicalEvent).pipe( + Effect.andThen( + increment(providerRuntimeEventsTotal, { + provider: canonicalEvent.provider, + eventType: canonicalEvent.type, + }), + ), + Effect.andThen(publishRuntimeEvent(canonicalEvent)), + ), ), ); + const persistRuntimeLifecycleEvent = (event: ProviderRuntimeEvent): Effect.Effect => { + const status = runtimeStatusFromEvent(event); + if (status === undefined || event.providerInstanceId === undefined) { + return Effect.void; + } + + const activeTurnId = runtimeActiveTurnIdFromEvent(event); + const lastError = runtimeLastErrorFromEvent(event); + const resumeCursor = + event.payload && typeof event.payload === "object" && "resumeCursor" in event.payload + ? (event.payload as { readonly resumeCursor?: unknown }).resumeCursor + : undefined; + return directory + .upsert({ + threadId: event.threadId, + provider: event.provider, + providerInstanceId: event.providerInstanceId, + status, + ...(resumeCursor !== undefined ? { resumeCursor } : {}), + runtimePayload: { + ...(activeTurnId !== undefined ? { activeTurnId } : {}), + ...(lastError !== undefined ? { lastError } : {}), + lastRuntimeEvent: event.type, + lastRuntimeEventAt: event.createdAt, + }, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider.runtime.lifecycle-persist-failed", { + threadId: event.threadId, + provider: event.provider, + providerInstanceId: event.providerInstanceId, + eventType: event.type, + cause, + }), + ), + ); + }; + // `subscribedAdapters` is our source-of-truth for "which instance adapters // are currently wired into the runtime event bus". It both tracks the set // of live subscriptions (so `reconcileInstanceSubscriptions` can diff and diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index cb0cfc0b..94131391 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -158,7 +158,8 @@ describe("ProviderSessionReaper", () => { respondToUserInput: () => unsupported(), stopSession, listSessions: () => Effect.succeed([]), - getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + getCapabilities: () => + Effect.succeed({ sessionModelSwitch: "in-session", liveSteer: "unsupported" }), getInstanceInfo: (instanceId) => { const driverKind = ProviderDriverKind.make(String(instanceId)); return Effect.succeed({ @@ -195,6 +196,7 @@ describe("ProviderSessionReaper", () => { getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), + getDeletedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }), getCounts: () => Effect.die("unused"), @@ -202,7 +204,6 @@ describe("ProviderSessionReaper", () => { getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), - getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: (threadId) => Effect.succeed( input.readModel.threads.find((thread) => thread.id === threadId) diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index bfa0a5c3..4f8bdfce 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -24,12 +24,17 @@ import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; export type ProviderSessionModelSwitchMode = "in-session" | "unsupported"; +export type ProviderLiveSteerSupport = "supported" | "unsupported"; export interface ProviderAdapterCapabilities { /** * Declares whether changing the model on an existing session is supported. */ readonly sessionModelSwitch: ProviderSessionModelSwitchMode; + /** + * Declares whether the adapter accepts user guidance while a turn is already running. + */ + readonly liveSteer: ProviderLiveSteerSupport; } export interface ProviderThreadTurnSnapshot { diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 55c44f9a..8ac2d8c2 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -201,6 +201,31 @@ describe("providerMaintenance", () => { }), ); + it("uses a Windows command shim for npm-managed provider updates on Windows", () => { + expect( + packageToolUpdate.resolve({ + binaryPath: "package-tool", + platform: "win32", + env: { + PATH: "", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: { + command: "npm.cmd install -g @example/package-tool@latest", + + executable: "npm.cmd", + + args: ["install", "-g", "@example/package-tool@latest"], + + lockKey: "npm-global", + }, + }); + }); + it.effect( "switches package-managed providers to pnpm updates when the resolved binary lives in pnpm's global bin", () => diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 07a559b7..955068c1 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -108,13 +108,18 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: { }); } +function resolveNpmGlobalUpdateExecutable(platform: NodeJS.Platform = process.platform): string { + return platform === "win32" ? "npm.cmd" : "npm"; +} + function makeNpmGlobalProviderMaintenanceCapabilities( definition: PackageManagedProviderMaintenanceDefinition, + options?: ProviderMaintenanceCapabilityResolutionOptions, ): ProviderMaintenanceCapabilities { return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, - updateExecutable: "npm", + updateExecutable: resolveNpmGlobalUpdateExecutable(options?.platform), updateArgs: ["install", "-g", `${definition.npmPackageName}@latest`], updateLockKey: "npm-global", }); @@ -247,7 +252,7 @@ export function resolvePackageManagedProviderMaintenance( ): ProviderMaintenanceCapabilities { const binaryPath = nonEmptyString(options?.binaryPath); if (!binaryPath) { - return makeNpmGlobalProviderMaintenanceCapabilities(definition); + return makeNpmGlobalProviderMaintenanceCapabilities(definition, options); } const resolvedCommandPath = @@ -269,7 +274,7 @@ export function resolvePackageManagedProviderMaintenance( ) { return ( makeNativeProviderMaintenanceCapabilities(definition) ?? - makeNpmGlobalProviderMaintenanceCapabilities(definition) + makeNpmGlobalProviderMaintenanceCapabilities(definition, options) ); } if (commandPaths.some(isVitePlusGlobalCommandPath)) { @@ -282,7 +287,7 @@ export function resolvePackageManagedProviderMaintenance( return makePnpmGlobalProviderMaintenanceCapabilities(definition); } if (commandPaths.some(isNpmGlobalCommandPath)) { - return makeNpmGlobalProviderMaintenanceCapabilities(definition); + return makeNpmGlobalProviderMaintenanceCapabilities(definition, options); } if (commandPaths.some(isHomebrewCommandPath)) { return makeHomebrewProviderMaintenanceCapabilities(definition); @@ -290,7 +295,7 @@ export function resolvePackageManagedProviderMaintenance( } if (!hasPathSeparator(binaryPath)) { - return makeNpmGlobalProviderMaintenanceCapabilities(definition); + return makeNpmGlobalProviderMaintenanceCapabilities(definition, options); } return makeManualOnlyProviderMaintenanceCapabilities({ diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 4ea93f27..034514c6 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -7,6 +7,7 @@ import type { ServerProviderSlashCommand, ServerProviderModel, ServerProviderState, + ServerProviderRuntimeCapabilities, } from "@cafecode/contracts"; import * as Effect from "effect/Effect"; import * as Data from "effect/Data"; @@ -199,6 +200,7 @@ export function buildServerProvider(input: { models: ReadonlyArray; slashCommands?: ReadonlyArray; skills?: ReadonlyArray; + runtimeCapabilities?: ServerProviderRuntimeCapabilities; probe: ProviderProbeResult; }): ServerProviderDraft { const versionAdvisory = input.driver @@ -224,6 +226,7 @@ export function buildServerProvider(input: { models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], + runtimeCapabilities: input.runtimeCapabilities ?? { liveSteer: "unsupported" }, ...(versionAdvisory ? { versionAdvisory } : {}), }; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index eafcea41..726a0214 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -12,7 +12,6 @@ import { MessageId, ExternalLauncherError, type OrchestrationThreadShell, - TerminalNotRunningError, type OrchestrationCommand, type OrchestrationEvent, ORCHESTRATION_WS_METHODS, @@ -28,7 +27,6 @@ import { import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import * as Clock from "effect/Clock"; -import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -57,10 +55,6 @@ import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; -import { - CheckpointDiffQuery, - type CheckpointDiffQueryShape, -} from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { GitManager, type GitManagerShape } from "./git/GitManager.ts"; import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; @@ -75,6 +69,10 @@ import { } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; +import { + CheckpointStore, + type CheckpointStoreShape, +} from "./checkpointing/Services/CheckpointStore.ts"; import { ProviderRegistry, type ProviderRegistryShape, @@ -83,7 +81,6 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/provid import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; -import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; import { BrowserTraceCollector, type BrowserTraceCollectorShape, @@ -197,6 +194,7 @@ const makeDefaultOrchestrationThreadShell = ( createdAt: now, updatedAt: now, archivedAt: null, + deletedAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -328,10 +326,9 @@ const buildAppUnderTest = (options?: { sourceControlRepositoryService?: Partial; vcsStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; - terminalManager?: Partial; orchestrationEngine?: Partial; projectionSnapshotQuery?: Partial; - checkpointDiffQuery?: Partial; + checkpointStore?: Partial; browserTraceCollector?: Partial; serverLifecycleEvents?: Partial; serverRuntimeStartup?: Partial; @@ -623,11 +620,6 @@ const buildAppUnderTest = (options?: { ...options?.layers?.projectSetupScriptRunner, }), ), - Layer.provide( - Layer.mock(TerminalManager)({ - ...options?.layers?.terminalManager, - }), - ), Layer.provide( Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, @@ -654,6 +646,13 @@ const buildAppUnderTest = (options?: { threads: [], updatedAt: "1970-01-01T00:00:00.000Z", }), + getDeletedShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "1970-01-01T00:00:00.000Z", + }), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getProjectShellById: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), @@ -666,22 +665,14 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(CheckpointDiffQuery)({ - getTurnDiff: () => - Effect.succeed({ - threadId: defaultThreadId, - fromTurnCount: 0, - toTurnCount: 0, - diff: "", - }), - getFullThreadDiff: () => - Effect.succeed({ - threadId: defaultThreadId, - fromTurnCount: 0, - toTurnCount: 0, - diff: "", - }), - ...options?.layers?.checkpointDiffQuery, + Layer.mock(CheckpointStore)({ + isGitRepository: () => Effect.succeed(false), + captureCheckpoint: () => Effect.void, + hasCheckpointRef: () => Effect.succeed(false), + restoreCheckpoint: () => Effect.succeed(false), + diffCheckpoints: () => Effect.succeed(""), + deleteCheckpointRefs: () => Effect.void, + ...options?.layers?.checkpointStore, }), ), ); @@ -723,6 +714,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provideMerge(makeAuthTestLayer()), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), @@ -2054,11 +2046,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc server.upsertKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { - command: "terminal.toggle", + command: "commandPalette.toggle", key: "ctrl+k", }; const resolved: ResolvedKeybindingRule = { - command: "terminal.toggle", + command: "commandPalette.toggle", shortcut: { key: "k", metaKey: false, @@ -2090,11 +2082,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc server.removeKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { - command: "terminal.toggle", + command: "commandPalette.toggle", key: "ctrl+k", }; const resolved: ResolvedKeybindingRule = { - command: "terminal.toggle", + command: "commandPalette.toggle", shortcut: { key: "j", metaKey: false, @@ -2621,54 +2613,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { behindCount: 0, pr: null, }), - runStackedAction: (input, options) => - Effect.gen(function* () { - const result = { - action: "commit" as const, - branch: { status: "skipped_not_requested" as const }, - commit: { - status: "created" as const, - commitSha: "abc123", - subject: "feat: demo", - }, - push: { status: "skipped_not_requested" as const }, - pr: { status: "skipped_not_requested" as const }, - toast: { - title: "Committed abc123", - description: "feat: demo", - cta: { - kind: "run_action" as const, - label: "Push", - action: { - kind: "push" as const, - }, - }, - }, - }; - - yield* ( - options?.progressReporter?.publish({ - actionId: options.actionId ?? input.actionId, - cwd: input.cwd, - action: input.action, - kind: "phase_started", - phase: "commit", - label: "Committing...", - }) ?? Effect.void - ); - - yield* ( - options?.progressReporter?.publish({ - actionId: options.actionId ?? input.actionId, - cwd: input.cwd, - action: input.action, - kind: "action_finished", - result, - }) ?? Effect.void - ); - - return result; - }), resolvePullRequest: () => Effect.succeed({ pullRequest: { @@ -2744,24 +2688,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(refreshedStatus.isRepo, true); - const stackedEvents = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitRunStackedAction]({ - actionId: "action-1", - cwd: "/tmp/repo", - action: "commit", - }).pipe( - Stream.runCollect, - Effect.map((events) => Array.from(events)), - ), - ), - ); - const lastStackedEvent = stackedEvents.at(-1); - assert.equal(lastStackedEvent?.kind, "action_finished"); - if (lastStackedEvent?.kind === "action_finished") { - assert.equal(lastStackedEvent.result.action, "commit"); - } - const resolvedPr = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitResolvePullRequest]({ @@ -2916,88 +2842,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("routes websocket rpc git.runStackedAction errors after refreshing git status", () => - Effect.gen(function* () { - const gitError = new GitCommandError({ - operation: "commit", - command: "git commit", - cwd: "/tmp/repo", - detail: "nothing to commit", - }); - let invalidationCalls = 0; - let statusCalls = 0; - yield* buildAppUnderTest({ - layers: { - gitManager: { - invalidateLocalStatus: () => - Effect.sync(() => { - invalidationCalls += 1; - }), - invalidateRemoteStatus: () => - Effect.sync(() => { - invalidationCalls += 1; - }), - invalidateStatus: () => - Effect.sync(() => { - invalidationCalls += 1; - }), - localStatus: () => - Effect.succeed({ - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/demo", - hasWorkingTreeChanges: true, - workingTree: { files: [], insertions: 0, deletions: 0 }, - }), - remoteStatus: () => - Effect.sync(() => { - statusCalls += 1; - return { - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }; - }), - status: () => - Effect.sync(() => { - statusCalls += 1; - return { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/demo", - hasWorkingTreeChanges: true, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }; - }), - runStackedAction: () => Effect.fail(gitError), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitRunStackedAction]({ - actionId: "action-1", - cwd: "/tmp/repo", - action: "commit", - }).pipe(Stream.runCollect, Effect.result), - ), - ); - - assertFailure(result, gitError); - assert.equal(invalidationCalls, 0); - assert.equal(statusCalls, 0); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("completes websocket rpc git.pull before background git status refresh finishes", () => Effect.gen(function* () { yield* buildAppUnderTest({ @@ -3047,159 +2891,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect( - "completes websocket rpc git.runStackedAction before background git status refresh finishes", - () => - Effect.gen(function* () { - yield* buildAppUnderTest({ - layers: { - vcsDriver: { - isInsideWorkTree: () => Effect.succeed(true), - }, - gitManager: { - invalidateLocalStatus: () => Effect.void, - invalidateRemoteStatus: () => Effect.void, - localStatus: () => - Effect.succeed({ - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/demo", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - }), - remoteStatus: () => - Effect.sleep(Duration.seconds(2)).pipe( - Effect.as({ - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }), - ), - runStackedAction: () => - Effect.succeed({ - action: "commit" as const, - branch: { status: "skipped_not_requested" as const }, - commit: { - status: "created" as const, - commitSha: "abc123", - subject: "feat: demo", - }, - push: { status: "skipped_not_requested" as const }, - pr: { status: "skipped_not_requested" as const }, - toast: { - title: "Committed abc123", - description: "feat: demo", - cta: { - kind: "run_action" as const, - label: "Push", - action: { - kind: "push" as const, - }, - }, - }, - }), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const startedAt = yield* Clock.currentTimeMillis; - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitRunStackedAction]({ - actionId: "action-1", - cwd: "/tmp/repo", - action: "commit", - }).pipe(Stream.runCollect), - ), - ); - const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt; - - assertTrue(elapsedMs < 1_000); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect( - "starts a background local git status refresh after a successful git.runStackedAction", - () => - Effect.gen(function* () { - const localRefreshStarted = yield* Deferred.make(); - - yield* buildAppUnderTest({ - layers: { - vcsDriver: { - isInsideWorkTree: () => Effect.succeed(true), - }, - gitManager: { - invalidateLocalStatus: () => Effect.void, - invalidateRemoteStatus: () => Effect.void, - localStatus: () => - Deferred.succeed(localRefreshStarted, undefined).pipe( - Effect.ignore, - Effect.andThen( - Effect.succeed({ - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/demo", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - }), - ), - ), - remoteStatus: () => - Effect.sleep(Duration.seconds(2)).pipe( - Effect.as({ - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }), - ), - runStackedAction: () => - Effect.succeed({ - action: "commit" as const, - branch: { status: "skipped_not_requested" as const }, - commit: { - status: "created" as const, - commitSha: "abc123", - subject: "feat: demo", - }, - push: { status: "skipped_not_requested" as const }, - pr: { status: "skipped_not_requested" as const }, - toast: { - title: "Committed abc123", - description: "feat: demo", - cta: { - kind: "run_action" as const, - label: "Push", - action: { - kind: "push" as const, - }, - }, - }, - }), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitRunStackedAction]({ - actionId: "action-1", - cwd: "/tmp/repo", - action: "commit", - }).pipe(Stream.runCollect), - ), - ); - - yield* Deferred.await(localRefreshStarted); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc orchestration methods", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; @@ -3251,22 +2942,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { dispatch: () => Effect.succeed({ sequence: 7 }), readEvents: () => Stream.empty, }, - checkpointDiffQuery: { - getTurnDiff: () => - Effect.succeed({ - threadId: ThreadId.make("thread-1"), - fromTurnCount: 0, - toTurnCount: 1, - diff: "turn-diff", - }), - getFullThreadDiff: () => - Effect.succeed({ - threadId: ThreadId.make("thread-1"), - fromTurnCount: 0, - toTurnCount: 1, - diff: "full-diff", - }), - }, }, }); @@ -3283,27 +2958,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(dispatchResult.sequence, 7); - const turnDiffResult = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.getTurnDiff]({ - threadId: ThreadId.make("thread-1"), - fromTurnCount: 0, - toTurnCount: 1, - }), - ), - ); - assert.equal(turnDiffResult.diff, "turn-diff"); - - const fullDiffResult = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.getFullThreadDiff]({ - threadId: ThreadId.make("thread-1"), - toTurnCount: 1, - }), - ), - ); - assert.equal(fullDiffResult.diff, "full-diff"); - const replayResult = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.replayEvents]({ @@ -3410,7 +3064,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("stops the provider session and closes thread terminals after archive", () => + it.effect("stops the provider session after archive", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive"); const effects: string[] = []; @@ -3419,12 +3073,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - terminalManager: { - close: (input) => - Effect.sync(() => { - effects.push(`terminal.close:${input.threadId}`); - }), - }, orchestrationEngine: { dispatch: (command) => Effect.sync(() => { @@ -3468,11 +3116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, [ - "dispatch:thread.archive", - "dispatch:thread.session.stop", - `terminal.close:${threadId}`, - ]); + assert.deepEqual(effects, ["dispatch:thread.archive", "dispatch:thread.session.stop"]); const sessionStopCommand = dispatchedCommands[1]; assert.equal(sessionStopCommand?.type, "thread.session.stop"); if (sessionStopCommand?.type === "thread.session.stop") { @@ -3491,12 +3135,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - terminalManager: { - close: (input) => - Effect.sync(() => { - effects.push(`terminal.close:${input.threadId}`); - }), - }, orchestrationEngine: { dispatch: (command) => Effect.sync(() => { @@ -3550,7 +3188,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "query:thread-shell:active", "dispatch:thread.archive", "dispatch:thread.session.stop", - `terminal.close:${threadId}`, ]); assert.deepEqual( dispatchedCommands.map((command) => command.type), @@ -3567,12 +3204,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - terminalManager: { - close: (input) => - Effect.sync(() => { - effects.push(`terminal.close:${input.threadId}`); - }), - }, orchestrationEngine: { dispatch: (command) => Effect.sync(() => { @@ -3602,7 +3233,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.archive", `terminal.close:${threadId}`]); + assert.deepEqual(effects, ["dispatch:thread.archive"]); assert.deepEqual( dispatchedCommands.map((command) => command.type), ["thread.archive"], @@ -3621,12 +3252,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - terminalManager: { - close: (input) => - Effect.sync(() => { - effects.push(`terminal.close:${input.threadId}`); - }), - }, orchestrationEngine: { dispatch: (command) => Effect.sync(() => { @@ -3670,7 +3295,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.archive", `terminal.close:${threadId}`]); + assert.deepEqual(effects, ["dispatch:thread.archive"]); assert.deepEqual( dispatchedCommands.map((command) => command.type), ["thread.archive"], @@ -3678,7 +3303,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("archives and still closes terminals when session stop fails", () => + it.effect("archives successfully when session stop fails", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive-stop-failure"); const effects: string[] = []; @@ -3687,12 +3312,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - terminalManager: { - close: (input) => - Effect.sync(() => { - effects.push(`terminal.close:${input.threadId}`); - }), - }, orchestrationEngine: { dispatch: (command) => { dispatchedCommands.push(command); @@ -3743,11 +3362,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, [ - "dispatch:thread.archive", - "dispatch:thread.session.stop", - `terminal.close:${threadId}`, - ]); + assert.deepEqual(effects, ["dispatch:thread.archive", "dispatch:thread.session.stop"]); assert.deepEqual( dispatchedCommands.map((command) => command.type), ["thread.archive", "thread.session.stop"], @@ -3755,7 +3370,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("archives and still closes terminals when session stop defects", () => + it.effect("archives successfully when session stop defects", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive-stop-defect"); const effects: string[] = []; @@ -3764,12 +3379,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - terminalManager: { - close: (input) => - Effect.sync(() => { - effects.push(`terminal.close:${input.threadId}`); - }), - }, orchestrationEngine: { dispatch: (command) => { dispatchedCommands.push(command); @@ -3815,11 +3424,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, [ - "dispatch:thread.archive", - "dispatch:thread.session.stop", - `terminal.close:${threadId}`, - ]); + assert.deepEqual(effects, ["dispatch:thread.archive", "dispatch:thread.session.stop"]); assert.deepEqual( dispatchedCommands.map((command) => command.type), ["thread.archive", "thread.session.stop"], @@ -3862,11 +3467,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const runForThread = vi.fn( (_: Parameters[0]) => Effect.succeed({ - status: "started" as const, - scriptId: "setup", - scriptName: "Setup", - terminalId: "setup-setup", - cwd: "/tmp/bootstrap-worktree", + status: "no-script" as const, }), ); @@ -3932,16 +3533,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 5); + assert.equal(response.sequence, 3); assert.deepEqual( dispatchedCommands.map((command) => command.type), - [ - "thread.create", - "thread.meta.update", - "thread.activity.append", - "thread.activity.append", - "thread.turn.start", - ], + ["thread.create", "thread.meta.update", "thread.turn.start"], ); assert.deepEqual(createWorktree.mock.calls[0]?.[0], { cwd: "/tmp/project", @@ -3957,15 +3552,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree"); - const setupActivities = dispatchedCommands.filter( - (command): command is Extract => - command.type === "thread.activity.append", - ); - assert.deepEqual( - setupActivities.map((command) => command.activity.kind), - ["setup-script.requested", "setup-script.started"], - ); - const finalCommand = dispatchedCommands[4]; + const finalCommand = dispatchedCommands[2]; assertTrue(finalCommand?.type === "thread.turn.start"); if (finalCommand?.type === "thread.turn.start") { assert.equal(finalCommand.bootstrap, undefined); @@ -3987,7 +3574,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); const runForThread = vi.fn( (_: Parameters[0]) => - Effect.fail(new ProjectSetupScriptRunnerError({ message: "pty unavailable" })), + Effect.fail(new ProjectSetupScriptRunnerError({ message: "setup unavailable" })), ); yield* buildAppUnderTest({ @@ -4060,132 +3647,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed"); assert.deepEqual(setupFailureActivity?.activity.payload, { - detail: "pty unavailable", + detail: "setup unavailable", worktreePath: "/tmp/bootstrap-worktree", }); assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () => - Effect.gen(function* () { - const dispatchedCommands: Array = []; - const createWorktree = vi.fn( - (_: Parameters[0]) => - Effect.succeed({ - worktree: { - refName: "t3code/bootstrap-refName", - path: "/tmp/bootstrap-worktree", - }, - }), - ); - const runForThread = vi.fn( - (_: Parameters[0]) => - Effect.succeed({ - status: "started" as const, - scriptId: "setup", - scriptName: "Setup", - terminalId: "setup-setup", - cwd: "/tmp/bootstrap-worktree", - }), - ); - let setupActivityAppendAttempt = 0; - - yield* buildAppUnderTest({ - layers: { - gitVcsDriver: { - createWorktree, - }, - orchestrationEngine: { - dispatch: (command) => { - if ( - command.type === "thread.activity.append" && - command.activity.kind.startsWith("setup-script.") - ) { - setupActivityAppendAttempt += 1; - if (setupActivityAppendAttempt === 2) { - return Effect.fail( - new OrchestrationListenerCallbackError({ - listener: "domain-event", - detail: "failed to append setup-script.started activity", - }), - ); - } - } - - return Effect.sync(() => { - dispatchedCommands.push(command); - return { sequence: dispatchedCommands.length }; - }); - }, - readEvents: () => Stream.empty, - }, - projectSetupScriptRunner: { - runForThread, - }, - }, - }); - - const createdAt = "2026-01-01T00:00:00.000Z"; - const wsUrl = yield* getWsServerUrl("/ws"); - const response = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-bootstrap-turn-start-setup-activity-failure"), - threadId: ThreadId.make("thread-bootstrap-setup-activity-failure"), - message: { - messageId: MessageId.make("msg-bootstrap-setup-activity-failure"), - role: "user", - text: "hello", - attachments: [], - }, - modelSelection: defaultModelSelection, - runtimeMode: "full-access", - interactionMode: "default", - bootstrap: { - createThread: { - projectId: defaultProjectId, - title: "Bootstrap Thread", - modelSelection: defaultModelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - createdAt, - }, - prepareWorktree: { - projectCwd: "/tmp/project", - baseBranch: "main", - branch: "t3code/bootstrap-refName", - }, - runSetupScript: true, - }, - createdAt, - }), - ), - ); - - assert.equal(response.sequence, 4); - assert.deepEqual( - dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], - ); - const setupActivities = dispatchedCommands.filter( - (command): command is Extract => - command.type === "thread.activity.append", - ); - assert.deepEqual( - setupActivities.map((command) => command.activity.kind), - ["setup-script.requested"], - ); - assertTrue( - setupActivities.every((command) => command.activity.kind !== "setup-script.failed"), - ); - assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("cleans up created bootstrap threads when worktree creation defects", () => Effect.gen(function* () { const dispatchedCommands: Array = []; @@ -4259,128 +3727,4 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - - it.effect("routes websocket rpc terminal methods", () => - Effect.gen(function* () { - const snapshot = { - threadId: "thread-1", - terminalId: "default", - cwd: "/tmp/project", - worktreePath: null, - status: "running" as const, - pid: 1234, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: "2026-01-01T00:00:00.000Z", - }; - - yield* buildAppUnderTest({ - layers: { - terminalManager: { - open: () => Effect.succeed(snapshot), - write: () => Effect.void, - resize: () => Effect.void, - clear: () => Effect.void, - restart: () => Effect.succeed(snapshot), - close: () => Effect.void, - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - - const opened = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.terminalOpen]({ - threadId: "thread-1", - terminalId: "default", - cwd: "/tmp/project", - }), - ), - ); - assert.equal(opened.terminalId, "default"); - - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.terminalWrite]({ - threadId: "thread-1", - terminalId: "default", - data: "echo hi\n", - }), - ), - ); - - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.terminalResize]({ - threadId: "thread-1", - terminalId: "default", - cols: 120, - rows: 40, - }), - ), - ); - - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.terminalClear]({ - threadId: "thread-1", - terminalId: "default", - }), - ), - ); - - const restarted = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.terminalRestart]({ - threadId: "thread-1", - terminalId: "default", - cwd: "/tmp/project", - cols: 120, - rows: 40, - }), - ), - ); - assert.equal(restarted.terminalId, "default"); - - yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.terminalClose]({ - threadId: "thread-1", - terminalId: "default", - }), - ), - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("routes websocket rpc terminal.write errors", () => - Effect.gen(function* () { - const terminalError = new TerminalNotRunningError({ - threadId: "thread-1", - terminalId: "default", - }); - yield* buildAppUnderTest({ - layers: { - terminalManager: { - write: () => Effect.fail(terminalError), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.terminalWrite]({ - threadId: "thread-1", - terminalId: "default", - data: "echo fail\n", - }), - ).pipe(Effect.result), - ); - - assertFailure(result, terminalError); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 27e9231f..5e8c2e5f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -24,7 +24,6 @@ import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; -import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; @@ -32,7 +31,6 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; -import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; import * as GitManager from "./git/GitManager.ts"; import { KeybindingsLive } from "./keybindings.ts"; import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; @@ -91,18 +89,6 @@ import { import * as NetService from "@cafecode/shared/Net"; import { disableTailscaleServe, ensureTailscaleServe } from "@cafecode/tailscale"; -const PtyAdapterLive = Layer.unwrap( - Effect.gen(function* () { - if (typeof Bun !== "undefined") { - const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY.ts")); - return BunPTY.layer; - } else { - const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY.ts")); - return NodePTY.layer; - } - }), -); - const HttpServerLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig; @@ -209,12 +195,9 @@ const VcsLayerLive = Layer.empty.pipe( ); const CheckpointingLayerLive = Layer.empty.pipe( - Layer.provideMerge(CheckpointDiffQueryLive), Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); -const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); - const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistryLayerLive), @@ -248,7 +231,6 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), - Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), Layer.provideMerge(ProviderRegistryLive), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 3226a959..54460c30 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -86,6 +86,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), + getDeletedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Deferred.await(releaseCounts).pipe( @@ -98,7 +99,6 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), }), @@ -142,6 +142,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), + getDeletedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => @@ -160,7 +161,6 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), }), @@ -196,13 +196,13 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), + getDeletedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), }), diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index bad84f58..a098b590 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -50,7 +50,7 @@ const processOutput = ( stderrTruncated: false, }); -it.effect("reports implemented tools separately from locally available executables", () => { +it.effect("reports available version control systems separately from hosting providers", () => { const processMock = { run: (input: VcsProcess.VcsProcessInput) => { if (input.command === "git") { @@ -110,13 +110,9 @@ Logged in to github.com account juliusmarminge (keyring) assert.deepStrictEqual( result.versionControlSystems.map((item) => ({ kind: item.kind, - implemented: item.implemented, status: item.status, })), - [ - { kind: "git", implemented: true, status: "available" }, - { kind: "jj", implemented: false, status: "missing" }, - ], + [{ kind: "git", status: "available" }], ); assert.deepStrictEqual( result.sourceControlProviders.map((item) => ({ diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 7952afab..42dd2399 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -17,7 +17,6 @@ interface DiscoveryProbe { readonly label: string; readonly executable?: string; readonly versionArgs?: ReadonlyArray; - readonly implemented: boolean; readonly installHint: string; } @@ -31,7 +30,6 @@ interface DiscoveryProbeResult { readonly kind: Kind; readonly label: string; readonly executable?: string; - readonly implemented: boolean; readonly status: "available" | "missing"; readonly version: Option.Option; readonly installHint: string; @@ -44,17 +42,8 @@ const VCS_PROBES: ReadonlyArray = [ label: "Git", executable: "git", versionArgs: ["--version"], - implemented: true, installHint: "Install Git from https://git-scm.com/downloads or with your package manager.", }, - { - kind: "jj", - label: "Jujutsu", - executable: "jj", - versionArgs: ["--version"], - implemented: false, - installHint: "Install Jujutsu with `brew install jj` or from https://github.com/jj-vcs/jj.", - }, ]; export interface SourceControlDiscoveryShape { @@ -84,7 +73,6 @@ export const layer = Layer.effect( return Effect.succeed({ kind: input.kind, label: input.label, - implemented: input.implemented, status: "missing" as const, version: Option.none(), installHint: input.installHint, @@ -109,7 +97,6 @@ export const layer = Layer.effect( kind: input.kind, label: input.label, executable, - implemented: input.implemented, status: "available" as const, version: Option.orElse( SourceControlProviderDiscovery.firstNonEmptyLine(result.stdout), @@ -124,7 +111,6 @@ export const layer = Layer.effect( kind: input.kind, label: input.label, executable, - implemented: input.implemented, status: "missing" as const, version: Option.none(), installHint: input.installHint, diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 297db516..58a24de2 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError, type SourceControlProviderError } from "@cafecode/contracts"; +import { type SourceControlProviderError } from "@cafecode/contracts"; import { ServerConfig } from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -147,174 +147,3 @@ it.effect("clones a looked-up repository into the requested destination", () => ); }).pipe(Effect.provide(NodeServices.layer)), ); - -it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => { - const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = []; - const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; - const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; - const provider = makeProvider({ - createRepository: (input) => - Effect.sync(() => { - createCalls.push({ - cwd: input.cwd, - repository: input.repository, - visibility: input.visibility, - }); - return CLONE_URLS; - }), - }); - - return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const result = yield* service.publishRepository({ - cwd: "/workspace", - provider: "github", - repository: "octocat/t3code", - visibility: "private", - remoteName: "origin", - protocol: "ssh", - }); - - assert.deepStrictEqual(result, { - repository: { provider: "github", ...CLONE_URLS }, - remoteName: "origin", - remoteUrl: CLONE_URLS.sshUrl, - branch: "feature/remote-v1", - upstreamBranch: "origin/feature/remote-v1", - status: "pushed", - }); - assert.deepStrictEqual(createCalls, [ - { cwd: "/workspace", repository: "octocat/t3code", visibility: "private" }, - ]); - assert.deepStrictEqual(remoteCalls, [ - { cwd: "/workspace", preferredName: "origin", url: CLONE_URLS.sshUrl }, - ]); - assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin" }]); - }).pipe( - Effect.provide( - makeLayer({ - provider, - git: { - ensureRemote: (input) => - Effect.sync(() => { - remoteCalls.push(input); - return "origin"; - }), - pushCurrentBranch: (cwd, _fallbackBranch, options) => - Effect.sync(() => { - pushCalls.push({ cwd, remoteName: options?.remoteName }); - return { - status: "pushed" as const, - branch: "feature/remote-v1", - upstreamBranch: "origin/feature/remote-v1", - setUpstream: true, - }; - }), - }, - }), - ), - ); -}); - -it.effect("publishes to the remote name returned by ensureRemote", () => { - const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; - - return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const result = yield* service.publishRepository({ - cwd: "/workspace", - provider: "github", - repository: "octocat/t3code", - visibility: "private", - remoteName: "origin", - protocol: "ssh", - }); - - assert.equal(result.remoteName, "origin-1"); - assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin-1" }]); - }).pipe( - Effect.provide( - makeLayer({ - git: { - ensureRemote: () => Effect.succeed("origin-1"), - pushCurrentBranch: (cwd, _fallbackBranch, options) => - Effect.sync(() => { - pushCalls.push({ cwd, remoteName: options?.remoteName }); - return { - status: "pushed" as const, - branch: "feature/remote-v1", - upstreamBranch: `${options?.remoteName ?? "missing"}/feature/remote-v1`, - setUpstream: true, - }; - }), - }, - }), - ), - ); -}); - -it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { - let pushCalls = 0; - return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const result = yield* service.publishRepository({ - cwd: "/workspace", - provider: "github", - repository: "octocat/t3code", - visibility: "private", - remoteName: "origin", - protocol: "ssh", - }); - - assert.deepStrictEqual(result, { - repository: { provider: "github", ...CLONE_URLS }, - remoteName: "origin", - remoteUrl: CLONE_URLS.sshUrl, - branch: "main", - status: "remote_added", - }); - assert.strictEqual(pushCalls, 0); - }).pipe( - Effect.provide( - makeLayer({ - git: { - execute: (input) => - input.args[0] === "rev-parse" - ? Effect.fail( - new GitCommandError({ - operation: input.operation, - command: "git rev-parse --verify HEAD", - cwd: input.cwd, - detail: "fatal: Needed a single revision", - }), - ) - : Effect.succeed(processOutput()), - statusDetails: () => - Effect.succeed({ - isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", - upstreamRef: null, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - aheadOfDefaultCount: 0, - }), - pushCurrentBranch: () => - Effect.sync(() => { - pushCalls += 1; - return { - status: "pushed" as const, - branch: "main", - upstreamBranch: "origin/main", - setUpstream: true, - }; - }), - }, - }), - ), - ); -}); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index bb0d3951..c0e69672 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -12,8 +12,6 @@ import { type SourceControlCloneRepositoryResult, type SourceControlCloneProtocol, type SourceControlProviderKind, - type SourceControlPublishRepositoryInput, - type SourceControlPublishRepositoryResult, type SourceControlRepositoryCloneUrls, type SourceControlRepositoryInfo, type SourceControlRepositoryLookupInput, @@ -31,9 +29,6 @@ export interface SourceControlRepositoryServiceShape { readonly cloneRepository: ( input: SourceControlCloneRepositoryInput, ) => Effect.Effect; - readonly publishRepository: ( - input: SourceControlPublishRepositoryInput, - ) => Effect.Effect; } export class SourceControlRepositoryService extends Context.Service< @@ -246,65 +241,6 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () }; }); - const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")( - function* (input: SourceControlPublishRepositoryInput) { - const providerKind = yield* ensureConcreteProvider({ - operation: "publishRepository", - provider: input.provider, - }); - const provider = yield* providers.get(providerKind); - const urls = yield* provider.createRepository({ - cwd: input.cwd, - repository: input.repository.trim(), - visibility: input.visibility, - }); - const remoteUrl = selectRemoteUrl(urls, input.protocol); - const remoteName = yield* git.ensureRemote({ - cwd: input.cwd, - preferredName: input.remoteName?.trim() || "origin", - url: remoteUrl, - }); - - // An empty local repo (no commits) would make `git push HEAD:...` fail - // with an opaque "src refspec HEAD does not match any". Treat this as a - // partial success: the remote was created and wired up, but there is - // nothing to push yet. - const hasCommits = yield* git - .execute({ - operation: "SourceControlRepositoryService.publishRepository.headCheck", - cwd: input.cwd, - args: ["rev-parse", "--verify", "HEAD"], - }) - .pipe( - Effect.map(() => true), - Effect.catch(() => Effect.succeed(false)), - ); - if (!hasCommits) { - const details = yield* git - .statusDetails(input.cwd) - .pipe(Effect.catch(() => Effect.succeed(null))); - return { - repository: toRepositoryInfo(providerKind, urls), - remoteName, - remoteUrl, - branch: details?.branch ?? "main", - status: "remote_added" as const, - }; - } - - const pushResult = yield* git.pushCurrentBranch(input.cwd, null, { remoteName }); - - return { - repository: toRepositoryInfo(providerKind, urls), - remoteName, - remoteUrl, - branch: pushResult.branch, - ...(pushResult.upstreamBranch ? { upstreamBranch: pushResult.upstreamBranch } : {}), - status: "pushed" as const, - }; - }, - ); - return SourceControlRepositoryService.of({ lookupRepository: (input) => lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), @@ -312,8 +248,6 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () cloneRepository(input).pipe( mapRepositoryError("cloneRepository", input.provider ?? "unknown"), ), - publishRepository: (input) => - publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), }); }); diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index ed8e98a8..5f99f663 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -79,7 +79,7 @@ const upsertAnonymousId = Effect.gen(function* () { * getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value. * 1. ~/.codex/auth.json tokens.account_id * 2. ~/.claude.json userID - * 3. ~/.t3/telemetry/anonymous-id + * 3. ~/.cafe-code/telemetry/anonymous-id */ export const getTelemetryIdentifier = Effect.gen(function* () { const codexAccountId = yield* Effect.result(getCodexAccountId); diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts deleted file mode 100644 index bde3d574..00000000 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ /dev/null @@ -1,122 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { PtyAdapter } from "../Services/PTY.ts"; -import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; - -class BunPtyProcess implements PtyProcess { - private readonly dataListeners = new Set<(data: string) => void>(); - private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); - private readonly decoder = new TextDecoder(); - private readonly process: Bun.Subprocess; - private didExit = false; - - constructor(process: Bun.Subprocess) { - this.process = process; - void this.process.exited - .then((exitCode) => { - this.emitExit({ - exitCode: Number.isInteger(exitCode) ? exitCode : 0, - signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, - }); - }) - .catch(() => { - this.emitExit({ exitCode: 1, signal: null }); - }); - } - - get pid(): number { - return this.process.pid; - } - - write(data: string): void { - if (!this.process.terminal) { - throw new Error("Bun PTY terminal handle is unavailable"); - } - this.process.terminal.write(data); - } - - resize(cols: number, rows: number): void { - if (!this.process.terminal?.resize) { - throw new Error("Bun PTY resize is unavailable"); - } - this.process.terminal.resize(cols, rows); - } - - kill(signal?: string): void { - if (!signal) { - this.process.kill(); - return; - } - this.process.kill(signal as NodeJS.Signals); - } - - onData(callback: (data: string) => void): () => void { - this.dataListeners.add(callback); - return () => { - this.dataListeners.delete(callback); - }; - } - - onExit(callback: (event: PtyExitEvent) => void): () => void { - this.exitListeners.add(callback); - return () => { - this.exitListeners.delete(callback); - }; - } - - emitData(data: Uint8Array): void { - if (this.didExit) return; - const text = this.decoder.decode(data, { stream: true }); - if (text.length === 0) return; - for (const listener of this.dataListeners) { - listener(text); - } - } - - private emitExit(event: PtyExitEvent): void { - if (this.didExit) return; - this.didExit = true; - - const remainder = this.decoder.decode(); - if (remainder.length > 0) { - for (const listener of this.dataListeners) { - listener(remainder); - } - } - - for (const listener of this.exitListeners) { - listener(event); - } - } -} - -export const layer = Layer.effect( - PtyAdapter, - Effect.gen(function* () { - if (process.platform === "win32") { - return yield* Effect.die( - "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx cafe-code`) instead.", - ); - } - return { - spawn: (input) => - Effect.sync(() => { - let processHandle: BunPtyProcess | null = null; - const command = [input.shell, ...(input.args ?? [])]; - const subprocess = Bun.spawn(command, { - cwd: input.cwd, - env: input.env, - terminal: { - cols: input.cols, - rows: input.rows, - data: (_terminal, data) => { - processHandle?.emitData(data); - }, - }, - }); - processHandle = new BunPtyProcess(subprocess); - return processHandle; - }), - } satisfies PtyAdapterShape; - }), -); diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts deleted file mode 100644 index 2b413c47..00000000 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ /dev/null @@ -1,1192 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import { - DEFAULT_TERMINAL_ID, - type TerminalEvent, - type TerminalOpenInput, - type TerminalRestartInput, -} from "@cafecode/contracts"; -import * as Data from "effect/Data"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; -import * as Path from "effect/Path"; -import * as Ref from "effect/Ref"; -import * as Schedule from "effect/Schedule"; -import * as Scope from "effect/Scope"; -import { TestClock } from "effect/testing"; -import { expect } from "vitest"; - -import * as ProcessRunner from "../../processRunner.ts"; -import type { TerminalManagerShape } from "../Services/Manager.ts"; -import { - type PtyAdapterShape, - type PtyExitEvent, - type PtyProcess, - type PtySpawnInput, - PtySpawnError, -} from "../Services/PTY.ts"; -import { makeTerminalManagerWithOptions } from "./Manager.ts"; - -class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ - readonly message: string; -}> {} - -class FakePtyProcess implements PtyProcess { - readonly writes: string[] = []; - readonly resizeCalls: Array<{ cols: number; rows: number }> = []; - readonly killSignals: Array = []; - readonly pid: number; - private readonly dataListeners = new Set<(data: string) => void>(); - private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); - killed = false; - - constructor(pid: number) { - this.pid = pid; - } - - write(data: string): void { - this.writes.push(data); - } - - resize(cols: number, rows: number): void { - this.resizeCalls.push({ cols, rows }); - } - - kill(signal?: string): void { - this.killed = true; - this.killSignals.push(signal); - } - - onData(callback: (data: string) => void): () => void { - this.dataListeners.add(callback); - return () => { - this.dataListeners.delete(callback); - }; - } - - onExit(callback: (event: PtyExitEvent) => void): () => void { - this.exitListeners.add(callback); - return () => { - this.exitListeners.delete(callback); - }; - } - - emitData(data: string): void { - for (const listener of this.dataListeners) { - listener(data); - } - } - - emitExit(event: PtyExitEvent): void { - for (const listener of this.exitListeners) { - listener(event); - } - } -} - -class FakePtyAdapter implements PtyAdapterShape { - readonly spawnInputs: PtySpawnInput[] = []; - readonly processes: FakePtyProcess[] = []; - readonly spawnFailures: Error[] = []; - private readonly mode: "sync" | "async"; - private nextPid = 9000; - - constructor(mode: "sync" | "async" = "sync") { - this.mode = mode; - } - - spawn(input: PtySpawnInput): Effect.Effect { - this.spawnInputs.push(input); - const failure = this.spawnFailures.shift(); - if (failure) { - return Effect.fail( - new PtySpawnError({ - adapter: "fake", - message: "Failed to spawn PTY process", - cause: failure, - }), - ); - } - const process = new FakePtyProcess(this.nextPid++); - this.processes.push(process); - if (this.mode === "async") { - return Effect.tryPromise({ - try: async () => process, - catch: (cause) => - new PtySpawnError({ - adapter: "fake", - message: "Failed to spawn PTY process", - cause, - }), - }); - } - return Effect.succeed(process); - } -} - -const waitFor = ( - predicate: Effect.Effect, - timeout: Duration.Input = 800, -): Effect.Effect => - predicate.pipe( - Effect.filterOrFail( - (done) => done, - () => new WaitForConditionError({ message: "Condition not met" }), - ), - Effect.retry(Schedule.spaced("15 millis")), - Effect.timeoutOption(timeout), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })), - onSome: () => Effect.void, - }), - ), - ); - -function openInput(overrides: Partial = {}): TerminalOpenInput { - return { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cwd: process.cwd(), - cols: 100, - rows: 24, - ...overrides, - }; -} - -function restartInput(overrides: Partial = {}): TerminalRestartInput { - return { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cwd: process.cwd(), - cols: 100, - rows: 24, - ...overrides, - }; -} - -const historyLogPath = (logsDir: string, threadId = "thread-1") => - Effect.service(Path.Path).pipe( - Effect.map(({ join }) => join(logsDir, `terminal_${Encoding.encodeBase64Url(threadId)}.log`)), - ); - -const multiTerminalHistoryLogPath = ( - logsDir: string, - threadId = "thread-1", - terminalId = DEFAULT_TERMINAL_ID, -) => - Effect.service(Path.Path).pipe( - Effect.map(({ join }) => { - const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; - return join( - logsDir, - terminalId === DEFAULT_TERMINAL_ID - ? `${threadPart}.log` - : `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`, - ); - }), - ); - -interface CreateManagerOptions { - shellResolver?: () => string; - platform?: NodeJS.Platform; - env?: NodeJS.ProcessEnv; - subprocessChecker?: (terminalPid: number) => Effect.Effect; - subprocessPollIntervalMs?: number; - processKillGraceMs?: number; - maxRetainedInactiveSessions?: number; - ptyAdapter?: FakePtyAdapter; -} - -interface ManagerFixture { - readonly baseDir: string; - readonly logsDir: string; - readonly ptyAdapter: FakePtyAdapter; - readonly manager: TerminalManagerShape; - readonly getEvents: Effect.Effect>; -} - -const createManager = ( - historyLineLimit = 5, - options: CreateManagerOptions = {}, -): Effect.Effect< - ManagerFixture, - PlatformError.PlatformError, - FileSystem.FileSystem | Path.Path | Scope.Scope | ProcessRunner.ProcessRunner -> => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => - Effect.gen(function* () { - const { join } = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-terminal-" }); - const logsDir = join(baseDir, "userdata", "logs", "terminals"); - const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); - - const manager = yield* makeTerminalManagerWithOptions({ - logsDir, - historyLineLimit, - ptyAdapter, - ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), - ...(options.platform !== undefined ? { platform: options.platform } : {}), - ...(options.env !== undefined ? { env: options.env } : {}), - ...(options.subprocessChecker !== undefined - ? { subprocessChecker: options.subprocessChecker } - : {}), - ...(options.subprocessPollIntervalMs !== undefined - ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } - : {}), - processKillGraceMs: options.processKillGraceMs ?? 1, - ...(options.maxRetainedInactiveSessions !== undefined - ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } - : {}), - }); - const eventsRef = yield* Ref.make>([]); - const scope = yield* Effect.scope; - const unsubscribe = yield* manager.subscribe((event) => - Ref.update(eventsRef, (events) => [...events, event]), - ); - yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); - - return { - baseDir, - logsDir, - join, - ptyAdapter, - manager, - getEvents: Ref.get(eventsRef), - }; - }), - ); - -it.layer( - Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), - { excludeTestServices: true }, -)("TerminalManager", (it) => { - const itEffectSkipOnWindows = process.platform === "win32" ? it.effect.skip : it.effect; - - it.effect("spawns lazily and reuses running terminal per thread", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - const [first, second] = yield* Effect.all( - [manager.open(openInput()), manager.open(openInput())], - { concurrency: "unbounded" }, - ); - const third = yield* manager.open(openInput()); - - assert.equal(first.threadId, "thread-1"); - assert.equal(first.terminalId, "default"); - assert.equal(second.threadId, "thread-1"); - assert.equal(third.threadId, "thread-1"); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - }), - ); - - const makeDirectory = (filePath: string) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => - fs.makeDirectory(filePath, { recursive: true }), - ); - - const chmod = (filePath: string, mode: number) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode)); - - const pathExists = (filePath: string) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath)); - - const readFileString = (filePath: string) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.readFileString(filePath)); - - const writeFileString = (filePath: string, contents: string) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => - fs.writeFileString(filePath, contents), - ); - - itEffectSkipOnWindows("preserves non-notFound cwd stat failures", () => - Effect.gen(function* () { - const path = yield* Path.Path; - - const { manager, baseDir } = yield* createManager(); - const blockedRoot = path.join(baseDir, "blocked-root"); - const blockedCwd = path.join(blockedRoot, "cwd"); - yield* makeDirectory(blockedCwd); - yield* chmod(blockedRoot, 0o000); - - const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe( - Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)), - ); - - expect(error).toMatchObject({ - _tag: "TerminalCwdError", - cwd: blockedCwd, - reason: "statFailed", - }); - }), - ); - - it.effect("supports asynchronous PTY spawn effects", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - - const snapshot = yield* manager.open(openInput()); - - assert.equal(snapshot.status, "running"); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - expect(ptyAdapter.processes).toHaveLength(1); - }), - ); - - it.effect("forwards write and resize to active pty process", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - yield* manager.write({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - data: "ls\n", - }); - yield* manager.resize({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cols: 120, - rows: 30, - }); - - expect(process.writes).toEqual(["ls\n"]); - expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); - }), - ); - - it.effect("resizes running terminal on open when a different size is requested", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput({ cols: 100, rows: 24 })); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 })); - - assert.equal(reopened.status, "running"); - expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); - }), - ); - - it.effect("supports multiple terminals per thread independently", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput({ terminalId: "default" })); - yield* manager.open(openInput({ terminalId: "term-2" })); - - const first = ptyAdapter.processes[0]; - const second = ptyAdapter.processes[1]; - expect(first).toBeDefined(); - expect(second).toBeDefined(); - if (!first || !second) return; - - yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); - yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); - - expect(first.writes).toEqual(["pwd\n"]); - expect(second.writes).toEqual(["ls\n"]); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - }), - ); - - it.effect("clears transcript and emits cleared event", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); - const path = yield* Path.Path; - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello\n"); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(readFileString), - Effect.map((text) => text === ""), - ), - ); - - const events = yield* getEvents; - expect(events.some((event) => event.type === "cleared")).toBe(true); - expect( - events.some( - (event) => - event.type === "cleared" && - event.threadId === "thread-1" && - event.terminalId === "default", - ), - ).toBe(true); - }), - ); - - it.effect("restarts terminal with empty transcript and respawns pty", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(); - yield* manager.open(openInput()); - const firstProcess = ptyAdapter.processes[0]; - expect(firstProcess).toBeDefined(); - if (!firstProcess) return; - firstProcess.emitData("before restart\n"); - const path = yield* Path.Path; - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - - const snapshot = yield* manager.restart(restartInput()); - assert.equal(snapshot.history, ""); - assert.equal(snapshot.status, "running"); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(readFileString), - Effect.map((text) => text === ""), - ), - ); - }), - ); - - it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => - Effect.gen(function* () { - const { manager, getEvents, baseDir } = yield* createManager(); - const path = yield* Path.Path; - const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); - const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); - yield* makeDirectory(firstWorktreePath); - yield* makeDirectory(secondWorktreePath); - const startedSnapshot = yield* manager.open( - openInput({ - cwd: firstWorktreePath, - worktreePath: firstWorktreePath, - }), - ); - const restartedSnapshot = yield* manager.restart( - restartInput({ - cwd: secondWorktreePath, - worktreePath: secondWorktreePath, - }), - ); - - assert.equal(startedSnapshot.worktreePath, firstWorktreePath); - assert.equal(restartedSnapshot.worktreePath, secondWorktreePath); - - const events = yield* getEvents; - const startedEvent = events.find( - (event): event is Extract => event.type === "started", - ); - const restartedEvent = events.find( - (event): event is Extract => - event.type === "restarted", - ); - - assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath); - assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath); - }), - ); - - it.effect("preserves worktree metadata when reopening an exited session", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); - const path = yield* Path.Path; - const worktreePath = path.join(baseDir, "worktrees", "feature-a"); - yield* makeDirectory(worktreePath); - - yield* manager.open( - openInput({ - cwd: worktreePath, - worktreePath, - }), - ); - - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - ); - - const reopenedSnapshot = yield* manager.open( - openInput({ - cwd: worktreePath, - worktreePath, - }), - ); - - assert.equal(reopenedSnapshot.worktreePath, worktreePath); - - const events = yield* getEvents; - const reopenedEvent = events - .toReversed() - .find( - (event): event is Extract => event.type === "started", - ); - - assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath); - }), - ); - - it.effect("emits exited event and reopens with clean transcript after exit", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); - const path = yield* Path.Path; - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitData("old data\n"); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - ); - const reopened = yield* manager.open(openInput()); - - assert.equal(reopened.history, ""); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - expect( - yield* historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(readFileString), - ), - ).toBe(""); - }), - ); - - it.effect("ignores trailing writes after terminal exit", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* manager.write({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - data: "\r", - }); - expect(process.writes).toEqual([]); - }), - ); - - it.effect("emits subprocess activity events when child-process state changes", () => - Effect.gen(function* () { - let hasRunningSubprocess = false; - const { manager, getEvents } = yield* createManager(5, { - subprocessChecker: () => Effect.succeed(hasRunningSubprocess), - subprocessPollIntervalMs: 20, - }); - - yield* manager.open(openInput()); - expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); - - hasRunningSubprocess = true; - yield* waitFor( - Effect.map(getEvents, (events) => - events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), - ), - "1200 millis", - ); - - hasRunningSubprocess = false; - yield* waitFor( - Effect.map(getEvents, (events) => - events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), - ), - "1200 millis", - ); - }), - ); - - it.effect("does not invoke subprocess polling until a terminal session is running", () => - Effect.gen(function* () { - let checks = 0; - const { manager } = yield* createManager(5, { - subprocessChecker: () => { - checks += 1; - return Effect.succeed(false); - }, - subprocessPollIntervalMs: 20, - }); - - yield* Effect.sleep("80 millis"); - assert.equal(checks, 0); - - yield* manager.open(openInput()); - yield* waitFor( - Effect.sync(() => checks > 0), - "1200 millis", - ); - }), - ); - - it.effect("caps persisted history to configured line limit", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(3); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("line1\nline2\nline3\nline4\n"); - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); - expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); - }), - ); - - it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("prompt "); - process.emitData("\u001b[32mok\u001b[0m "); - process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007"); - process.emitData("\u001b[1;1R"); - process.emitData("done\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "prompt \u001b[32mok\u001b[0m done\n"); - }), - ); - - it.effect( - "preserves clear and style control sequences while dropping chunk-split query traffic", - () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("before clear\n"); - process.emitData("\u001b[H\u001b[2J"); - process.emitData("prompt "); - process.emitData("\u001b]11;"); - process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1"); - process.emitData("R\u001b[36mdone\u001b[0m\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal( - reopened.history, - "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n", - ); - }), - ); - - it.effect("does not leak final bytes from ESC sequences with intermediate bytes", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("before "); - process.emitData("\u001b(B"); - process.emitData("after\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "before \u001b(Bafter\n"); - }), - ); - - it.effect( - "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", - () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("before "); - process.emitData("\u001b("); - process.emitData("Bafter\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - assert.equal(reopened.history, "before \u001b(Bafter\n"); - }), - ); - - it.effect("deletes history file when close(deleteHistory=true)", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitData("bye\n"); - const path = yield* Path.Path; - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - - yield* manager.close({ threadId: "thread-1", deleteHistory: true }); - expect( - yield* historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ).toBe(false); - }), - ); - - it.effect("closes all terminals for a thread when close omits terminalId", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(); - yield* manager.open(openInput({ terminalId: "default" })); - yield* manager.open(openInput({ terminalId: "sidecar" })); - const defaultProcess = ptyAdapter.processes[0]; - const sidecarProcess = ptyAdapter.processes[1]; - expect(defaultProcess).toBeDefined(); - expect(sidecarProcess).toBeDefined(); - if (!defaultProcess || !sidecarProcess) return; - - defaultProcess.emitData("default\n"); - sidecarProcess.emitData("sidecar\n"); - const path = yield* Path.Path; - yield* waitFor( - multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - yield* waitFor( - multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - - yield* manager.close({ threadId: "thread-1", deleteHistory: true }); - - assert.equal(defaultProcess.killed, true); - assert.equal(sidecarProcess.killed, true); - expect( - yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ).toBe(false); - expect( - yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ).toBe(false); - }), - ); - - it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust("10 millis"); - yield* Fiber.join(closeFiber); - - assert.equal(process.killSignals[0], "SIGTERM"); - expect(process.killSignals).toContain("SIGKILL"); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { - maxRetainedInactiveSessions: 1, - }); - - yield* manager.open(openInput({ threadId: "thread-1" })); - yield* manager.open(openInput({ threadId: "thread-2" })); - - const first = ptyAdapter.processes[0]; - const second = ptyAdapter.processes[1]; - expect(first).toBeDefined(); - expect(second).toBeDefined(); - if (!first || !second) return; - - first.emitData("first-history\n"); - second.emitData("second-history\n"); - const path = yield* Path.Path; - yield* waitFor( - historyLogPath(logsDir, "thread-1").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - first.emitExit({ exitCode: 0, signal: 0 }); - yield* Effect.sleep(Duration.millis(5)); - second.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map( - getEvents, - (events) => events.filter((event) => event.type === "exited").length === 2, - ), - ); - - const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" })); - const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" })); - - assert.equal(reopenedFirst.history, "first-history\n"); - assert.equal(reopenedSecond.history, ""); - }), - ); - - it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () => - Effect.gen(function* () { - const { manager, logsDir } = yield* createManager(); - const path = yield* Path.Path; - const legacyPath = path.join(logsDir, "thread-1.log"); - const nextPath = yield* historyLogPath(logsDir); - yield* writeFileString(legacyPath, "legacy-line\n"); - - const snapshot = yield* manager.open(openInput()); - - assert.equal(snapshot.history, "legacy-line\n"); - expect(yield* pathExists(nextPath)).toBe(true); - expect(yield* readFileString(nextPath)).toBe("legacy-line\n"); - expect(yield* pathExists(legacyPath)).toBe(false); - }), - ); - - it.effect("retries with fallback shells when preferred shell spawn fails", () => - Effect.gen(function* () { - const missingShell = - process.platform === "win32" - ? "C:\\definitely\\missing-shell.exe" - : "/definitely/missing-shell -l"; - const { manager, ptyAdapter } = yield* createManager(5, { - shellResolver: () => missingShell, - }); - ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); - - const snapshot = yield* manager.open(openInput()); - - assert.equal(snapshot.status, "running"); - expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); - expect(ptyAdapter.spawnInputs[0]?.shell).toBe( - process.platform === "win32" ? missingShell : "/definitely/missing-shell", - ); - - if (process.platform === "win32") { - expect( - ptyAdapter.spawnInputs.some( - (input) => - input.shell === "pwsh.exe" || - input.shell === "powershell.exe" || - input.shell === "cmd.exe", - ), - ).toBe(true); - } else { - expect( - ptyAdapter.spawnInputs - .slice(1) - .some((input) => input.shell !== "/definitely/missing-shell"), - ).toBe(true); - } - }), - ); - - it.effect("prefers PowerShell over ComSpec for Windows terminals", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - platform: "win32", - env: { - ComSpec: "C:\\Windows\\System32\\cmd.exe", - PATH: "C:\\Windows\\System32", - SystemRoot: "C:\\Windows", - }, - }); - - yield* manager.open(openInput()); - - expect(ptyAdapter.spawnInputs[0]).toEqual( - expect.objectContaining({ - shell: "pwsh.exe", - args: ["-NoLogo"], - }), - ); - }), - ); - - it.effect("falls back to built-in PowerShell by absolute path on Windows", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - platform: "win32", - env: { - ComSpec: "C:\\Windows\\System32\\cmd.exe", - PATH: "C:\\Windows\\System32", - SystemRoot: "C:\\Windows", - }, - shellResolver: () => "C:\\missing\\custom-shell.exe", - }); - ptyAdapter.spawnFailures.push( - new Error("spawn custom-shell.exe ENOENT"), - new Error("spawn pwsh.exe ENOENT"), - ); - - yield* manager.open(openInput()); - - expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ - "C:\\missing\\custom-shell.exe", - "pwsh.exe", - "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", - ]); - expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]); - expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]); - }), - ); - - it.effect("filters app runtime env variables from terminal sessions", () => - Effect.gen(function* () { - const originalValues = new Map(); - const setEnv = (key: string, value: string | undefined) => { - if (!originalValues.has(key)) { - originalValues.set(key, process.env[key]); - } - if (value === undefined) { - delete process.env[key]; - return; - } - process.env[key] = value; - }; - const restoreEnv = () => { - for (const [key, value] of originalValues) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }; - - setEnv("PORT", "5173"); - setEnv("CAFE_CODE_PORT", "3773"); - setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); - setEnv("TEST_TERMINAL_KEEP", "keep-me"); - - try { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.env.PORT).toBeUndefined(); - expect(spawnInput.env.CAFE_CODE_PORT).toBeUndefined(); - expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); - expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); - } finally { - restoreEnv(); - } - }), - ); - - it.effect("injects runtime env overrides into spawned terminals", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open( - openInput({ - env: { - CAFE_CODE_PROJECT_ROOT: "/repo", - CAFE_CODE_WORKTREE_PATH: "/repo/worktree-a", - CUSTOM_FLAG: "1", - }, - }), - ); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - assert.equal(spawnInput.env.CAFE_CODE_PROJECT_ROOT, "/repo"); - assert.equal(spawnInput.env.CAFE_CODE_WORKTREE_PATH, "/repo/worktree-a"); - assert.equal(spawnInput.env.CUSTOM_FLAG, "1"); - }), - ); - - it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => - Effect.gen(function* () { - if (process.platform === "win32") return; - const { manager, ptyAdapter } = yield* createManager(5, { - shellResolver: () => "/bin/zsh", - }); - yield* manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); - }), - ); - - it.effect("bridges PTY callbacks back into Effect-managed event streaming", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello from callback\n"); - - yield* waitFor( - Effect.map(getEvents, (events) => - events.some((event) => event.type === "output" && event.data === "hello from callback\n"), - ), - "1200 millis", - ); - }), - ); - - it.effect("pushes PTY callbacks to direct event subscribers", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - const scope = yield* Effect.scope; - const subscriberEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.subscribe((event) => - Ref.update(subscriberEvents, (events) => [...events, event]), - ); - yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello from subscriber\n"); - - yield* waitFor( - Effect.map(Ref.get(subscriberEvents), (events) => - events.some( - (event) => event.type === "output" && event.data === "hello from subscriber\n", - ), - ), - "1200 millis", - ); - }), - ); - - it.effect("preserves queued PTY output ordering through exit callbacks", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("first\n"); - process.emitData("second\n"); - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => { - const relevant = events.filter( - (event) => event.type === "output" || event.type === "exited", - ); - return relevant.length >= 3; - }), - "1200 millis", - ); - - const relevant = (yield* getEvents).filter( - (event) => event.type === "output" || event.type === "exited", - ); - expect(relevant).toEqual([ - expect.objectContaining({ type: "output", data: "first\n" }), - expect.objectContaining({ type: "output", data: "second\n" }), - expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0 }), - ]); - }), - ); - - it.effect("scoped runtime shutdown stops active terminals cleanly", () => - Effect.gen(function* () { - const scope = yield* Scope.make("sequential"); - const { manager, ptyAdapter } = yield* createManager(5, { - processKillGraceMs: 10, - }).pipe(Effect.provideService(Scope.Scope, scope)); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust("10 millis"); - yield* Fiber.join(closeScope); - - assert.equal(process.killSignals[0], "SIGTERM"); - expect(process.killSignals).toContain("SIGKILL"); - }).pipe(Effect.provide(TestClock.layer())), - ); -}); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts deleted file mode 100644 index 7e840498..00000000 --- a/apps/server/src/terminal/Layers/Manager.ts +++ /dev/null @@ -1,1972 +0,0 @@ -import { - DEFAULT_TERMINAL_ID, - type TerminalEvent, - type TerminalSessionSnapshot, - type TerminalSessionStatus, -} from "@cafecode/contracts"; -import { makeKeyedCoalescingWorker } from "@cafecode/shared/KeyedCoalescingWorker"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; -import * as Equal from "effect/Equal"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import * as SynchronizedRef from "effect/SynchronizedRef"; - -import { ServerConfig } from "../../config.ts"; -import { - increment, - terminalRestartsTotal, - terminalSessionsTotal, -} from "../../observability/Metrics.ts"; -import * as ProcessRunner from "../../processRunner.ts"; -import { - TerminalCwdError, - TerminalHistoryError, - TerminalManager, - TerminalNotRunningError, - TerminalSessionLookupError, - type TerminalManagerShape, -} from "../Services/Manager.ts"; -import { - PtyAdapter, - PtySpawnError, - type PtyAdapterShape, - type PtyExitEvent, - type PtyProcess, -} from "../Services/PTY.ts"; - -const DEFAULT_HISTORY_LINE_LIMIT = 5_000; -const DEFAULT_PERSIST_DEBOUNCE_MS = 40; -const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; -const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; -const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; -const DEFAULT_OPEN_COLS = 120; -const DEFAULT_OPEN_ROWS = 30; -const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); -const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - -class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( - "TerminalSubprocessCheckError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect), - terminalPid: Schema.Number, - command: Schema.Literals(["powershell", "pgrep", "ps"]), - }, -) {} - -class TerminalProcessSignalError extends Schema.TaggedErrorClass()( - "TerminalProcessSignalError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect), - signal: Schema.Literals(["SIGTERM", "SIGKILL"]), - }, -) {} - -interface TerminalSubprocessChecker { - ( - terminalPid: number, - ): Effect.Effect; -} - -interface ShellCandidate { - shell: string; - args?: string[]; -} - -interface TerminalStartInput { - threadId: string; - terminalId: string; - cwd: string; - worktreePath?: string | null; - cols: number; - rows: number; - env?: Record; -} - -interface TerminalSessionState { - threadId: string; - terminalId: string; - cwd: string; - worktreePath: string | null; - status: TerminalSessionStatus; - pid: number | null; - history: string; - pendingHistoryControlSequence: string; - pendingProcessEvents: Array; - pendingProcessEventIndex: number; - processEventDrainRunning: boolean; - exitCode: number | null; - exitSignal: number | null; - updatedAt: string; - cols: number; - rows: number; - process: PtyProcess | null; - unsubscribeData: (() => void) | null; - unsubscribeExit: (() => void) | null; - hasRunningSubprocess: boolean; - runtimeEnv: Record | null; -} - -interface PersistHistoryRequest { - history: string; - immediate: boolean; -} - -type PendingProcessEvent = { type: "output"; data: string } | { type: "exit"; event: PtyExitEvent }; - -type DrainProcessEventAction = - | { type: "idle" } - | { - type: "output"; - threadId: string; - terminalId: string; - history: string | null; - data: string; - } - | { - type: "exit"; - process: PtyProcess | null; - threadId: string; - terminalId: string; - exitCode: number | null; - exitSignal: number | null; - }; - -interface TerminalManagerState { - sessions: Map; - killFibers: Map>; -} - -function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { - return { - threadId: session.threadId, - terminalId: session.terminalId, - cwd: session.cwd, - worktreePath: session.worktreePath, - status: session.status, - pid: session.pid, - history: session.history, - exitCode: session.exitCode, - exitSignal: session.exitSignal, - updatedAt: session.updatedAt, - }; -} - -function cleanupProcessHandles(session: TerminalSessionState): void { - session.unsubscribeData?.(); - session.unsubscribeData = null; - session.unsubscribeExit?.(); - session.unsubscribeExit = null; -} - -function enqueueProcessEvent( - session: TerminalSessionState, - expectedPid: number, - event: PendingProcessEvent, -): boolean { - if (!session.process || session.status !== "running" || session.pid !== expectedPid) { - return false; - } - - session.pendingProcessEvents.push(event); - if (session.processEventDrainRunning) { - return false; - } - - session.processEventDrainRunning = true; - return true; -} - -function defaultShellResolver( - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): string { - if (platform === "win32") { - return "pwsh.exe"; - } - return env.SHELL ?? "bash"; -} - -function normalizeShellCommand( - value: string | undefined, - platform: NodeJS.Platform = process.platform, -): string | null { - if (!value) return null; - const trimmed = value.trim(); - if (trimmed.length === 0) return null; - - if (platform === "win32") { - return trimmed; - } - - const firstToken = trimmed.split(/\s+/g)[0]?.trim(); - if (!firstToken) return null; - return firstToken.replace(/^['"]|['"]$/g, ""); -} - -function basenameForPlatform(command: string, platform: NodeJS.Platform): string { - const normalized = - platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/"); - const parts = normalized - .split(platform === "win32" ? /\\+/ : /\/+/) - .filter((part) => part.length > 0); - return parts.at(-1) ?? normalized; -} - -function joinWindowsPath(...parts: ReadonlyArray): string { - return parts - .map((part, index) => { - if (index === 0) return part.replace(/[\\/]+$/g, ""); - return part.replace(/^[\\/]+|[\\/]+$/g, ""); - }) - .filter((part) => part.length > 0) - .join("\\"); -} - -function shellCandidateFromCommand( - command: string | null, - platform: NodeJS.Platform = process.platform, -): ShellCandidate | null { - if (!command || command.length === 0) return null; - const shellName = basenameForPlatform(command, platform).toLowerCase(); - if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { - return { shell: command, args: ["-NoLogo"] }; - } - if (platform !== "win32" && shellName === "zsh") { - return { shell: command, args: ["-o", "nopromptsp"] }; - } - return { shell: command }; -} - -function windowsSystemRoot(env: NodeJS.ProcessEnv): string { - return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; -} - -function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { - return joinWindowsPath( - windowsSystemRoot(env), - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe", - ); -} - -function windowsCmdPath(env: NodeJS.ProcessEnv): string { - return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe"); -} - -function formatShellCandidate(candidate: ShellCandidate): string { - if (!candidate.args || candidate.args.length === 0) return candidate.shell; - return `${candidate.shell} ${candidate.args.join(" ")}`; -} - -function uniqueShellCandidates(candidates: Array): ShellCandidate[] { - const seen = new Set(); - const ordered: ShellCandidate[] = []; - for (const candidate of candidates) { - if (!candidate) continue; - const key = formatShellCandidate(candidate); - if (seen.has(key)) continue; - seen.add(key); - ordered.push(candidate); - } - return ordered; -} - -function resolveShellCandidates( - shellResolver: () => string, - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): ShellCandidate[] { - const requested = shellCandidateFromCommand( - normalizeShellCommand(shellResolver(), platform), - platform, - ); - - if (platform === "win32") { - return uniqueShellCandidates([ - requested, - shellCandidateFromCommand("pwsh.exe", platform), - shellCandidateFromCommand(windowsPowerShellPath(env), platform), - shellCandidateFromCommand("powershell.exe", platform), - shellCandidateFromCommand(env.ComSpec ?? null, platform), - shellCandidateFromCommand(windowsCmdPath(env), platform), - shellCandidateFromCommand("cmd.exe", platform), - ]); - } - - return uniqueShellCandidates([ - requested, - shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform), - shellCandidateFromCommand("/bin/zsh", platform), - shellCandidateFromCommand("/bin/bash", platform), - shellCandidateFromCommand("/bin/sh", platform), - shellCandidateFromCommand("zsh", platform), - shellCandidateFromCommand("bash", platform), - shellCandidateFromCommand("sh", platform), - ]); -} - -function isRetryableShellSpawnError(error: PtySpawnError): boolean { - const queue: unknown[] = [error]; - const seen = new Set(); - const messages: string[] = []; - - while (queue.length > 0) { - const current = queue.shift(); - if (!current || seen.has(current)) { - continue; - } - seen.add(current); - - if (typeof current === "string") { - messages.push(current); - continue; - } - - if (current instanceof Error) { - messages.push(current.message); - if (current.cause) { - queue.push(current.cause); - } - continue; - } - - if (typeof current === "object") { - const value = current as { message?: unknown; cause?: unknown }; - if (typeof value.message === "string") { - messages.push(value.message); - } - if (value.cause) { - queue.push(value.cause); - } - } - } - - const message = messages.join(" ").toLowerCase(); - return ( - message.includes("posix_spawnp failed") || - message.includes("enoent") || - message.includes("not found") || - message.includes("file not found") || - message.includes("no such file") - ); -} - -function checkWindowsSubprocessActivity( - terminalPid: number, -): Effect.Effect { - const command = [ - `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, - "if ($children) { exit 0 }", - "exit 1", - ].join("; "); - return Effect.gen(function* () { - const processRunner = yield* ProcessRunner.ProcessRunner; - return yield* processRunner.run({ - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", command], - timeout: "1500 millis", - maxOutputBytes: 32_768, - outputMode: "truncate", - shell: process.platform === "win32", - timeoutBehavior: "timedOutResult", - }); - }).pipe( - Effect.map((result) => result.code === 0), - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to check Windows terminal subprocess activity.", - cause, - terminalPid, - command: "powershell", - }), - ), - ); -} - -const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* ( - terminalPid: number, -): Effect.fn.Return { - const processRunner = yield* ProcessRunner.ProcessRunner; - const runPgrep = processRunner - .run({ - command: "pgrep", - args: ["-P", String(terminalPid)], - timeout: "1 second", - maxOutputBytes: 32_768, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }) - .pipe( - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect terminal subprocesses with pgrep.", - cause, - terminalPid, - command: "pgrep", - }), - ), - ); - - const runPs = processRunner - .run({ - command: "ps", - args: ["-eo", "pid=,ppid="], - timeout: "1 second", - maxOutputBytes: 262_144, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }) - .pipe( - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - message: "Failed to inspect terminal subprocesses with ps.", - cause, - terminalPid, - command: "ps", - }), - ), - ); - - const pgrepResult = yield* Effect.exit(runPgrep); - if (pgrepResult._tag === "Success") { - if (pgrepResult.value.code === 0) { - return pgrepResult.value.stdout.trim().length > 0; - } - if (pgrepResult.value.code === 1) { - return false; - } - } - - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Failure" || psResult.value.code !== 0) { - return false; - } - - for (const line of psResult.value.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - if (ppid === terminalPid) { - return true; - } - } - return false; -}); - -const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* ( - terminalPid: number, -): Effect.fn.Return { - if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return false; - } - if (process.platform === "win32") { - return yield* checkWindowsSubprocessActivity(terminalPid); - } - return yield* checkPosixSubprocessActivity(terminalPid); -}); - -function capHistory(history: string, maxLines: number): string { - if (history.length === 0) return history; - const hasTrailingNewline = history.endsWith("\n"); - const lines = history.split("\n"); - if (hasTrailingNewline) { - lines.pop(); - } - if (lines.length <= maxLines) return history; - const capped = lines.slice(lines.length - maxLines).join("\n"); - return hasTrailingNewline ? `${capped}\n` : capped; -} - -function isCsiFinalByte(codePoint: number): boolean { - return codePoint >= 0x40 && codePoint <= 0x7e; -} - -function shouldStripCsiSequence(body: string, finalByte: string): boolean { - if (finalByte === "n") { - return true; - } - if (finalByte === "R" && /^[0-9;?]*$/.test(body)) { - return true; - } - if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { - return true; - } - return false; -} - -function shouldStripOscSequence(content: string): boolean { - return /^(10|11|12);(?:\?|rgb:)/.test(content); -} - -function stripStringTerminator(value: string): string { - if (value.endsWith("\u001b\\")) { - return value.slice(0, -2); - } - const lastCharacter = value.at(-1); - if (lastCharacter === "\u0007" || lastCharacter === "\u009c") { - return value.slice(0, -1); - } - return value; -} - -function findStringTerminatorIndex(input: string, start: number): number | null { - for (let index = start; index < input.length; index += 1) { - const codePoint = input.charCodeAt(index); - if (codePoint === 0x07 || codePoint === 0x9c) { - return index + 1; - } - if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { - return index + 2; - } - } - return null; -} - -function isEscapeIntermediateByte(codePoint: number): boolean { - return codePoint >= 0x20 && codePoint <= 0x2f; -} - -function isEscapeFinalByte(codePoint: number): boolean { - return codePoint >= 0x30 && codePoint <= 0x7e; -} - -function findEscapeSequenceEndIndex(input: string, start: number): number | null { - let cursor = start; - while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) { - cursor += 1; - } - if (cursor >= input.length) { - return null; - } - return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; -} - -function sanitizeTerminalHistoryChunk( - pendingControlSequence: string, - data: string, -): { visibleText: string; pendingControlSequence: string } { - const input = `${pendingControlSequence}${data}`; - let visibleText = ""; - let index = 0; - - const append = (value: string) => { - visibleText += value; - }; - - while (index < input.length) { - const codePoint = input.charCodeAt(index); - - if (codePoint === 0x1b) { - const nextCodePoint = input.charCodeAt(index + 1); - if (Number.isNaN(nextCodePoint)) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - - if (nextCodePoint === 0x5b) { - let cursor = index + 2; - while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { - const sequence = input.slice(index, cursor + 1); - const body = input.slice(index + 2, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { - append(sequence); - } - index = cursor + 1; - break; - } - cursor += 1; - } - if (cursor >= input.length) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - continue; - } - - if ( - nextCodePoint === 0x5d || - nextCodePoint === 0x50 || - nextCodePoint === 0x5e || - nextCodePoint === 0x5f - ) { - const terminatorIndex = findStringTerminatorIndex(input, index + 2); - if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - const sequence = input.slice(index, terminatorIndex); - const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); - if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) { - append(sequence); - } - index = terminatorIndex; - continue; - } - - const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); - if (escapeSequenceEndIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - append(input.slice(index, escapeSequenceEndIndex)); - index = escapeSequenceEndIndex; - continue; - } - - if (codePoint === 0x9b) { - let cursor = index + 1; - while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { - const sequence = input.slice(index, cursor + 1); - const body = input.slice(index + 1, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { - append(sequence); - } - index = cursor + 1; - break; - } - cursor += 1; - } - if (cursor >= input.length) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - continue; - } - - if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { - const terminatorIndex = findStringTerminatorIndex(input, index + 1); - if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; - } - const sequence = input.slice(index, terminatorIndex); - const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); - if (codePoint !== 0x9d || !shouldStripOscSequence(content)) { - append(sequence); - } - index = terminatorIndex; - continue; - } - - append(input[index] ?? ""); - index += 1; - } - - return { visibleText, pendingControlSequence: "" }; -} - -function legacySafeThreadId(threadId: string): string { - return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); -} - -function toSafeThreadId(threadId: string): string { - return `terminal_${Encoding.encodeBase64Url(threadId)}`; -} - -function toSafeTerminalId(terminalId: string): string { - return Encoding.encodeBase64Url(terminalId); -} - -function toSessionKey(threadId: string, terminalId: string): string { - return `${threadId}\u0000${terminalId}`; -} - -function shouldExcludeTerminalEnvKey(key: string): boolean { - const normalizedKey = key.toUpperCase(); - if (normalizedKey.startsWith("CAFE_CODE_")) { - return true; - } - if (normalizedKey.startsWith("VITE_")) { - return true; - } - return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); -} - -function createTerminalSpawnEnv( - baseEnv: NodeJS.ProcessEnv, - runtimeEnv?: Record | null, -): NodeJS.ProcessEnv { - const spawnEnv: NodeJS.ProcessEnv = {}; - for (const [key, value] of Object.entries(baseEnv)) { - if (value === undefined) continue; - if (shouldExcludeTerminalEnvKey(key)) continue; - spawnEnv[key] = value; - } - if (runtimeEnv) { - for (const [key, value] of Object.entries(runtimeEnv)) { - spawnEnv[key] = value; - } - } - return spawnEnv; -} - -function normalizedRuntimeEnv( - env: Record | undefined, -): Record | null { - if (!env) return null; - const entries = Object.entries(env); - if (entries.length === 0) return null; - return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); -} - -interface TerminalManagerOptions { - logsDir: string; - historyLineLimit?: number; - ptyAdapter: PtyAdapterShape; - shellResolver?: () => string; - platform?: NodeJS.Platform; - env?: NodeJS.ProcessEnv; - subprocessChecker?: TerminalSubprocessChecker; - subprocessPollIntervalMs?: number; - processKillGraceMs?: number; - maxRetainedInactiveSessions?: number; -} - -const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { - const { terminalLogsDir } = yield* ServerConfig; - const ptyAdapter = yield* PtyAdapter; - return yield* makeTerminalManagerWithOptions({ - logsDir: terminalLogsDir, - ptyAdapter, - }); -}); - -export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWithOptions")( - function* (options: TerminalManagerOptions) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - - const logsDir = options.logsDir; - const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - const platform = options.platform ?? process.platform; - const baseEnv = options.env ?? process.env; - const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); - const processRunner = yield* ProcessRunner.ProcessRunner; - const subprocessChecker = - options.subprocessChecker ?? - ((terminalPid) => - defaultSubprocessChecker(terminalPid).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - )); - const subprocessPollIntervalMs = - options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; - const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; - const maxRetainedInactiveSessions = - options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; - - yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); - - const managerStateRef = yield* SynchronizedRef.make({ - sessions: new Map(), - killFibers: new Map(), - }); - const threadLocksRef = yield* SynchronizedRef.make(new Map()); - const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>(); - const workerScope = yield* Scope.make("sequential"); - yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); - - const publishEvent = (event: TerminalEvent) => - Effect.gen(function* () { - for (const listener of terminalEventListeners) { - yield* listener(event).pipe(Effect.ignoreCause({ log: true })); - } - }); - - const historyPath = (threadId: string, terminalId: string) => { - const threadPart = toSafeThreadId(threadId); - if (terminalId === DEFAULT_TERMINAL_ID) { - return path.join(logsDir, `${threadPart}.log`); - } - return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); - }; - - const legacyHistoryPath = (threadId: string) => - path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); - - const toTerminalHistoryError = - (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) => - (cause: unknown) => - new TerminalHistoryError({ - operation, - threadId, - terminalId, - cause, - }); - - const readManagerState = SynchronizedRef.get(managerStateRef); - - const modifyManagerState = ( - f: (state: TerminalManagerState) => readonly [A, TerminalManagerState], - ) => SynchronizedRef.modify(managerStateRef, f); - - const getThreadSemaphore = (threadId: string) => - SynchronizedRef.modifyEffect(threadLocksRef, (current) => { - const existing: Option.Option = Option.fromNullishOr( - current.get(threadId), - ); - return Option.match(existing, { - onNone: () => - Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(threadId, semaphore); - return [semaphore, next] as const; - }), - ), - onSome: (semaphore) => Effect.succeed([semaphore, current] as const), - }); - }); - - const withThreadLock = ( - threadId: string, - effect: Effect.Effect, - ): Effect.Effect => - Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); - - const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* ( - process: PtyProcess | null, - ) { - if (!process) return; - const fiber: Option.Option> = yield* modifyManagerState< - Option.Option> - >((state) => { - const existing: Option.Option> = Option.fromNullishOr( - state.killFibers.get(process), - ); - if (Option.isNone(existing)) { - return [Option.none>(), state] as const; - } - const killFibers = new Map(state.killFibers); - killFibers.delete(process); - return [existing, { ...state, killFibers }] as const; - }); - if (Option.isSome(fiber)) { - yield* Fiber.interrupt(fiber.value).pipe(Effect.ignore); - } - }); - - const registerKillFiber = Effect.fn("terminal.registerKillFiber")(function* ( - process: PtyProcess, - fiber: Fiber.Fiber, - ) { - yield* modifyManagerState((state) => { - const killFibers = new Map(state.killFibers); - killFibers.set(process, fiber); - return [undefined, { ...state, killFibers }] as const; - }); - }); - - const runKillEscalation = Effect.fn("terminal.runKillEscalation")(function* ( - process: PtyProcess, - threadId: string, - terminalId: string, - ) { - const terminated = yield* Effect.try({ - try: () => process.kill("SIGTERM"), - catch: (cause) => - new TerminalProcessSignalError({ - message: "Failed to send SIGTERM to terminal process.", - cause, - signal: "SIGTERM", - }), - }).pipe( - Effect.as(true), - Effect.catch((error) => - Effect.logWarning("failed to kill terminal process", { - threadId, - terminalId, - signal: "SIGTERM", - error: error.message, - }).pipe(Effect.as(false)), - ), - ); - if (!terminated) { - return; - } - - yield* Effect.sleep(processKillGraceMs); - - yield* Effect.try({ - try: () => process.kill("SIGKILL"), - catch: (cause) => - new TerminalProcessSignalError({ - message: "Failed to send SIGKILL to terminal process.", - cause, - signal: "SIGKILL", - }), - }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to force-kill terminal process", { - threadId, - terminalId, - signal: "SIGKILL", - error: error.message, - }), - ), - ); - }); - - const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* ( - process: PtyProcess, - threadId: string, - terminalId: string, - ) { - const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe( - Effect.ensuring( - modifyManagerState((state) => { - if (!state.killFibers.has(process)) { - return [undefined, state] as const; - } - const killFibers = new Map(state.killFibers); - killFibers.delete(process); - return [undefined, { ...state, killFibers }] as const; - }), - ), - Effect.forkIn(workerScope), - ); - - yield* registerKillFiber(process, fiber); - }); - - const persistWorker = yield* makeKeyedCoalescingWorker< - string, - PersistHistoryRequest, - never, - never - >({ - merge: (current, next) => ({ - history: next.history, - immediate: current.immediate || next.immediate, - }), - process: Effect.fn("terminal.persistHistoryWorker")(function* (sessionKey, request) { - if (!request.immediate) { - yield* Effect.sleep(DEFAULT_PERSIST_DEBOUNCE_MS); - } - - const [threadId, terminalId] = sessionKey.split("\u0000"); - if (!threadId || !terminalId) { - return; - } - - yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe( - Effect.catch((error) => - Effect.logWarning("failed to persist terminal history", { - threadId, - terminalId, - error, - }), - ), - ); - }), - }); - - const queuePersist = Effect.fn("terminal.queuePersist")(function* ( - threadId: string, - terminalId: string, - history: string, - ) { - yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { - history, - immediate: false, - }); - }); - - const flushPersist = Effect.fn("terminal.flushPersist")(function* ( - threadId: string, - terminalId: string, - ) { - yield* persistWorker.drainKey(toSessionKey(threadId, terminalId)); - }); - - const persistHistory = Effect.fn("terminal.persistHistory")(function* ( - threadId: string, - terminalId: string, - history: string, - ) { - yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { - history, - immediate: true, - }); - yield* flushPersist(threadId, terminalId); - }); - - const readHistory = Effect.fn("terminal.readHistory")(function* ( - threadId: string, - terminalId: string, - ) { - const nextPath = historyPath(threadId, terminalId); - if ( - yield* fileSystem - .exists(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))) - ) { - const raw = yield* fileSystem - .readFileString(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))); - const capped = capHistory(raw, historyLineLimit); - if (capped !== raw) { - yield* fileSystem - .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId))); - } - return capped; - } - - if (terminalId !== DEFAULT_TERMINAL_ID) { - return ""; - } - - const legacyPath = legacyHistoryPath(threadId); - if ( - !(yield* fileSystem - .exists(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)))) - ) { - return ""; - } - - const raw = yield* fileSystem - .readFileString(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); - const capped = capHistory(raw, historyLineLimit); - yield* fileSystem - .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); - yield* fileSystem.remove(legacyPath, { force: true }).pipe( - Effect.catch((cleanupError) => - Effect.logWarning("failed to remove legacy terminal history", { - threadId, - error: cleanupError, - }), - ), - ); - return capped; - }); - - const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( - threadId: string, - terminalId: string, - ) { - yield* fileSystem.remove(historyPath(threadId, terminalId), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal history", { - threadId, - terminalId, - error, - }), - ), - ); - if (terminalId === DEFAULT_TERMINAL_ID) { - yield* fileSystem.remove(legacyHistoryPath(threadId), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal history", { - threadId, - terminalId, - error, - }), - ), - ); - } - }); - - const deleteAllHistoryForThread = Effect.fn("terminal.deleteAllHistoryForThread")(function* ( - threadId: string, - ) { - const threadPrefix = `${toSafeThreadId(threadId)}_`; - const entries = yield* fileSystem - .readDirectory(logsDir, { recursive: false }) - .pipe(Effect.catch(() => Effect.succeed([] as Array))); - yield* Effect.forEach( - entries.filter( - (name) => - name === `${toSafeThreadId(threadId)}.log` || - name === `${legacySafeThreadId(threadId)}.log` || - name.startsWith(threadPrefix), - ), - (name) => - fileSystem.remove(path.join(logsDir, name), { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to delete terminal histories for thread", { - threadId, - error, - }), - ), - ), - { discard: true }, - ); - }); - - const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { - const stats = yield* fileSystem.stat(cwd).pipe( - Effect.mapError( - (cause) => - new TerminalCwdError({ - cwd, - reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed", - cause, - }), - ), - ); - if (stats.type !== "Directory") { - return yield* new TerminalCwdError({ - cwd, - reason: "notDirectory", - }); - } - }); - - const getSession = Effect.fn("terminal.getSession")(function* ( - threadId: string, - terminalId: string, - ): Effect.fn.Return> { - return yield* Effect.map(readManagerState, (state) => - Option.fromNullishOr(state.sessions.get(toSessionKey(threadId, terminalId))), - ); - }); - - const requireSession = Effect.fn("terminal.requireSession")(function* ( - threadId: string, - terminalId: string, - ): Effect.fn.Return { - return yield* Effect.flatMap(getSession(threadId, terminalId), (session) => - Option.match(session, { - onNone: () => - Effect.fail( - new TerminalSessionLookupError({ - threadId, - terminalId, - }), - ), - onSome: Effect.succeed, - }), - ); - }); - - const sessionsForThread = Effect.fn("terminal.sessionsForThread")(function* (threadId: string) { - return yield* readManagerState.pipe( - Effect.map((state) => - [...state.sessions.values()].filter((session) => session.threadId === threadId), - ), - ); - }); - - const evictInactiveSessionsIfNeeded = Effect.fn("terminal.evictInactiveSessionsIfNeeded")( - function* () { - yield* modifyManagerState((state) => { - const inactiveSessions = [...state.sessions.values()].filter( - (session) => session.status !== "running", - ); - if (inactiveSessions.length <= maxRetainedInactiveSessions) { - return [undefined, state] as const; - } - - inactiveSessions.sort( - (left, right) => - left.updatedAt.localeCompare(right.updatedAt) || - left.threadId.localeCompare(right.threadId) || - left.terminalId.localeCompare(right.terminalId), - ); - - const sessions = new Map(state.sessions); - - const toEvict = inactiveSessions.length - maxRetainedInactiveSessions; - for (const session of inactiveSessions.slice(0, toEvict)) { - const key = toSessionKey(session.threadId, session.terminalId); - sessions.delete(key); - } - - return [undefined, { ...state, sessions }] as const; - }); - }, - ); - - const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* ( - session: TerminalSessionState, - expectedPid: number, - ) { - while (true) { - const updatedAt = yield* nowIso; - const action: DrainProcessEventAction = yield* Effect.sync(() => { - if (session.pid !== expectedPid || !session.process || session.status !== "running") { - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - return { type: "idle" } as const; - } - - const nextEvent = session.pendingProcessEvents[session.pendingProcessEventIndex]; - if (!nextEvent) { - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - return { type: "idle" } as const; - } - - session.pendingProcessEventIndex += 1; - if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) { - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - } - - if (nextEvent.type === "output") { - const sanitized = sanitizeTerminalHistoryChunk( - session.pendingHistoryControlSequence, - nextEvent.data, - ); - session.pendingHistoryControlSequence = sanitized.pendingControlSequence; - if (sanitized.visibleText.length > 0) { - session.history = capHistory( - `${session.history}${sanitized.visibleText}`, - historyLineLimit, - ); - } - session.updatedAt = updatedAt; - - return { - type: "output", - threadId: session.threadId, - terminalId: session.terminalId, - history: sanitized.visibleText.length > 0 ? session.history : null, - data: nextEvent.data, - } as const; - } - - const process = session.process; - cleanupProcessHandles(session); - session.process = null; - session.pid = null; - session.hasRunningSubprocess = false; - session.status = "exited"; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.exitCode = Number.isInteger(nextEvent.event.exitCode) - ? nextEvent.event.exitCode - : null; - session.exitSignal = Number.isInteger(nextEvent.event.signal) - ? nextEvent.event.signal - : null; - session.updatedAt = updatedAt; - - return { - type: "exit", - process, - threadId: session.threadId, - terminalId: session.terminalId, - exitCode: session.exitCode, - exitSignal: session.exitSignal, - } as const; - }); - - if (action.type === "idle") { - return; - } - - if (action.type === "output") { - if (action.history !== null) { - yield* queuePersist(action.threadId, action.terminalId, action.history); - } - - const createdAt = yield* nowIso; - yield* publishEvent({ - type: "output", - threadId: action.threadId, - terminalId: action.terminalId, - createdAt, - data: action.data, - }); - continue; - } - - yield* clearKillFiber(action.process); - const createdAt = yield* nowIso; - yield* publishEvent({ - type: "exited", - threadId: action.threadId, - terminalId: action.terminalId, - createdAt, - exitCode: action.exitCode, - exitSignal: action.exitSignal, - }); - yield* evictInactiveSessionsIfNeeded(); - return; - } - }); - - const stopProcess = Effect.fn("terminal.stopProcess")(function* ( - session: TerminalSessionState, - ) { - const process = session.process; - if (!process) return; - - const updatedAt = yield* nowIso; - yield* modifyManagerState((state) => { - cleanupProcessHandles(session); - session.process = null; - session.pid = null; - session.hasRunningSubprocess = false; - session.status = "exited"; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.updatedAt = updatedAt; - return [undefined, state] as const; - }); - - yield* clearKillFiber(process); - yield* startKillEscalation(process, session.threadId, session.terminalId); - yield* evictInactiveSessionsIfNeeded(); - }); - - const trySpawn = Effect.fn("terminal.trySpawn")(function* ( - shellCandidates: ReadonlyArray, - spawnEnv: NodeJS.ProcessEnv, - session: TerminalSessionState, - index = 0, - lastError: PtySpawnError | null = null, - ): Effect.fn.Return<{ process: PtyProcess; shellLabel: string }, PtySpawnError> { - if (index >= shellCandidates.length) { - const detail = lastError?.message ?? "Failed to spawn PTY process"; - const tried = - shellCandidates.length > 0 - ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` - : ""; - return yield* new PtySpawnError({ - adapter: "terminal-manager", - message: `${detail}.${tried}`.trim(), - ...(lastError ? { cause: lastError } : {}), - }); - } - - const candidate = shellCandidates[index]; - if (!candidate) { - return yield* ( - lastError ?? - new PtySpawnError({ - adapter: "terminal-manager", - message: "No shell candidate available for PTY spawn.", - }) - ); - } - - const attempt = yield* Effect.result( - options.ptyAdapter.spawn({ - shell: candidate.shell, - ...(candidate.args ? { args: candidate.args } : {}), - cwd: session.cwd, - cols: session.cols, - rows: session.rows, - env: spawnEnv, - }), - ); - - if (attempt._tag === "Success") { - return { - process: attempt.success, - shellLabel: formatShellCandidate(candidate), - }; - } - - const spawnError = attempt.failure; - if (!isRetryableShellSpawnError(spawnError)) { - return yield* spawnError; - } - - return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError); - }); - - const startSession = Effect.fn("terminal.startSession")(function* ( - session: TerminalSessionState, - input: TerminalStartInput, - eventType: "started" | "restarted", - ) { - yield* stopProcess(session); - yield* Effect.annotateCurrentSpan({ - "terminal.thread_id": session.threadId, - "terminal.id": session.terminalId, - "terminal.event_type": eventType, - "terminal.cwd": input.cwd, - }); - - const startingAt = yield* nowIso; - yield* modifyManagerState((state) => { - session.status = "starting"; - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.cols = input.cols; - session.rows = input.rows; - session.exitCode = null; - session.exitSignal = null; - session.hasRunningSubprocess = false; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.updatedAt = startingAt; - return [undefined, state] as const; - }); - - let ptyProcess: PtyProcess | null = null; - let startedShell: string | null = null; - - const startResult = yield* Effect.result( - increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( - Effect.andThen( - Effect.gen(function* () { - const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); - const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); - const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); - ptyProcess = spawnResult.process; - startedShell = spawnResult.shellLabel; - - const processPid = ptyProcess.pid; - const unsubscribeData = ptyProcess.onData((data) => { - if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); - const unsubscribeExit = ptyProcess.onExit((event) => { - if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); - - const runningAt = yield* nowIso; - yield* modifyManagerState((state) => { - session.process = ptyProcess; - session.pid = processPid; - session.status = "running"; - session.updatedAt = runningAt; - session.unsubscribeData = unsubscribeData; - session.unsubscribeExit = unsubscribeExit; - return [undefined, state] as const; - }); - - const createdAt = yield* nowIso; - yield* publishEvent({ - type: eventType, - threadId: session.threadId, - terminalId: session.terminalId, - createdAt, - snapshot: snapshot(session), - }); - }), - ), - ), - ); - - if (startResult._tag === "Success") { - return; - } - - { - const error = startResult.failure; - if (ptyProcess) { - yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); - } - const erroredAt = yield* nowIso; - - yield* modifyManagerState((state) => { - session.status = "error"; - session.pid = null; - session.process = null; - session.unsubscribeData = null; - session.unsubscribeExit = null; - session.hasRunningSubprocess = false; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.updatedAt = erroredAt; - return [undefined, state] as const; - }); - - yield* evictInactiveSessionsIfNeeded(); - - const message = error.message; - const createdAt = yield* nowIso; - yield* publishEvent({ - type: "error", - threadId: session.threadId, - terminalId: session.terminalId, - createdAt, - message, - }); - yield* Effect.logError("failed to start terminal", { - threadId: session.threadId, - terminalId: session.terminalId, - error: message, - ...(startedShell ? { shell: startedShell } : {}), - }); - } - }); - - const closeSession = Effect.fn("terminal.closeSession")(function* ( - threadId: string, - terminalId: string, - deleteHistoryOnClose: boolean, - ) { - const key = toSessionKey(threadId, terminalId); - const session = yield* getSession(threadId, terminalId); - - if (Option.isSome(session)) { - yield* stopProcess(session.value); - yield* persistHistory(threadId, terminalId, session.value.history); - } - - yield* flushPersist(threadId, terminalId); - - yield* modifyManagerState((state) => { - if (!state.sessions.has(key)) { - return [undefined, state] as const; - } - const sessions = new Map(state.sessions); - sessions.delete(key); - return [undefined, { ...state, sessions }] as const; - }); - - if (deleteHistoryOnClose) { - yield* deleteHistory(threadId, terminalId); - } - }); - - const pollSubprocessActivity = Effect.fn("terminal.pollSubprocessActivity")(function* () { - const state = yield* readManagerState; - const runningSessions = [...state.sessions.values()].filter( - (session): session is TerminalSessionState & { pid: number } => - session.status === "running" && Number.isInteger(session.pid), - ); - - if (runningSessions.length === 0) { - return; - } - - const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( - session: TerminalSessionState & { pid: number }, - ) { - const terminalPid = session.pid; - const hasRunningSubprocess = yield* subprocessChecker(terminalPid).pipe( - Effect.map(Option.some), - Effect.catch((reason) => - Effect.logWarning("failed to check terminal subprocess activity", { - threadId: session.threadId, - terminalId: session.terminalId, - terminalPid, - reason, - }).pipe(Effect.as(Option.none())), - ), - ); - - if (Option.isNone(hasRunningSubprocess)) { - return; - } - - const updatedAt = yield* nowIso; - const createdAt = yield* nowIso; - const event = yield* modifyManagerState((state) => { - const liveSession: Option.Option = Option.fromNullishOr( - state.sessions.get(toSessionKey(session.threadId, session.terminalId)), - ); - if ( - Option.isNone(liveSession) || - liveSession.value.status !== "running" || - liveSession.value.pid !== terminalPid || - liveSession.value.hasRunningSubprocess === hasRunningSubprocess.value - ) { - return [Option.none(), state] as const; - } - - liveSession.value.hasRunningSubprocess = hasRunningSubprocess.value; - liveSession.value.updatedAt = updatedAt; - - return [ - Option.some({ - type: "activity" as const, - threadId: liveSession.value.threadId, - terminalId: liveSession.value.terminalId, - createdAt, - hasRunningSubprocess: hasRunningSubprocess.value, - }), - state, - ] as const; - }); - - if (Option.isSome(event)) { - yield* publishEvent(event.value); - } - }); - - yield* Effect.forEach(runningSessions, checkSubprocessActivity, { - concurrency: "unbounded", - discard: true, - }); - }); - - const hasRunningSessions = readManagerState.pipe( - Effect.map((state) => - [...state.sessions.values()].some((session) => session.status === "running"), - ), - ); - - yield* Effect.forever( - hasRunningSessions.pipe( - Effect.flatMap((active) => - active - ? pollSubprocessActivity().pipe( - Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)), - ) - : Effect.sleep(subprocessPollIntervalMs), - ), - ), - ).pipe(Effect.forkIn(workerScope)); - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const sessions = yield* modifyManagerState( - (state) => - [ - [...state.sessions.values()], - { - ...state, - sessions: new Map(), - }, - ] as const, - ); - - const cleanupSession = Effect.fn("terminal.cleanupSession")(function* ( - session: TerminalSessionState, - ) { - cleanupProcessHandles(session); - if (!session.process) return; - yield* clearKillFiber(session.process); - yield* runKillEscalation(session.process, session.threadId, session.terminalId); - }); - - yield* Effect.forEach(sessions, cleanupSession, { - concurrency: "unbounded", - discard: true, - }); - }).pipe(Effect.ignoreCause({ log: true })), - ); - - const open: TerminalManagerShape["open"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existing = yield* getSession(input.threadId, terminalId); - if (Option.isNone(existing)) { - yield* flushPersist(input.threadId, terminalId); - const history = yield* readHistory(input.threadId, terminalId); - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - const createdAt = yield* nowIso; - const session: TerminalSessionState = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history, - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: createdAt, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - return [undefined, { ...state, sessions }] as const; - }); - - yield* evictInactiveSessionsIfNeeded(); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "started", - ); - return snapshot(session); - } - - const liveSession = existing.value; - const nextRuntimeEnv = normalizedRuntimeEnv(input.env); - const currentRuntimeEnv = liveSession.runtimeEnv; - const targetCols = input.cols ?? liveSession.cols; - const targetRows = input.rows ?? liveSession.rows; - const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); - - if (liveSession.cwd !== input.cwd || runtimeEnvChanged) { - yield* stopProcess(liveSession); - liveSession.cwd = input.cwd; - liveSession.worktreePath = input.worktreePath ?? null; - liveSession.runtimeEnv = nextRuntimeEnv; - liveSession.history = ""; - liveSession.pendingHistoryControlSequence = ""; - liveSession.pendingProcessEvents = []; - liveSession.pendingProcessEventIndex = 0; - liveSession.processEventDrainRunning = false; - yield* persistHistory( - liveSession.threadId, - liveSession.terminalId, - liveSession.history, - ); - } else if (liveSession.status === "exited" || liveSession.status === "error") { - liveSession.runtimeEnv = nextRuntimeEnv; - liveSession.worktreePath = input.worktreePath ?? null; - liveSession.history = ""; - liveSession.pendingHistoryControlSequence = ""; - liveSession.pendingProcessEvents = []; - liveSession.pendingProcessEventIndex = 0; - liveSession.processEventDrainRunning = false; - yield* persistHistory( - liveSession.threadId, - liveSession.terminalId, - liveSession.history, - ); - } - - if (!liveSession.process) { - yield* startSession( - liveSession, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: liveSession.worktreePath, - cols: targetCols, - rows: targetRows, - ...(input.env ? { env: input.env } : {}), - }, - "started", - ); - return snapshot(liveSession); - } - - if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { - liveSession.cols = targetCols; - liveSession.rows = targetRows; - liveSession.updatedAt = yield* nowIso; - liveSession.process.resize(targetCols, targetRows); - } - - return snapshot(liveSession); - }), - ); - - const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) { - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - const session = yield* requireSession(input.threadId, terminalId); - const process = session.process; - if (!process || session.status !== "running") { - if (session.status === "exited") return; - return yield* new TerminalNotRunningError({ - threadId: input.threadId, - terminalId, - }); - } - yield* Effect.sync(() => process.write(input.data)); - }); - - const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) { - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - const session = yield* requireSession(input.threadId, terminalId); - const process = session.process; - if (!process || session.status !== "running") { - return yield* new TerminalNotRunningError({ - threadId: input.threadId, - terminalId, - }); - } - session.cols = input.cols; - session.rows = input.rows; - session.updatedAt = yield* nowIso; - yield* Effect.sync(() => process.resize(input.cols, input.rows)); - }); - - const clear: TerminalManagerShape["clear"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - const session = yield* requireSession(input.threadId, terminalId); - const updatedAt = yield* nowIso; - session.history = ""; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.updatedAt = updatedAt; - yield* persistHistory(input.threadId, terminalId, session.history); - const createdAt = yield* nowIso; - yield* publishEvent({ - type: "cleared", - threadId: input.threadId, - terminalId, - createdAt, - }); - }), - ); - - const restart: TerminalManagerShape["restart"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - const createdAt = yield* nowIso; - session = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history: "", - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: createdAt, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } - - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; - - session.history = ""; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }), - ); - - const close: TerminalManagerShape["close"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - if (input.terminalId) { - yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true); - return; - } - - const threadSessions = yield* sessionsForThread(input.threadId); - yield* Effect.forEach( - threadSessions, - (session) => closeSession(input.threadId, session.terminalId, false), - { discard: true }, - ); - - if (input.deleteHistory) { - yield* deleteAllHistoryForThread(input.threadId); - } - }), - ); - - return { - open, - write, - resize, - clear, - restart, - close, - subscribe: (listener) => - Effect.sync(() => { - terminalEventListeners.add(listener); - return () => { - terminalEventListeners.delete(listener); - }; - }), - } satisfies TerminalManagerShape; - }, -); - -export const TerminalManagerLive = Layer.effect(TerminalManager, makeTerminalManager()).pipe( - Layer.provide(ProcessRunner.layer), -); diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts deleted file mode 100644 index 15d24360..00000000 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Effect from "effect/Effect"; -import { assert, it } from "@effect/vitest"; - -import { ensureNodePtySpawnHelperExecutable } from "./NodePTY.ts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; - -it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { - it.effect("adds executable bits when helper exists but is not executable", () => - Effect.gen(function* () { - if (process.platform === "win32") return; - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); - const helperPath = path.join(dir, "spawn-helper"); - yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(helperPath, 0o644); - - yield* ensureNodePtySpawnHelperExecutable(helperPath); - - const mode = (yield* fs.stat(helperPath)).mode & 0o777; - assert.equal(mode & 0o111, 0o111); - }), - ); - - it.effect("keeps executable helper as executable", () => - Effect.gen(function* () { - if (process.platform === "win32") return; - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); - const helperPath = path.join(dir, "spawn-helper"); - yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(helperPath, 0o755); - - yield* ensureNodePtySpawnHelperExecutable(helperPath); - - const mode = (yield* fs.stat(helperPath)).mode & 0o777; - assert.equal(mode & 0o111, 0o111); - }), - ); -}); diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts deleted file mode 100644 index c81d76f5..00000000 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { createRequire } from "node:module"; - -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import { PtyAdapter } from "../Services/PTY.ts"; -import { - PtySpawnError, - type PtyAdapterShape, - type PtyExitEvent, - type PtyProcess, -} from "../Services/PTY.ts"; - -let didEnsureSpawnHelperExecutable = false; - -const resolveNodePtySpawnHelperPath = Effect.gen(function* () { - const requireForNodePty = createRequire(import.meta.url); - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - - const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); - const packageDir = path.dirname(packageJsonPath); - const candidates = [ - path.join(packageDir, "build", "Release", "spawn-helper"), - path.join(packageDir, "build", "Debug", "spawn-helper"), - path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), - ]; - - for (const candidate of candidates) { - if (yield* fs.exists(candidate)) { - return candidate; - } - } - return null; -}).pipe(Effect.orElseSucceed(() => null)); - -export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) { - const fs = yield* FileSystem.FileSystem; - if (process.platform === "win32") return; - if (!explicitPath && didEnsureSpawnHelperExecutable) return; - - const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath); - if (!helperPath) return; - if (!explicitPath) { - didEnsureSpawnHelperExecutable = true; - } - - if (!(yield* fs.exists(helperPath))) { - return; - } - - // Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing. - yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); -}); - -class NodePtyProcess implements PtyProcess { - private readonly process: import("node-pty").IPty; - - constructor(process: import("node-pty").IPty) { - this.process = process; - } - - get pid(): number { - return this.process.pid; - } - - write(data: string): void { - this.process.write(data); - } - - resize(cols: number, rows: number): void { - this.process.resize(cols, rows); - } - - kill(signal?: string): void { - this.process.kill(signal); - } - - onData(callback: (data: string) => void): () => void { - const disposable = this.process.onData(callback); - return () => { - disposable.dispose(); - }; - } - - onExit(callback: (event: PtyExitEvent) => void): () => void { - const disposable = this.process.onExit((event) => { - callback({ - exitCode: event.exitCode, - signal: event.signal ?? null, - }); - }); - return () => { - disposable.dispose(); - }; - } -} - -export const layer = Layer.effect( - PtyAdapter, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const nodePty = yield* Effect.promise(() => import("node-pty")); - - const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( - ensureNodePtySpawnHelperExecutable().pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.orElseSucceed(() => undefined), - ), - ); - - return { - spawn: Effect.fn(function* (input) { - yield* ensureNodePtySpawnHelperExecutableCached; - const ptyProcess = yield* Effect.try({ - try: () => - nodePty.spawn(input.shell, input.args ?? [], { - cwd: input.cwd, - cols: input.cols, - rows: input.rows, - env: input.env, - name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", - }), - catch: (cause) => - new PtySpawnError({ - adapter: "node-pty", - message: cause instanceof Error ? cause.message : "Failed to spawn PTY process", - cause, - }), - }); - return new NodePtyProcess(ptyProcess); - }), - } satisfies PtyAdapterShape; - }), -); diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts deleted file mode 100644 index 4661dd08..00000000 --- a/apps/server/src/terminal/Services/Manager.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * TerminalManager - Terminal session orchestration service interface. - * - * Owns terminal lifecycle operations, output fanout, and session state - * transitions for thread-scoped terminals. - * - * @module TerminalManager - */ -import { - TerminalClearInput, - TerminalCloseInput, - TerminalEvent, - TerminalCwdError, - TerminalError, - TerminalHistoryError, - TerminalNotRunningError, - TerminalOpenInput, - TerminalResizeInput, - TerminalRestartInput, - TerminalSessionSnapshot, - TerminalSessionLookupError, - TerminalSessionStatus, - TerminalWriteInput, -} from "@cafecode/contracts"; -import type { PtyProcess } from "./PTY.ts"; -import * as Effect from "effect/Effect"; -import * as Context from "effect/Context"; - -export { - TerminalCwdError, - TerminalError, - TerminalHistoryError, - TerminalNotRunningError, - TerminalSessionLookupError, -}; - -export interface TerminalSessionState { - threadId: string; - terminalId: string; - cwd: string; - worktreePath: string | null; - status: TerminalSessionStatus; - pid: number | null; - history: string; - pendingHistoryControlSequence: string; - exitCode: number | null; - exitSignal: number | null; - updatedAt: string; - cols: number; - rows: number; - process: PtyProcess | null; - unsubscribeData: (() => void) | null; - unsubscribeExit: (() => void) | null; - hasRunningSubprocess: boolean; - runtimeEnv: Record | null; -} - -export interface ShellCandidate { - shell: string; - args?: string[]; -} - -export interface TerminalStartInput extends TerminalOpenInput { - cols: number; - rows: number; -} - -/** - * TerminalManagerShape - Service API for terminal session lifecycle operations. - */ -export interface TerminalManagerShape { - /** - * Open or attach to a terminal session. - * - * Reuses an existing session for the same thread/terminal id and restores - * persisted history on first open. - */ - readonly open: ( - input: TerminalOpenInput, - ) => Effect.Effect; - - /** - * Write input bytes to a terminal session. - */ - readonly write: (input: TerminalWriteInput) => Effect.Effect; - - /** - * Resize the PTY backing a terminal session. - */ - readonly resize: (input: TerminalResizeInput) => Effect.Effect; - - /** - * Clear terminal output history. - */ - readonly clear: (input: TerminalClearInput) => Effect.Effect; - - /** - * Restart a terminal session in place. - * - * Always resets history before spawning the new process. - */ - readonly restart: ( - input: TerminalRestartInput, - ) => Effect.Effect; - - /** - * Close an active terminal session. - * - * When `terminalId` is omitted, closes all sessions for the thread. - */ - readonly close: (input: TerminalCloseInput) => Effect.Effect; - - /** - * Subscribe to terminal runtime events with a direct callback. - * - * Returns an unsubscribe function. - */ - readonly subscribe: ( - listener: (event: TerminalEvent) => Effect.Effect, - ) => Effect.Effect<() => void>; -} - -/** - * TerminalManager - Service tag for terminal session orchestration. - */ -export class TerminalManager extends Context.Service()( - "cafecode/terminal/Services/Manager/TerminalManager", -) {} diff --git a/apps/server/src/terminal/Services/PTY.ts b/apps/server/src/terminal/Services/PTY.ts deleted file mode 100644 index 136b1e7d..00000000 --- a/apps/server/src/terminal/Services/PTY.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * PtyAdapter - Terminal PTY adapter service contract. - * - * Defines the process primitives required by terminal session management - * without binding to a specific PTY implementation. - * - * @module PtyAdapter - */ -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; - -/** - * PtyError - Error type for PTY adapter operations. - */ -export class PtySpawnError extends Schema.TaggedErrorClass()("PtySpawnError", { - adapter: Schema.String, - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - -export interface PtyExitEvent { - exitCode: number; - signal: number | null; -} - -export interface PtyProcess { - readonly pid: number; - write(data: string): void; - resize(cols: number, rows: number): void; - kill(signal?: string): void; - onData(callback: (data: string) => void): () => void; - onExit(callback: (event: PtyExitEvent) => void): () => void; -} - -export interface PtySpawnInput { - shell: string; - args?: string[]; - cwd: string; - cols: number; - rows: number; - env: NodeJS.ProcessEnv; -} - -/** - * PtyAdapterShape - Service API for spawning and controlling PTY processes. - */ -export interface PtyAdapterShape { - /** - * Spawn a PTY process for a terminal session. - */ - spawn(input: PtySpawnInput): Effect.Effect; -} - -/** - * PtyAdapter - Service tag for PTY process integration. - */ -export class PtyAdapter extends Context.Service()( - "cafecode/terminal/Services/PTY/PtyAdapter", -) {} diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 785d5418..9cc6d181 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -180,6 +180,10 @@ export interface GitVcsDriverShape { cwd: string, baseRef: string, ) => Effect.Effect; + readonly workingTreeDiff: ( + cwd: string, + options?: { readonly ignoreWhitespace?: boolean }, + ) => Effect.Effect; readonly readConfigValue: ( cwd: string, key: string, @@ -639,7 +643,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( yield* execute({ operation, cwd: input.cwd, - args: ["add", "-A", "--", "."], + args: ["add", "-u", "--", "."], env: commitEnv, }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index c82ba2cc..d68e0b8d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -37,11 +37,14 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; -const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); -const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); -const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); +const WORKING_TREE_DIFF_MAX_OUTPUT_BYTES = 10_000_000; +const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.minutes(1); +const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(2); +const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.minutes(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ + GCM_INTERACTIVE: "never", + GIT_TERMINAL_PROMPT: "0", SSH_ASKPASS_REQUIRE: "never", } satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; @@ -610,6 +613,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig; + const statusUpstreamFetchSemaphore = yield* Semaphore.make(1); const executeRaw: GitVcsDriver.GitVcsDriverShape["execute"] = Effect.fnUntraced( function* (input) { @@ -869,16 +873,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ): Effect.Effect => { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return executeGit( - "GitVcsDriver.fetchRemoteForStatus", - fetchCwd, - ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], - { - allowNonZeroExit: true, - env: STATUS_UPSTREAM_REFRESH_ENV, - timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), - }, - ).pipe(Effect.asVoid); + return statusUpstreamFetchSemaphore.withPermit( + executeGit( + "GitVcsDriver.fetchRemoteForStatus", + fetchCwd, + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], + { + allowNonZeroExit: true, + env: STATUS_UPSTREAM_REFRESH_ENV, + timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), + }, + ).pipe(Effect.asVoid), + ); }; const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { @@ -1630,6 +1636,51 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); + const workingTreeDiff: GitVcsDriver.GitVcsDriverShape["workingTreeDiff"] = Effect.fn( + "workingTreeDiff", + )(function* (cwd, options) { + const headResult = yield* executeGit( + "GitVcsDriver.workingTreeDiff.resolveHead", + cwd, + ["rev-parse", "--verify", "--quiet", "HEAD"], + { + allowNonZeroExit: true, + maxOutputBytes: 64 * 1024, + }, + ); + const hasHead = headResult.exitCode === 0; + const result = yield* executeGit( + "GitVcsDriver.workingTreeDiff.diff", + cwd, + [ + "diff", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--minimal", + ...(options?.ignoreWhitespace ? ["--ignore-all-space"] : []), + ...(hasHead ? ["HEAD"] : []), + "--", + ".", + ], + { + allowNonZeroExit: true, + maxOutputBytes: WORKING_TREE_DIFF_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + if (result.exitCode !== 0) { + return yield* createGitCommandError( + "GitVcsDriver.workingTreeDiff.diff", + cwd, + ["diff", "--patch", hasHead ? "HEAD" : "--", "."], + result.stderr.trim() || "git diff failed", + ); + } + return result.stdout; + }); + const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) => runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( Effect.map((stdout) => stdout.trim()), @@ -2124,6 +2175,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* pushCurrentBranch, pullCurrentBranch, readRangeContext, + workingTreeDiff, readConfigValue, listRefs, createWorktree, diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 3d1e7915..e0fd2885 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -19,10 +19,10 @@ describe("VcsProjectConfig", () => { const config = yield* VcsProjectConfig.VcsProjectConfig; const kind = yield* config.resolveKind({ cwd: "/repo", - requestedKind: "jj", + requestedKind: "git", }); - assert.equal(kind, "jj"); + assert.equal(kind, "git"); }), ); }); @@ -42,13 +42,13 @@ describe("VcsProjectConfig", () => { yield* fileSystem.writeFileString( path.join(configDir, "vcs.json"), // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ vcs: { kind: "jj" } }), + JSON.stringify({ vcs: { kind: "git" } }), ); const config = yield* VcsProjectConfig.VcsProjectConfig; const kind = yield* config.resolveKind({ cwd: nested }); - assert.equal(kind, "jj"); + assert.equal(kind, "git"); }), ); }); @@ -65,6 +65,32 @@ describe("VcsProjectConfig", () => { const nested = path.join(root, "packages", "app"); yield* fileSystem.makeDirectory(configDir, { recursive: true }); yield* fileSystem.makeDirectory(nested, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(configDir, "vcs.json"), + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ vcs: { kind: "git" } }), + ); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: nested }); + + assert.equal(kind, "git"); + }), + ); + }); + + it.layer(TestLayer)("ignores unsupported VCS config values", (it) => { + it.effect("falls back to auto", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "cafecode-vcs-config-test-", + }); + const configDir = path.join(root, ".cafecode"); + const nested = path.join(root, "packages", "app"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.makeDirectory(nested, { recursive: true }); yield* fileSystem.writeFileString( path.join(configDir, "vcs.json"), // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -74,7 +100,7 @@ describe("VcsProjectConfig", () => { const config = yield* VcsProjectConfig.VcsProjectConfig; const kind = yield* config.resolveKind({ cwd: nested }); - assert.equal(kind, "jj"); + assert.equal(kind, "auto"); }), ); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 3c33f2c9..4347b602 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -310,6 +310,69 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("defaults streamed status to an occasional remote refresh", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const remoteUpdated = yield* Deferred.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "remoteUpdated" + ? Deferred.succeed(remoteUpdated, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkScoped); + + assert.deepStrictEqual(yield* Deferred.await(remoteUpdated), { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + } satisfies VcsStatusStreamEvent); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localInvalidationCalls, 0); + assert.equal(state.remoteInvalidationCalls, 1); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("starts automatic remote refreshes only for positive intervals", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const remoteUpdated = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(5)) }, + ), + (event) => + event._tag === "remoteUpdated" + ? Deferred.succeed(remoteUpdated, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkScoped); + + assert.deepStrictEqual(yield* Deferred.await(remoteUpdated), { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + } satisfies VcsStatusStreamEvent); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 1); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { assert.equal( Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), @@ -333,6 +396,25 @@ describe("VcsStatusBroadcaster", () => { ); }); + it("slows successful remote polling down when status reads are expensive", () => { + assert.equal( + Duration.toMillis(VcsStatusBroadcaster.remoteRefreshSuccessDelay(500, Duration.minutes(5))), + 300_000, + ); + assert.equal( + Duration.toMillis( + VcsStatusBroadcaster.remoteRefreshSuccessDelay(10_000, Duration.seconds(5)), + ), + 50_000, + ); + assert.equal( + Duration.toMillis( + VcsStatusBroadcaster.remoteRefreshSuccessDelay(600_000, Duration.seconds(5)), + ), + 900_000, + ); + }); + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index e4690646..922c9f0c 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -23,9 +24,11 @@ import { mergeGitStatusParts } from "@cafecode/shared/git"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; -const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); +const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.minutes(5); +const DISABLED_REMOTE_REFRESH_RECHECK_INTERVAL = Duration.minutes(5); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); +const VCS_STATUS_REFRESH_SLOW_CALL_MULTIPLIER = 5; interface VcsStatusChange { readonly cwd: string; @@ -65,6 +68,19 @@ export function remoteRefreshFailureDelay( return Duration.max(configuredInterval, cappedBackoff); } +export function remoteRefreshSuccessDelay( + elapsedMs: number, + configuredInterval: Duration.Duration, +) { + const slowCallDelay = Duration.millis( + Math.max(0, elapsedMs * VCS_STATUS_REFRESH_SLOW_CALL_MULTIPLIER), + ); + return Duration.max( + configuredInterval, + Duration.min(slowCallDelay, VCS_STATUS_REFRESH_FAILURE_MAX_DELAY), + ); +} + export interface VcsStatusBroadcasterShape { readonly getStatus: ( input: VcsStatusInput, @@ -262,24 +278,34 @@ export const layer = Layer.effect( const consecutiveFailuresRef = yield* Ref.make(0); const refreshRemoteStatusIfEnabled = Effect.gen(function* () { const configuredInterval = yield* automaticRemoteRefreshInterval; - const activeInterval = Duration.isZero(configuredInterval) - ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL - : configuredInterval; if (Duration.isZero(configuredInterval)) { - return activeInterval; + return DISABLED_REMOTE_REFRESH_RECHECK_INTERVAL; } + const startedAtMs = yield* Clock.currentTimeMillis; const exit = yield* refreshRemoteStatus(cwd).pipe(Effect.exit); + const elapsedMs = Math.max(0, (yield* Clock.currentTimeMillis) - startedAtMs); if (Exit.isSuccess(exit)) { yield* Ref.set(consecutiveFailuresRef, 0); - return activeInterval; + const nextDelay = remoteRefreshSuccessDelay(elapsedMs, configuredInterval); + if (Duration.toMillis(nextDelay) > Duration.toMillis(configuredInterval)) { + yield* Effect.logDebug("VCS remote status refresh was slow; delaying next poll", { + cwd, + elapsedMs, + nextDelayMs: Duration.toMillis(nextDelay), + }); + } + return nextDelay; } const consecutiveFailures = yield* Ref.updateAndGet( consecutiveFailuresRef, (count) => count + 1, ); - const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); + const nextDelay = Duration.max( + remoteRefreshFailureDelay(consecutiveFailures, configuredInterval), + remoteRefreshSuccessDelay(elapsedMs, configuredInterval), + ); yield* Effect.logWarning("VCS remote status refresh failed", { cwd, detail: exit.cause.toString(), @@ -364,13 +390,17 @@ export const layer = Layer.effect( const subscription = yield* PubSub.subscribe(changesPubSub); const initialLocal = yield* getOrLoadLocalStatus(cwd); const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null; - yield* retainRemotePoller( - cwd, + const automaticRemoteRefreshInterval = options?.automaticRemoteRefreshInterval ?? - Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), - ); + Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL); + const shouldStartRemotePoller = !Duration.isZero(yield* automaticRemoteRefreshInterval); + if (shouldStartRemotePoller) { + yield* retainRemotePoller(cwd, automaticRemoteRefreshInterval); + } - const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); + const release = shouldStartRemotePoller + ? releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid) + : Effect.void; return Stream.concat( Stream.make({ diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index e748a27a..8b93bc0d 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -136,5 +136,67 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { expect(escapedStat).toBeNull(); }), ); + + it.effect("rejects writes through symlinked directories outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const outside = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + + yield* fileSystem.symlink(outside, path.join(cwd, "linked")); + + const error = yield* workspaceFileSystem + .writeFile({ + cwd, + relativePath: "linked/nested/escape.md", + contents: "# nope\n", + }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: linked/nested/escape.md", + ); + + const escapedFileStat = yield* fileSystem + .stat(path.join(outside, "nested", "escape.md")) + .pipe(Effect.catch(() => Effect.succeed(null))); + const escapedDirectoryStat = yield* fileSystem + .stat(path.join(outside, "nested")) + .pipe(Effect.catch(() => Effect.succeed(null))); + expect(escapedFileStat).toBeNull(); + expect(escapedDirectoryStat).toBeNull(); + }), + ); + + it.effect("rejects writes to symlinked files outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const outside = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const outsideFile = path.join(outside, "escape.md"); + + yield* fileSystem.writeFileString(outsideFile, "original\n"); + yield* fileSystem.symlink(outsideFile, path.join(cwd, "escape.md")); + + const error = yield* workspaceFileSystem + .writeFile({ + cwd, + relativePath: "escape.md", + contents: "modified\n", + }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: escape.md", + ); + + const outsideContents = yield* fileSystem.readFileString(outsideFile).pipe(Effect.orDie); + expect(outsideContents).toBe("original\n"); + }), + ); }); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts index 9f53ade1..8bf822f0 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; import { WorkspaceFileSystem, @@ -9,14 +10,135 @@ import { type WorkspaceFileSystemShape, } from "../Services/WorkspaceFileSystem.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; +import { WorkspacePathOutsideRootError } from "../Services/WorkspacePaths.ts"; import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; +function isInsideOrEqualRoot(path: Path.Path, root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative.length === 0 || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); +} + +function isNotFoundError(error: PlatformError.PlatformError): boolean { + return error.reason._tag === "NotFound"; +} + export const makeWorkspaceFileSystem = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries; + const realPathOrFileSystemError = Effect.fn("WorkspaceFileSystem.realPath")(function* ( + cwd: string, + relativePath: string, + absolutePath: string, + operation: string, + ) { + return yield* fileSystem.realPath(absolutePath).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemError({ + cwd, + relativePath, + operation, + detail: cause.message, + cause, + }), + ), + ); + }); + + const realPathIfExists = Effect.fn("WorkspaceFileSystem.realPathIfExists")(function* ( + cwd: string, + relativePath: string, + absolutePath: string, + ) { + return yield* fileSystem.realPath(absolutePath).pipe( + Effect.map((realPath) => realPath as string | null), + Effect.catch((cause) => + isNotFoundError(cause) + ? Effect.succeed(null) + : Effect.fail( + new WorkspaceFileSystemError({ + cwd, + relativePath, + operation: "workspaceFileSystem.realPathIfExists", + detail: cause.message, + cause, + }), + ), + ), + ); + }); + + const nearestExistingRealPath = Effect.fn("WorkspaceFileSystem.nearestExistingRealPath")( + function* (input: { + readonly cwd: string; + readonly relativePath: string; + readonly absolutePath: string; + }) { + let currentPath = input.absolutePath; + while (true) { + const realPath = yield* realPathIfExists(input.cwd, input.relativePath, currentPath); + if (realPath !== null) { + return realPath; + } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return yield* new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "workspaceFileSystem.nearestExistingRealPath", + detail: `No existing ancestor found for ${input.absolutePath}`, + }); + } + currentPath = parentPath; + } + }, + ); + + const guardWriteTargetWithinRealRoot = Effect.fn( + "WorkspaceFileSystem.guardWriteTargetWithinRealRoot", + )(function* (input: { + readonly cwd: string; + readonly relativePath: string; + readonly absolutePath: string; + }) { + const rootRealPath = yield* realPathOrFileSystemError( + input.cwd, + input.relativePath, + input.cwd, + "workspaceFileSystem.realPathRoot", + ); + const ancestorRealPath = yield* nearestExistingRealPath({ + cwd: input.cwd, + relativePath: input.relativePath, + absolutePath: path.dirname(input.absolutePath), + }); + if (!isInsideOrEqualRoot(path, rootRealPath, ancestorRealPath)) { + return yield* new WorkspacePathOutsideRootError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + } + + const targetRealPath = yield* realPathIfExists( + input.cwd, + input.relativePath, + input.absolutePath, + ); + if (targetRealPath !== null && !isInsideOrEqualRoot(path, rootRealPath, targetRealPath)) { + return yield* new WorkspacePathOutsideRootError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + } + }); + const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn( "WorkspaceFileSystem.writeFile", )(function* (input) { @@ -25,6 +147,11 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { relativePath: input.relativePath, }); + yield* guardWriteTargetWithinRealRoot({ + cwd: input.cwd, + relativePath: input.relativePath, + absolutePath: target.absolutePath, + }); yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( Effect.mapError( (cause) => @@ -37,6 +164,11 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { }), ), ); + yield* guardWriteTargetWithinRealRoot({ + cwd: input.cwd, + relativePath: input.relativePath, + absolutePath: target.absolutePath, + }); yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2c17fa5c..a2d5ad08 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -4,32 +4,25 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import { type AuthAccessStreamEvent, AuthSessionId } from "@cafecode/contracts/auth"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, - type AuthAccessStreamEvent, - AuthSessionId, CommandId, EventId, type OrchestrationCommand, - type GitActionProgressEvent, - type GitManagerServiceError, OrchestrationDispatchCommandError, type OrchestrationEvent, type OrchestrationShellStreamEvent, - OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, - OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, ProjectSearchEntriesError, ProjectWriteFileError, OrchestrationReplayEventsError, FilesystemBrowseError, ThreadId, - type TerminalEvent, WS_METHODS, WsRpcGroup, } from "@cafecode/contracts"; @@ -37,7 +30,6 @@ import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { ServerConfig } from "./config.ts"; import { Keybindings } from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; @@ -54,7 +46,6 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; -import { TerminalManager } from "./terminal/Services/Manager.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; @@ -79,6 +70,7 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; +import { hardDeleteThreadLocalData } from "./orchestration/threadHardDelete.ts"; import { BootstrapCredentialService, type BootstrapCredentialChange, @@ -162,13 +154,11 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; - const checkpointDiffQuery = yield* CheckpointDiffQuery; const keybindings = yield* Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const gitWorkflow = yield* GitWorkflowService; const vcsProvisioning = yield* VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; - const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig; @@ -408,55 +398,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ); }; - const recordSetupScriptStarted = (input: { - readonly requestedAt: string; - readonly worktreePath: string; - readonly scriptId: string; - readonly scriptName: string; - readonly terminalId: string; - }) => - Effect.gen(function* () { - const startedAt = yield* nowIso; - const payload = { - scriptId: input.scriptId, - scriptName: input.scriptName, - terminalId: input.terminalId, - worktreePath: input.worktreePath, - }; - yield* Effect.all([ - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.requested", - summary: "Starting setup script", - createdAt: input.requestedAt, - payload, - tone: "info", - }), - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.started", - summary: "Setup script started", - createdAt: startedAt, - payload, - tone: "info", - }), - ]).pipe( - Effect.asVoid, - Effect.catch((error) => - Effect.logWarning( - "bootstrap turn start launched setup script but failed to record setup activity", - { - threadId: command.threadId, - worktreePath: input.worktreePath, - scriptId: input.scriptId, - terminalId: input.terminalId, - detail: error.message, - }, - ), - ), - ); - }); - const runSetupProgram = () => Effect.gen(function* () { if (!bootstrap?.runSetupScript || !targetWorktreePath) { @@ -479,18 +420,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => requestedAt, worktreePath, }), - onSuccess: (setupResult) => { - if (setupResult.status !== "started") { - return Effect.void; - } - return recordSetupScriptStarted({ - requestedAt, - worktreePath, - scriptId: setupResult.scriptId, - scriptName: setupResult.scriptName, - terminalId: setupResult.terminalId, - }); - }, + onSuccess: () => Effect.void, }), ); }); @@ -649,15 +579,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), ); } - - yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: normalizedCommand.threadId, - error: error.message, - }), - ), - ); } return result; }).pipe( @@ -672,34 +593,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "orchestration" }, ), - [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getTurnDiff, - checkpointDiffQuery.getTurnDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetTurnDiffError({ - message: "Failed to load turn diff", - cause, - }), - ), - ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getFullThreadDiff, - checkpointDiffQuery.getFullThreadDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetFullThreadDiffError({ - message: "Failed to load full thread diff", - cause, - }), - ), - ), - { "rpc.aggregate": "orchestration" }, - ), [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.replayEvents, @@ -774,6 +667,37 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.getDeletedShellSnapshot]: (_input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getDeletedShellSnapshot, + projectionSnapshotQuery.getDeletedShellSnapshot().pipe( + Effect.tapError((cause) => + Effect.logError("orchestration deleted shell snapshot load failed", { cause }), + ), + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load deleted orchestration shell snapshot", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), + [ORCHESTRATION_WS_METHODS.hardDeleteThread]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.hardDeleteThread, + hardDeleteThreadLocalData(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to permanently delete thread", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeThread, @@ -939,16 +863,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => "rpc.aggregate": "source-control", }, ), - [WS_METHODS.sourceControlPublishRepository]: (input) => - observeRpcEffect( - WS_METHODS.sourceControlPublishRepository, - sourceControlRepositories - .publishRepository(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { - "rpc.aggregate": "source-control", - }, - ), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, @@ -1015,6 +929,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => "rpc.aggregate": "vcs", }, ), + [WS_METHODS.vcsWorkingTreeDiff]: (input) => + observeRpcEffect(WS_METHODS.vcsWorkingTreeDiff, gitWorkflow.workingTreeDiff(input), { + "rpc.aggregate": "vcs", + }), [WS_METHODS.vcsPull]: (input) => observeRpcEffect( WS_METHODS.vcsPull, @@ -1027,29 +945,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "git" }, ), - [WS_METHODS.gitRunStackedAction]: (input) => - observeRpcStream( - WS_METHODS.gitRunStackedAction, - Stream.callback((queue) => - gitWorkflow - .runStackedAction(input, { - actionId: input.actionId, - progressReporter: { - publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), - }, - }) - .pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => - refreshGitStatus(input.cwd).pipe( - Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), - ), - }), - ), - ), - { "rpc.aggregate": "vcs" }, - ), [WS_METHODS.gitResolvePullRequest]: (input) => observeRpcEffect( WS_METHODS.gitResolvePullRequest, @@ -1102,41 +997,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.terminalOpen]: (input) => - observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalWrite]: (input) => - observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalResize]: (input) => - observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalClear]: (input) => - observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalRestart]: (input) => - observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalClose]: (input) => - observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.subscribeTerminalEvents]: (_input) => - observeRpcStream( - WS_METHODS.subscribeTerminalEvents, - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.subscribe((event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ), - ), - { "rpc.aggregate": "terminal" }, - ), [WS_METHODS.subscribeServerConfig]: (_input) => observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, diff --git a/apps/web/index.html b/apps/web/index.html index 84117ce4..0abf82e8 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -18,8 +18,8 @@ const themeColorMeta = document.querySelector('meta[name="theme-color"]'); try { const storedTheme = - window.localStorage.getItem("cafecode:theme") || - window.localStorage.getItem("t3code:theme"); + window.localStorage.getItem("cafe-code:theme") || + window.localStorage.getItem("cafecode:theme"); const theme = storedTheme === "light" || storedTheme === "dark" || storedTheme === "system" ? storedTheme diff --git a/apps/web/package.json b/apps/web/package.json index a884d420..a4df6a5d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@cafecode/web", - "version": "0.0.24", + "version": "0.0.25", "private": true, "license": "AGPL-3.0-or-later", "type": "module", @@ -45,20 +45,20 @@ }, "devDependencies": { "@effect/language-service": "catalog:", - "@rolldown/plugin-babel": "^0.2.0", - "@tailwindcss/vite": "^4.0.0", - "@tanstack/router-plugin": "^1.161.0", + "@rolldown/plugin-babel": "^0.2.3", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/router-plugin": "^1.168.10", "@types/babel__core": "^7.20.5", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", - "@vitejs/plugin-react": "^6.0.0", - "@vitest/browser-playwright": "^4.0.18", + "@vitejs/plugin-react": "^6.0.2", + "@vitest/browser-playwright": "^4.1.7", "babel-plugin-react-compiler": "1.0.0", "msw": "2.12.11", "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "catalog:", - "vite": "^8.0.0", + "vite": "^8.0.14", "vitest": "catalog:", "vitest-browser-react": "^2.0.5" } diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png index e0e1b965..4205c869 100644 Binary files a/apps/web/public/apple-touch-icon.png and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/cafe-code-sidebar-icon.png b/apps/web/public/cafe-code-sidebar-icon.png new file mode 100644 index 00000000..fff28c9e Binary files /dev/null and b/apps/web/public/cafe-code-sidebar-icon.png differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png index 673d8459..5c0a654e 100644 Binary files a/apps/web/public/favicon-16x16.png and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png index 25bcc95d..148598d7 100644 Binary files a/apps/web/public/favicon-32x32.png and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico index 36975b99..8391ba29 100644 Binary files a/apps/web/public/favicon.ico and b/apps/web/public/favicon.ico differ diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index d28c1291..e6567800 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -40,11 +40,14 @@ function createLocalStorageStub(): Storage { function getTestWindow(): Window & typeof globalThis { const localStorage = createLocalStorageStub(); + const sessionStorage = createLocalStorageStub(); const testWindow = { localStorage, + sessionStorage, } as Window & typeof globalThis; vi.stubGlobal("window", testWindow); vi.stubGlobal("localStorage", localStorage); + vi.stubGlobal("sessionStorage", sessionStorage); return testWindow; } @@ -92,10 +95,11 @@ describe("clientPersistenceStorage", () => { ).toBeNull(); }); - it("stores browser secrets inline with the saved environment record", async () => { + it("stores browser secrets in sessionStorage without writing bearer material to localStorage", async () => { const testWindow = getTestWindow(); const { SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, + SAVED_ENVIRONMENT_SESSION_SECRETS_STORAGE_KEY, readBrowserSavedEnvironmentRegistry, readBrowserSavedEnvironmentSecret, writeBrowserSavedEnvironmentRegistry, @@ -112,12 +116,15 @@ describe("clientPersistenceStorage", () => { JSON.parse(testWindow.localStorage.getItem(SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY)!), ).toEqual({ version: 1, - records: [ - { - ...savedRegistryRecord, - bearerToken: "bearer-token", - }, - ], + records: [savedRegistryRecord], + }); + expect( + JSON.parse(testWindow.sessionStorage.getItem(SAVED_ENVIRONMENT_SESSION_SECRETS_STORAGE_KEY)!), + ).toEqual({ + version: 1, + secrets: { + [testEnvironmentId]: "bearer-token", + }, }); }); }); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index ae552238..987c248f 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -12,10 +12,13 @@ import { setLocalStorageItemWithLegacy, } from "./hooks/useLocalStorage"; -export const CLIENT_SETTINGS_STORAGE_KEY = "cafecode:client-settings:v1"; -export const LEGACY_CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; -export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "cafecode:saved-environment-registry:v1"; -export const LEGACY_SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; +export const CLIENT_SETTINGS_STORAGE_KEY = "cafe-code:client-settings:v1"; +export const LEGACY_CLIENT_SETTINGS_STORAGE_KEY = "cafecode:client-settings:v1"; +export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "cafe-code:saved-environment-registry:v1"; +export const LEGACY_SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = + "cafecode:saved-environment-registry:v1"; +export const SAVED_ENVIRONMENT_SESSION_SECRETS_STORAGE_KEY = + "cafe-code:saved-environment-session-secrets:v1"; const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ environmentId: EnvironmentId, @@ -32,10 +35,28 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ port: Schema.NullOr(Schema.Number), }), ), - bearerToken: Schema.optionalKey(Schema.String), }); type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; +const LegacyBrowserSavedEnvironmentRecordSchema = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + createdAt: Schema.String, + lastConnectedAt: Schema.NullOr(Schema.String), + desktopSsh: Schema.optionalKey( + Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), + }), + ), + bearerToken: Schema.optionalKey(Schema.String), +}); +type LegacyBrowserSavedEnvironmentRecord = typeof LegacyBrowserSavedEnvironmentRecordSchema.Type; + const BrowserSavedEnvironmentRegistryDocumentSchema = Schema.Struct({ version: Schema.optionalKey(Schema.Number), records: Schema.optionalKey(Schema.Array(BrowserSavedEnvironmentRecordSchema)), @@ -43,6 +64,23 @@ const BrowserSavedEnvironmentRegistryDocumentSchema = Schema.Struct({ type BrowserSavedEnvironmentRegistryDocument = typeof BrowserSavedEnvironmentRegistryDocumentSchema.Type; +const LegacyBrowserSavedEnvironmentRegistryDocumentSchema = Schema.Struct({ + version: Schema.optionalKey(Schema.Number), + records: Schema.optionalKey(Schema.Array(LegacyBrowserSavedEnvironmentRecordSchema)), +}); +type LegacyBrowserSavedEnvironmentRegistryDocument = + typeof LegacyBrowserSavedEnvironmentRegistryDocumentSchema.Type; + +const BrowserSavedEnvironmentSessionSecretsSchema = Schema.Struct({ + version: Schema.optionalKey(Schema.Number), + secrets: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), +}); +type BrowserSavedEnvironmentSessionSecrets = + typeof BrowserSavedEnvironmentSessionSecretsSchema.Type; +const decodeBrowserSavedEnvironmentSessionSecrets = Schema.decodeUnknownSync( + BrowserSavedEnvironmentSessionSecretsSchema, +); + function hasWindow(): boolean { return typeof window !== "undefined"; } @@ -61,6 +99,81 @@ function toPersistedSavedEnvironmentRecord( return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; } +function sanitizeLegacySavedEnvironmentRecord( + record: LegacyBrowserSavedEnvironmentRecord, +): BrowserSavedEnvironmentRecord { + return toPersistedSavedEnvironmentRecord(record); +} + +function sanitizeLegacySavedEnvironmentRegistryDocument( + document: LegacyBrowserSavedEnvironmentRegistryDocument, +): BrowserSavedEnvironmentRegistryDocument { + return { + version: 1, + records: (document.records ?? []).map((record) => sanitizeLegacySavedEnvironmentRecord(record)), + }; +} + +function documentContainsLegacyBearerToken( + document: LegacyBrowserSavedEnvironmentRegistryDocument, +): boolean { + return (document.records ?? []).some((record) => record.bearerToken !== undefined); +} + +function getBrowserSessionStorage(): Storage | null { + if (!hasWindow()) { + return null; + } + + try { + return window.sessionStorage; + } catch { + return null; + } +} + +function readBrowserSavedEnvironmentSessionSecrets(): BrowserSavedEnvironmentSessionSecrets { + const storage = getBrowserSessionStorage(); + if (!storage) { + return {}; + } + + const raw = storage.getItem(SAVED_ENVIRONMENT_SESSION_SECRETS_STORAGE_KEY); + if (!raw) { + return {}; + } + + try { + return decodeBrowserSavedEnvironmentSessionSecrets(JSON.parse(raw)); + } catch { + storage.removeItem(SAVED_ENVIRONMENT_SESSION_SECRETS_STORAGE_KEY); + return {}; + } +} + +function writeBrowserSavedEnvironmentSessionSecrets( + document: BrowserSavedEnvironmentSessionSecrets, +): void { + const storage = getBrowserSessionStorage(); + if (!storage) { + return; + } + + const secrets = document.secrets ?? {}; + if (Object.keys(secrets).length === 0) { + storage.removeItem(SAVED_ENVIRONMENT_SESSION_SECRETS_STORAGE_KEY); + return; + } + + storage.setItem( + SAVED_ENVIRONMENT_SESSION_SECRETS_STORAGE_KEY, + JSON.stringify({ + version: 1, + secrets, + } satisfies BrowserSavedEnvironmentSessionSecrets), + ); +} + export function readBrowserClientSettings(): ClientSettings | null { if (!hasWindow()) { return null; @@ -99,9 +212,17 @@ function readBrowserSavedEnvironmentRegistryDocument(): BrowserSavedEnvironmentR const parsed = getLocalStorageItemWithLegacy( SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, [LEGACY_SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY], - BrowserSavedEnvironmentRegistryDocumentSchema, + LegacyBrowserSavedEnvironmentRegistryDocumentSchema, ); - return parsed ?? {}; + if (!parsed) { + return {}; + } + + const sanitized = sanitizeLegacySavedEnvironmentRegistryDocument(parsed); + if (documentContainsLegacyBearerToken(parsed)) { + writeBrowserSavedEnvironmentRegistryDocument(sanitized); + } + return sanitized; } catch { return {}; } @@ -144,78 +265,48 @@ export function readBrowserSavedEnvironmentRegistry(): ReadonlyArray, ): void { - const existing = new Map( - readBrowserSavedEnvironmentRecordsWithSecrets().map( - (record) => [record.environmentId, record] as const, - ), - ); writeBrowserSavedEnvironmentRecords( - records.map((record) => { - const bearerToken = existing.get(record.environmentId)?.bearerToken; - return bearerToken - ? { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - bearerToken, - } - : toPersistedSavedEnvironmentRecord(record); - }), + records.map((record) => toPersistedSavedEnvironmentRecord(record)), ); } export function readBrowserSavedEnvironmentSecret( environmentId: EnvironmentIdValue, ): string | null { - return ( - readBrowserSavedEnvironmentRecordsWithSecrets().find( - (record) => record.environmentId === environmentId, - )?.bearerToken ?? null - ); + readBrowserSavedEnvironmentRegistryDocument(); + return readBrowserSavedEnvironmentSessionSecrets().secrets?.[environmentId] ?? null; } export function writeBrowserSavedEnvironmentSecret( environmentId: EnvironmentIdValue, secret: string, ): boolean { - const document = readBrowserSavedEnvironmentRegistryDocument(); - const records = document.records ?? []; - let found = false; - writeBrowserSavedEnvironmentRegistryDocument({ + const found = (readBrowserSavedEnvironmentRegistryDocument().records ?? []).some( + (record) => record.environmentId === environmentId, + ); + if (!found) { + return false; + } + + const document = readBrowserSavedEnvironmentSessionSecrets(); + writeBrowserSavedEnvironmentSessionSecrets({ version: document.version ?? 1, - records: records.map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - found = true; - const nextRecord = { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - bearerToken: secret, - }; - return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; - }), + secrets: { + ...document.secrets, + [environmentId]: secret, + }, }); - return found; + return true; } export function removeBrowserSavedEnvironmentSecret(environmentId: EnvironmentIdValue): void { const document = readBrowserSavedEnvironmentRegistryDocument(); - writeBrowserSavedEnvironmentRegistryDocument({ - version: document.version ?? 1, - records: (document.records ?? []).map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - return toPersistedSavedEnvironmentRecord(record); - }), + writeBrowserSavedEnvironmentRegistryDocument(document); + + const sessionDocument = readBrowserSavedEnvironmentSessionSecrets(); + const { [environmentId]: _removed, ...remainingSecrets } = sessionDocument.secrets ?? {}; + writeBrowserSavedEnvironmentSessionSecrets({ + version: sessionDocument.version ?? 1, + secrets: remainingSecrets, }); } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index d98f30a1..ca54be84 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -58,7 +58,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index 4e8698d4..3f5d369f 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -4,9 +4,11 @@ import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -const { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({ +const { confirmMock, openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({ + confirmMock: vi.fn(async () => true), openInPreferredEditorMock: vi.fn(async () => "vscode"), readLocalApiMock: vi.fn(() => ({ + dialogs: { confirm: confirmMock }, server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) }, shell: { openInEditor: vi.fn(async () => undefined) }, })), @@ -23,10 +25,11 @@ vi.mock("../localApi", () => ({ readLocalApi: readLocalApiMock, })); -import ChatMarkdown from "./ChatMarkdown"; +import ChatMarkdown, { sanitizeHighlightedCodeHtml } from "./ChatMarkdown"; describe("ChatMarkdown", () => { afterEach(() => { + confirmMock.mockClear(); openInPreferredEditorMock.mockClear(); readLocalApiMock.mockClear(); localStorage.clear(); @@ -37,7 +40,10 @@ describe("ChatMarkdown", () => { const filePath = "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; const screen = await render( - , + , ); try { @@ -59,7 +65,10 @@ describe("ChatMarkdown", () => { const filePath = "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; const screen = await render( - , + , ); try { @@ -81,7 +90,10 @@ describe("ChatMarkdown", () => { const filePath = "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; const screen = await render( - , + , ); try { @@ -108,7 +120,7 @@ describe("ChatMarkdown", () => { const screen = await render( , ); @@ -138,4 +150,55 @@ describe("ChatMarkdown", () => { await screen.unmount(); } }); + + it("asks before opening markdown file links outside the workspace", async () => { + confirmMock.mockResolvedValueOnce(false); + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "hosts" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("data-open-policy", "confirm"); + + await link.click(); + + await vi.waitFor(() => { + expect(confirmMock).toHaveBeenCalledWith(expect.stringContaining("/private/etc/hosts")); + }); + expect(openInPreferredEditorMock).not.toHaveBeenCalled(); + } finally { + await screen.unmount(); + } + }); + + it("sanitizes hostile highlighted code markup before HTML insertion", () => { + const sanitized = sanitizeHighlightedCodeHtml(` +
+        
+          
+            
+              safe text
+            
+            
+            bad
+            token
+          
+        
+      
+ `); + + const container = document.createElement("div"); + container.innerHTML = sanitized; + + expect(container.querySelector("script")).toBeNull(); + expect(container.querySelector("svg")).toBeNull(); + expect(container.querySelector("a")).toBeNull(); + expect(container.querySelector("[onclick],[onmouseover],[onload]")).toBeNull(); + expect(container.querySelector("[data-extra]")).toBeNull(); + expect(container.innerHTML).not.toContain("javascript:"); + expect(container.textContent).toContain("safe text"); + expect(container.textContent).toContain("token"); + }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index aaa0764d..5c87e28d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -74,6 +74,10 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); const highlighterPromiseCache = new Map>(); +const SHIKI_ALLOWED_TAGS = new Set(["pre", "code", "span"]); +const SHIKI_ALLOWED_ATTRIBUTES = new Set(["class", "style", "tabindex"]); +const UNSAFE_STYLE_VALUE_PATTERN = /(?:url\s*\(|expression\s*\(|@import)/i; +const UNSAFE_CLASS_VALUE_PATTERN = /[<>"'`=]/; function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); @@ -125,6 +129,58 @@ function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } +function escapeHtmlText(input: string): string { + return input + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function isSafeShikiAttribute(name: string, value: string): boolean { + const normalizedName = name.toLowerCase(); + if (!SHIKI_ALLOWED_ATTRIBUTES.has(normalizedName)) { + return false; + } + + switch (normalizedName) { + case "class": + return !UNSAFE_CLASS_VALUE_PATTERN.test(value); + case "style": + return !UNSAFE_STYLE_VALUE_PATTERN.test(value); + case "tabindex": + return /^-?\d+$/.test(value); + default: + return false; + } +} + +export function sanitizeHighlightedCodeHtml(html: string): string { + if (typeof document === "undefined") { + return escapeHtmlText(html); + } + + const template = document.createElement("template"); + template.innerHTML = html; + + for (const element of Array.from(template.content.querySelectorAll("*"))) { + const tagName = element.tagName.toLowerCase(); + if (!SHIKI_ALLOWED_TAGS.has(tagName)) { + element.replaceWith(document.createTextNode(element.textContent ?? "")); + continue; + } + + for (const attribute of Array.from(element.attributes)) { + if (!isSafeShikiAttribute(attribute.name, attribute.value)) { + element.removeAttribute(attribute.name); + } + } + } + + return template.innerHTML; +} + function getHighlighterPromise(language: string): Promise { const cached = highlighterPromiseCache.get(language); if (cached) return cached; @@ -215,7 +271,7 @@ function SuspenseShikiCodeBlock({ return (
); } @@ -260,19 +316,26 @@ function UncachedShikiCodeBlock({ return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); } }, [code, highlighter, language, themeName]); + const safeHighlightedHtml = useMemo( + () => sanitizeHighlightedCodeHtml(highlightedHtml), + [highlightedHtml], + ); useEffect(() => { if (!isStreaming) { highlightedCodeCache.set( cacheKey, - highlightedHtml, - estimateHighlightedSize(highlightedHtml, code), + safeHighlightedHtml, + estimateHighlightedSize(safeHighlightedHtml, code), ); } - }, [cacheKey, code, highlightedHtml, isStreaming]); + }, [cacheKey, code, safeHighlightedHtml, isStreaming]); return ( -
+
); } @@ -282,6 +345,7 @@ interface MarkdownFileLinkProps { displayPath: string; filePath: string; label: string; + openPolicy: "direct" | "confirm"; theme: "light" | "dark"; className?: string | undefined; } @@ -373,6 +437,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ displayPath, filePath, label, + openPolicy, theme, className, }: MarkdownFileLinkProps) { @@ -386,7 +451,18 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } - void openInPreferredEditor(api, targetPath).catch((error) => { + void (async () => { + if (openPolicy === "confirm") { + const confirmed = await api.dialogs.confirm( + `This file is outside the current workspace:\n\n${targetPath}\n\nOpen it anyway?`, + ); + if (!confirmed) { + return; + } + } + + await openInPreferredEditor(api, targetPath); + })().catch((error) => { toastManager.add( stackedThreadToast({ type: "error", @@ -395,7 +471,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }), ); }); - }, [targetPath]); + }, [openPolicy, targetPath]); const handleCopy = useCallback((value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { @@ -468,6 +544,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ { event.preventDefault(); event.stopPropagation(); @@ -491,6 +568,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ >
{displayPath} + {openPolicy === "confirm" ? " (outside workspace)" : ""}
@@ -507,6 +585,7 @@ function areMarkdownFileLinkPropsEqual( previous.displayPath === next.displayPath && previous.filePath === next.filePath && previous.label === next.label && + previous.openPolicy === next.openPolicy && previous.theme === next.theme && previous.className === next.className ); @@ -575,6 +654,7 @@ function ChatMarkdown({ displayPath={fileLinkMeta.displayPath} filePath={fileLinkMeta.filePath} label={labelParts.join(" · ")} + openPolicy={fileLinkMeta.openPolicy} theme={resolvedTheme} className={props.className} /> diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index c0b80896..3da9427b 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -43,11 +43,6 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import { - INLINE_TERMINAL_CONTEXT_PLACEHOLDER, - removeInlineTerminalContextPlaceholder, - type TerminalContextDraft, -} from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; @@ -55,7 +50,6 @@ import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; -import { useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; @@ -205,7 +199,6 @@ function createMockEnvironmentApi(input: { dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"]; }): EnvironmentApi { return { - terminal: {} as EnvironmentApi["terminal"], projects: {} as EnvironmentApi["projects"], filesystem: { browse: input.browse, @@ -215,15 +208,15 @@ function createMockEnvironmentApi(input: { git: {} as EnvironmentApi["git"], orchestration: { dispatchCommand: input.dispatchCommand, - getTurnDiff: (() => { - throw new Error("Not implemented in browser test."); - }) as EnvironmentApi["orchestration"]["getTurnDiff"], - getFullThreadDiff: (() => { - throw new Error("Not implemented in browser test."); - }) as EnvironmentApi["orchestration"]["getFullThreadDiff"], getArchivedShellSnapshot: (() => { throw new Error("Not implemented in browser test."); }) as EnvironmentApi["orchestration"]["getArchivedShellSnapshot"], + getDeletedShellSnapshot: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["getDeletedShellSnapshot"], + hardDeleteThread: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["hardDeleteThread"], subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"], subscribeThread: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeThread"], @@ -267,25 +260,6 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe }; } -function createTerminalContext(input: { - id: string; - terminalLabel: string; - lineStart: number; - lineEnd: number; - text: string; -}): TerminalContextDraft { - return { - id: input.id, - threadId: THREAD_ID, - terminalId: `terminal-${input.id}`, - terminalLabel: input.terminalLabel, - lineStart: input.lineStart, - lineEnd: input.lineEnd, - text: input.text, - createdAt: NOW_ISO, - }; -} - function createSnapshotForTargetUser(options: { targetMessageId: MessageId; targetText: string; @@ -1052,25 +1026,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { if (tag === WS_METHODS.shellOpenInEditor) { return null; } - if (tag === WS_METHODS.terminalOpen) { - return { - threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, - terminalId: typeof body.terminalId === "string" ? body.terminalId : "default", - cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project", - worktreePath: - typeof body.worktreePath === "string" - ? body.worktreePath - : body.worktreePath === null - ? null - : null, - status: "running", - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: NOW_ISO, - }; - } return {}; } @@ -1724,13 +1679,6 @@ describe("ChatView timeline estimator parity (full app)", () => { projectOrder: [], threadLastVisitedAtById: {}, }); - useTerminalStateStore.persist.clearStorage(); - useTerminalStateStore.setState({ - terminalStateByThreadKey: {}, - terminalLaunchContextByThreadKey: {}, - terminalEventEntriesByKey: {}, - nextTerminalEventId: 1, - }); }); afterEach(() => { @@ -1893,74 +1841,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("does not leak a server worktree path into drawer runtime env when launch context clears it", async () => { - const snapshot = createSnapshotForTargetUser({ - targetMessageId: "msg-user-launch-context-target" as MessageId, - targetText: "launch context worktree override", - }); - const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); - if (targetThread) { - Object.assign(targetThread, { - branch: "feature/branch", - worktreePath: "/repo/worktrees/feature-branch", - }); - } - - useTerminalStateStore.setState({ - terminalStateByThreadKey: { - [THREAD_KEY]: { - terminalOpen: true, - terminalHeight: 280, - terminalIds: ["default"], - runningTerminalIds: [], - activeTerminalId: "default", - terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], - activeTerminalGroupId: "group-default", - }, - }, - terminalLaunchContextByThreadKey: { - [THREAD_KEY]: { - cwd: "/repo/project", - worktreePath: null, - }, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot, - }); - - try { - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalOpen, - ) as - | { - _tag: string; - cwd?: string; - worktreePath?: string | null; - env?: Record; - } - | undefined; - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, - cwd: "/repo/project", - worktreePath: null, - env: { - CAFE_CODE_PROJECT_ROOT: "/repo/project", - }, - }); - expect(openRequest?.env?.CAFE_CODE_WORKTREE_PATH).toBeUndefined(); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => { setDraftThreadWithoutWorktree(); @@ -2166,7 +2046,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); it("falls back to the first installed editor when the stored favorite is unavailable", async () => { - localStorage.setItem("t3code:last-editor", JSON.stringify("vscodium")); + localStorage.setItem("cafecode:last-editor", JSON.stringify("vscodium")); setDraftThreadWithoutWorktree(); const mounted = await mountChatView({ @@ -2212,151 +2092,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("runs project scripts from local draft threads at the project cwd", async () => { - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [THREAD_KEY]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - envMode: "local", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: THREAD_KEY, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: withProjectScripts(createDraftOnlySnapshot(), [ - { - id: "lint", - name: "Lint", - command: "bun run lint", - icon: "lint", - runOnWorktreeCreate: false, - }, - ]), - }); - - try { - const runButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.title === "Run Lint", - ) as HTMLButtonElement | null, - "Unable to find Run Lint button.", - ); - runButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalOpen, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, - threadId: THREAD_ID, - cwd: "/repo/project", - env: { - CAFE_CODE_PROJECT_ROOT: "/repo/project", - }, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await vi.waitFor( - () => { - const writeRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalWrite, - ); - expect(writeRequest).toMatchObject({ - _tag: WS_METHODS.terminalWrite, - threadId: THREAD_ID, - data: "bun run lint\r", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("runs project scripts from worktree draft threads at the worktree cwd", async () => { - useComposerDraftStore.setState({ - draftThreadsByThreadKey: { - [THREAD_KEY]: { - threadId: THREAD_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - projectId: PROJECT_ID, - logicalProjectKey: PROJECT_DRAFT_KEY, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: "feature/draft", - worktreePath: "/repo/worktrees/feature-draft", - envMode: "worktree", - }, - }, - logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: THREAD_KEY, - }, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: withProjectScripts(createDraftOnlySnapshot(), [ - { - id: "test", - name: "Test", - command: "bun run test", - icon: "test", - runOnWorktreeCreate: false, - }, - ]), - }); - - try { - const runButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.title === "Run Test", - ) as HTMLButtonElement | null, - "Unable to find Run Test button.", - ); - runButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalOpen, - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, - threadId: THREAD_ID, - cwd: "/repo/worktrees/feature-draft", - env: { - CAFE_CODE_PROJECT_ROOT: "/repo/project", - CAFE_CODE_WORKTREE_PATH: "/repo/worktrees/feature-draft", - }, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - it("lets the server own setup after preparing a pull request worktree thread", async () => { useComposerDraftStore.setState({ draftThreadsByThreadKey: { @@ -2470,22 +2205,12 @@ describe("ChatView timeline estimator parity (full app)", () => { }, { timeout: 8_000, interval: 16 }, ); - - expect( - wsRequests.some( - (request) => - request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r", - ), - ).toBe(false); } finally { await mounted.cleanup(); } }); it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { - useTerminalStateStore.setState({ - terminalStateByThreadKey: {}, - }); useComposerDraftStore.setState({ draftThreadsByThreadKey: { [THREAD_KEY]: { @@ -2572,14 +2297,6 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(wsRequests.some((request) => request._tag === WS_METHODS.vcsCreateWorktree)).toBe( false, ); - expect( - wsRequests.some( - (request) => - request._tag === WS_METHODS.terminalWrite && - request.threadId === THREAD_ID && - request.data === "bun install\r", - ), - ).toBe(false); } finally { await mounted.cleanup(); } @@ -2987,9 +2704,6 @@ describe("ChatView timeline estimator parity (full app)", () => { }); it("shows the send state once bootstrap dispatch is in flight", async () => { - useTerminalStateStore.setState({ - terminalStateByThreadKey: {}, - }); useComposerDraftStore.setState({ draftThreadsByThreadKey: { [THREAD_KEY]: { @@ -3560,162 +3274,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("keeps removed terminal context pills removed when a new one is added", async () => { - const removedLabel = "Terminal 1 lines 1-2"; - const addedLabel = "Terminal 2 lines 9-10"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-removed", - terminalLabel: "Terminal 1", - lineStart: 1, - lineEnd: 2, - text: "bun i\nno changes", - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-terminal-pill-backspace" as MessageId, - targetText: "terminal pill backspace target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const store = useComposerDraftStore.getState(); - const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? ""; - const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); - store.setPrompt(THREAD_REF, nextPrompt.prompt); - store.removeTerminalContext(THREAD_REF, "ctx-removed"); - - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined(); - expect(document.body.textContent).not.toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-added", - terminalLabel: "Terminal 2", - lineStart: 9, - lineEnd: 10, - text: "git status\nOn branch main", - }), - ); - - await vi.waitFor( - () => { - const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]; - expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); - expect(document.body.textContent).toContain(addedLabel); - expect(document.body.textContent).not.toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("disables send when the composer only contains an expired terminal pill", async () => { - const expiredLabel = "Terminal 1 line 4"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-expired-only", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-expired-pill-disabled" as MessageId, - targetText: "expired pill disabled target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(expiredLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(true); - } finally { - await mounted.cleanup(); - } - }); - - it("warns when sending text while omitting expired terminal pills", async () => { - const expiredLabel = "Terminal 1 line 4"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_REF, - createTerminalContext({ - id: "ctx-expired-send-warning", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - }), - ); - useComposerDraftStore - .getState() - .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-expired-pill-warning" as MessageId, - targetText: "expired pill warning target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(expiredLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - sendButton.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain( - "Expired terminal context omitted from message", - ); - expect(document.body.textContent).not.toContain(expiredLabel); - expect(document.body.textContent).toContain("yoowaddup"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - it("shows a pointer cursor for the running stop button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -3813,7 +3371,7 @@ describe("ChatView timeline estimator parity (full app)", () => { it("shows the confirm archive action after clicking the archive button", async () => { localStorage.setItem( - "t3code:client-settings:v1", + "cafecode:client-settings:v1", JSON.stringify({ ...DEFAULT_CLIENT_SETTINGS, confirmThreadArchive: true, @@ -3842,7 +3400,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await expect.element(confirmButton).toBeInTheDocument(); await expect.element(confirmButton).toBeVisible(); } finally { - localStorage.removeItem("t3code:client-settings:v1"); + localStorage.removeItem("cafecode:client-settings:v1"); await mounted.cleanup(); } }); @@ -4281,7 +3839,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, { @@ -4348,7 +3906,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, ], @@ -4391,7 +3949,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, ], @@ -4443,7 +4001,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, ], @@ -4490,7 +4048,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, ], @@ -4601,7 +4159,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, ], @@ -5229,7 +4787,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, ], @@ -5354,7 +4912,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, ], @@ -5403,7 +4961,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, ], @@ -5465,7 +5023,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, ], @@ -5521,7 +5079,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, ], @@ -5568,7 +5126,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, ], @@ -6034,7 +5592,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, whenAst: { type: "not", - node: { type: "identifier", name: "terminalFocus" }, + node: { type: "identifier", name: "modelPickerOpen" }, }, }, { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c39e896d..32e569f2 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,12 +12,10 @@ import { type EnvironmentState, useStore } from "../store"; import { type Thread } from "../types"; import { - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, deriveComposerSendState, hasServerAcknowledgedLocalDispatch, - reconcileMountedTerminalThreadIds, + resolveFollowUpQueuePhase, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, @@ -26,70 +24,27 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); describe("deriveComposerSendState", () => { - it("treats expired terminal pills as non-sendable content", () => { + it("ignores stale inline context placeholders when deciding sendability", () => { const state = deriveComposerSendState({ prompt: "\uFFFC", imageCount: 0, - terminalContexts: [ - { - id: "ctx-expired", - threadId: ThreadId.make("thread-1"), - terminalId: "default", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - createdAt: "2026-03-17T12:52:29.000Z", - }, - ], }); expect(state.trimmedPrompt).toBe(""); - expect(state.sendableTerminalContexts).toEqual([]); - expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(false); }); - it("keeps text sendable while excluding expired terminal pills", () => { + it("keeps text and images sendable", () => { const state = deriveComposerSendState({ prompt: `yoo \uFFFC waddup`, - imageCount: 0, - terminalContexts: [ - { - id: "ctx-expired", - threadId: ThreadId.make("thread-1"), - terminalId: "default", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - createdAt: "2026-03-17T12:52:29.000Z", - }, - ], + imageCount: 1, }); expect(state.trimmedPrompt).toBe("yoo waddup"); - expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); }); }); -describe("buildExpiredTerminalContextToastCopy", () => { - it("formats clear empty-state guidance", () => { - expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ - title: "Expired terminal context won't be sent", - description: "Remove it or re-add it to include terminal output.", - }); - }); - - it("formats omission guidance for sent messages", () => { - expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ - title: "Expired terminal contexts omitted from message", - description: "Re-add it if you want that terminal output included.", - }); - }); -}); - describe("resolveSendEnvMode", () => { it("keeps worktree mode for git repositories", () => { expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); @@ -101,84 +56,41 @@ describe("resolveSendEnvMode", () => { }); }); -describe("reconcileMountedTerminalThreadIds", () => { - it("keeps previously mounted open threads and adds the active open thread", () => { - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-stale")], - openThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-active")], - activeThreadId: ThreadId.make("thread-active"), - activeThreadTerminalOpen: true, - }), - ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]); - }); - - it("drops mounted threads once their terminal drawer is no longer open", () => { - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: [ThreadId.make("thread-closed")], - openThreadIds: [], - activeThreadId: ThreadId.make("thread-closed"), - activeThreadTerminalOpen: false, - }), - ).toEqual([]); - }); - - it("keeps only the most recently active hidden terminal threads", () => { - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: [ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ThreadId.make("thread-3"), - ], - openThreadIds: [ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ThreadId.make("thread-3"), - ThreadId.make("thread-4"), - ], - activeThreadId: ThreadId.make("thread-4"), - activeThreadTerminalOpen: true, - maxHiddenThreadCount: 2, - }), - ).toEqual([ThreadId.make("thread-2"), ThreadId.make("thread-3"), ThreadId.make("thread-4")]); - }); +describe("resolveFollowUpQueuePhase", () => { + it("treats a completed active turn as ready even when the session status is still running", () => { + const turnId = TurnId.make("turn-completed"); - it("moves the active thread to the end so it is treated as most recently used", () => { expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: [ - ThreadId.make("thread-a"), - ThreadId.make("thread-b"), - ThreadId.make("thread-c"), - ], - openThreadIds: [ - ThreadId.make("thread-a"), - ThreadId.make("thread-b"), - ThreadId.make("thread-c"), - ], - activeThreadId: ThreadId.make("thread-a"), - activeThreadTerminalOpen: true, - maxHiddenThreadCount: 2, + resolveFollowUpQueuePhase({ + phase: "running", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: "2026-03-29T00:01:30.000Z", + assistantMessageId: null, + }, + activeTurnId: turnId, }), - ).toEqual([ThreadId.make("thread-b"), ThreadId.make("thread-c"), ThreadId.make("thread-a")]); + ).toBe("ready"); }); - it("defaults to the hidden mounted terminal cap", () => { - const currentThreadIds = Array.from( - { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, - (_, index) => ThreadId.make(`thread-${index + 1}`), - ); - + it("trusts visible turn completion even when the provider active turn id is stale", () => { expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds, - openThreadIds: currentThreadIds, - activeThreadId: null, - activeThreadTerminalOpen: false, + resolveFollowUpQueuePhase({ + phase: "running", + latestTurn: { + turnId: TurnId.make("turn-completed"), + state: "completed", + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: "2026-03-29T00:01:30.000Z", + assistantMessageId: null, + }, + activeTurnId: TurnId.make("stale-provider-turn"), }), - ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); + ).toBe("ready"); }); }); @@ -549,6 +461,41 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); + it("clears local dispatch when the latest turn settled after dispatch even if fields already match", () => { + const completedLatestTurn = { + ...previousLatestTurn, + turnId: TurnId.make("turn-2"), + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: "2026-03-29T00:01:30.000Z", + }; + const completedSession = { + ...previousSession, + updatedAt: "2026-03-29T00:01:30.000Z", + }; + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch: { + startedAt: "2026-03-29T00:01:10.000Z", + preparingWorktree: false, + latestTurnTurnId: completedLatestTurn.turnId, + latestTurnRequestedAt: completedLatestTurn.requestedAt, + latestTurnStartedAt: completedLatestTurn.startedAt, + latestTurnCompletedAt: completedLatestTurn.completedAt, + sessionOrchestrationStatus: completedSession.orchestrationStatus, + sessionUpdatedAt: completedSession.updatedAt, + }, + phase: "ready", + latestTurn: completedLatestTurn, + session: completedSession, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(true); + }); + it("does not clear local dispatch while the session is running a newer turn than latestTurn", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.make("thread-1"), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index bfc846d3..dbd55017 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -12,16 +12,11 @@ import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { selectThreadByRef, useStore } from "../store"; -import { - filterTerminalContextsWithText, - stripInlineTerminalContextPlaceholders, - type TerminalContextDraft, -} from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; -export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "cafecode:last-invoked-script-by-project"; -export const LEGACY_LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; -export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; +export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "cafe-code:last-invoked-script-by-project"; +export const LEGACY_LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "cafecode:last-invoked-script-by-project"; +const INLINE_CONTEXT_PLACEHOLDER = "\uFFFC"; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); @@ -73,37 +68,6 @@ export function shouldWriteThreadErrorToCurrentServerThread(input: { ); } -export function reconcileMountedTerminalThreadIds(input: { - currentThreadIds: ReadonlyArray; - openThreadIds: ReadonlyArray; - activeThreadId: string | null; - activeThreadTerminalOpen: boolean; - maxHiddenThreadCount?: number; -}): string[] { - const openThreadIdSet = new Set(input.openThreadIds); - const hiddenThreadIds = input.currentThreadIds.filter( - (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), - ); - const maxHiddenThreadCount = Math.max( - 0, - input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - ); - const nextThreadIds = - hiddenThreadIds.length > maxHiddenThreadCount - ? hiddenThreadIds.slice(-maxHiddenThreadCount) - : hiddenThreadIds; - - if ( - input.activeThreadId && - input.activeThreadTerminalOpen && - !nextThreadIds.includes(input.activeThreadId) - ) { - nextThreadIds.push(input.activeThreadId); - } - - return nextThreadIds; -} - export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { return; @@ -181,44 +145,14 @@ export function cloneComposerImageForRetry( } } -export function deriveComposerSendState(options: { - prompt: string; - imageCount: number; - terminalContexts: ReadonlyArray; -}): { +export function deriveComposerSendState(options: { prompt: string; imageCount: number }): { trimmedPrompt: string; - sendableTerminalContexts: TerminalContextDraft[]; - expiredTerminalContextCount: number; hasSendableContent: boolean; } { - const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim(); - const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); - const expiredTerminalContextCount = - options.terminalContexts.length - sendableTerminalContexts.length; + const trimmedPrompt = options.prompt.replaceAll(INLINE_CONTEXT_PLACEHOLDER, "").trim(); return { trimmedPrompt, - sendableTerminalContexts, - expiredTerminalContextCount, - hasSendableContent: - trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0, - }; -} - -export function buildExpiredTerminalContextToastCopy( - expiredTerminalContextCount: number, - variant: "omitted" | "empty", -): { title: string; description: string } { - const count = Math.max(1, Math.floor(expiredTerminalContextCount)); - const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts"; - if (variant === "empty") { - return { - title: `${noun} won't be sent`, - description: "Remove it or re-add it to include terminal output.", - }; - } - return { - title: `${noun} omitted from message`, - description: "Re-add it if you want that terminal output included.", + hasSendableContent: trimmedPrompt.length > 0 || options.imageCount > 0, }; } @@ -336,6 +270,37 @@ export function createLocalDispatchSnapshot( }; } +function completedTurnAcknowledgesLocalDispatch( + localDispatch: LocalDispatchSnapshot, + latestTurn: Thread["latestTurn"] | null, +): boolean { + if (!latestTurn?.completedAt) { + return false; + } + + const dispatchStartedAt = Date.parse(localDispatch.startedAt); + const turnCompletedAt = Date.parse(latestTurn.completedAt); + if (!Number.isFinite(dispatchStartedAt) || !Number.isFinite(turnCompletedAt)) { + return false; + } + + return turnCompletedAt >= dispatchStartedAt; +} + +export function resolveFollowUpQueuePhase(input: { + phase: SessionPhase; + latestTurn: Thread["latestTurn"] | null; + activeTurnId: TurnId | null | undefined; +}): SessionPhase { + if (input.phase !== "running") { + return input.phase; + } + if (!input.latestTurn?.completedAt) { + return input.phase; + } + return "ready"; +} + export function hasServerAcknowledgedLocalDispatch(input: { localDispatch: LocalDispatchSnapshot | null; phase: SessionPhase; @@ -352,6 +317,10 @@ export function hasServerAcknowledgedLocalDispatch(input: { return true; } + if (completedTurnAcknowledgesLocalDispatch(input.localDispatch, input.latestTurn)) { + return true; + } + const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; const latestTurnChanged = diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4dfcbe3d..c33d25be 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,39 +3,31 @@ import { DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, + type DesktopRendererDebugSnapshot, type MessageId, type ModelSelection, - type ProjectScript, type ProjectId, type ProviderApprovalDecision, ProviderInstanceId, type ServerProvider, - type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type TurnId, - type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, ProviderDriverKind, RuntimeMode, - TerminalOpenInput, + type UploadChatAttachment as OrchestrationUploadChatAttachment, } from "@cafecode/contracts"; -import { - parseScopedThreadKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@cafecode/client-runtime"; +import { scopedThreadKey, scopeProjectRef, scopeThreadRef } from "@cafecode/client-runtime"; import { applyClaudePromptEffortPrefix, createModelSelection, resolvePromptInjectedEffort, } from "@cafecode/shared/model"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@cafecode/shared/projectScripts"; import { truncate } from "@cafecode/shared/String"; import { Debouncer } from "@tanstack/react-pacer"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; @@ -87,8 +79,6 @@ import { import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, - DEFAULT_THREAD_TERMINAL_ID, - MAX_TERMINALS_PER_GROUP, type ChatMessage, type SessionPhase, type Thread, @@ -103,22 +93,13 @@ import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; -import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; -import { cn, randomUUID } from "~/lib/utils"; +import { cn } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; -import { type NewProjectScriptInput } from "./ProjectScriptsControl"; -import { - commandForProjectScript, - nextProjectScriptId, - projectScriptIdFromCommand, -} from "~/projectScripts"; import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; -import { isTerminalFocused } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, @@ -136,13 +117,20 @@ import { type DraftId, } from "../composerDraftStore"; import { - appendTerminalContextsToPrompt, - formatTerminalContextLabel, - type TerminalContextDraft, - type TerminalContextSelection, -} from "../lib/terminalContext"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; -import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; + ChatComposer, + type ChatComposerHandle, + type FollowUpQueueViewItem, +} from "./chat/ChatComposer"; +import { + canExpandQueuedFollowUpText, + canStartQueuedFollowUpTurn, + decideQueuedFollowUpAction, + decideFollowUpDelivery, + previewQueuedFollowUpText, + queuedFollowUpActionLabel, + queuedFollowUpActionTitle, + rekeyQueuedFollowUpsForActiveThread, +} from "./chat/followUpQueue"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; @@ -154,29 +142,23 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, hasServerAcknowledgedLocalDispatch, - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - LEGACY_LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, PullRequestDialogState, cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, - reconcileMountedTerminalThreadIds, + resolveFollowUpQueuePhase, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, } from "./ChatView.logic"; -import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; import { useServerAvailableEditors, @@ -198,15 +180,238 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; +const DEBUG_SNAPSHOT_VERSION = 3; +const DEBUG_TEXT_PREVIEW_LIMIT = 240; +const DEBUG_JSON_PREVIEW_LIMIT = 2_000; +const DEBUG_RECENT_MESSAGE_LIMIT = 20; +const DEBUG_RECENT_ACTIVITY_LIMIT = 30; +const DEBUG_RECENT_RUNTIME_EVENT_LIMIT = 12; +const DEBUG_INTERESTING_THREAD_LIMIT = 40; + +function truncateDebugText(value: string, limit = DEBUG_TEXT_PREVIEW_LIMIT): string { + if (value.length <= limit) { + return value; + } + return `${value.slice(0, Math.max(0, limit - 1))}…`; +} + +function stringifyDebugPreview(value: unknown, limit = DEBUG_JSON_PREVIEW_LIMIT): string { + try { + return truncateDebugText(JSON.stringify(value), limit); + } catch { + return "[unserializable]"; + } +} + +function payloadKeys(payload: unknown): readonly string[] { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + return []; + } + return Object.keys(payload).toSorted(); +} + +function countBy(items: readonly T[], keyOf: (item: T) => string): Record { + const counts: Record = {}; + for (const item of items) { + const key = keyOf(item); + counts[key] = (counts[key] ?? 0) + 1; + } + return counts; +} + +function summarizeDebugMessage(message: ChatMessage) { + return { + id: message.id, + role: message.role, + turnId: message.turnId ?? null, + createdAt: message.createdAt, + completedAt: message.completedAt ?? null, + streaming: message.streaming, + textLength: message.text.length, + textPreview: truncateDebugText(message.text), + attachmentCount: message.attachments?.length ?? 0, + }; +} + +function summarizeDebugActivity(activity: OrchestrationThreadActivity) { + return { + id: activity.id, + kind: activity.kind, + tone: activity.tone, + summary: activity.summary, + turnId: activity.turnId, + sequence: activity.sequence ?? null, + createdAt: activity.createdAt, + payloadKeys: payloadKeys(activity.payload), + payloadPreview: stringifyDebugPreview(activity.payload), + }; +} + +function summarizeDebugTurnDiff(diff: TurnDiffSummary) { + return { + turnId: diff.turnId, + completedAt: diff.completedAt, + status: diff.status ?? null, + checkpointRef: diff.checkpointRef ?? null, + assistantMessageId: diff.assistantMessageId ?? null, + checkpointTurnCount: diff.checkpointTurnCount ?? null, + fileCount: diff.files.length, + files: diff.files.slice(0, 20), + }; +} + +function summarizeDebugLatestTurn(latestTurn: Thread["latestTurn"]) { + if (!latestTurn) { + return null; + } + return { + turnId: latestTurn.turnId, + state: latestTurn.state, + requestedAt: latestTurn.requestedAt, + startedAt: latestTurn.startedAt, + completedAt: latestTurn.completedAt, + assistantMessageId: latestTurn.assistantMessageId, + sourceProposedPlan: latestTurn.sourceProposedPlan ?? null, + }; +} + +function summarizeDebugSession(session: Thread["session"]) { + if (!session) { + return null; + } + return { + provider: session.provider, + providerInstanceId: session.providerInstanceId ?? null, + status: session.status, + orchestrationStatus: session.orchestrationStatus, + activeTurnId: session.activeTurnId ?? null, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + lastError: session.lastError ?? null, + }; +} + +function activityIsLifecycleRelevant(activity: OrchestrationThreadActivity): boolean { + return ( + activity.kind === "runtime.warning" || + activity.kind === "runtime.error" || + activity.kind === "tool.started" || + activity.kind === "tool.updated" || + activity.kind === "tool.completed" || + activity.kind === "turn.plan.updated" || + activity.kind === "approval.requested" || + activity.kind === "approval.resolved" || + activity.kind === "user-input.requested" || + activity.kind === "user-input.resolved" + ); +} + +function summarizeDebugThreadLifecycle(thread: Thread) { + const session = thread.session; + const latestTurn = thread.latestTurn; + const activeTurnId = session?.activeTurnId ?? null; + const latestTurnId = latestTurn?.turnId ?? null; + const phase = derivePhase(session); + const latestTurnSettled = isLatestTurnSettled(latestTurn, session); + const activeTurnMessages = + latestTurnId === null + ? [] + : thread.messages.filter((message) => message.turnId === latestTurnId); + const activeTurnActivities = + latestTurnId === null + ? [] + : thread.activities.filter((activity) => activity.turnId === latestTurnId); + const streamingMessages = thread.messages.filter((message) => message.streaming); + const latestTurnCompletedAt = latestTurn?.completedAt ?? null; + const activitiesAfterLatestTurnCompleted = + latestTurnId !== null && latestTurnCompletedAt !== null + ? activeTurnActivities.filter((activity) => activity.createdAt > latestTurnCompletedAt) + : []; + const sessionActiveTurnMatchesLatestTurn = + activeTurnId !== null && latestTurnId !== null && activeTurnId === latestTurnId; + const staleCompletedActiveTurn = + session?.status === "running" && + activeTurnId !== null && + sessionActiveTurnMatchesLatestTurn && + latestTurn?.state === "completed" && + latestTurn.completedAt !== null; + const latestTurnCompletedButSessionRunning = + latestTurn?.completedAt != null && session?.status === "running"; + const latestTurnReadyButSessionOwnsActiveTurn = + latestTurn?.completedAt != null && + activeTurnId !== null && + sessionActiveTurnMatchesLatestTurn && + session?.orchestrationStatus !== "error" && + session?.orchestrationStatus !== "interrupted" && + session?.orchestrationStatus !== "stopped"; + const latestTurnRunningButSessionNotRunning = + latestTurn?.state === "running" && session?.status !== "running"; + const hasStreamingMessagesButNotRunning = streamingMessages.length > 0 && phase !== "running"; + const redFlags = [ + staleCompletedActiveTurn ? "stale-completed-active-turn" : null, + latestTurnCompletedButSessionRunning ? "latest-turn-completed-but-session-running" : null, + latestTurnReadyButSessionOwnsActiveTurn ? "completed-turn-still-owned-by-session" : null, + latestTurnRunningButSessionNotRunning ? "latest-turn-running-session-not-running" : null, + hasStreamingMessagesButNotRunning ? "streaming-message-while-not-running" : null, + activitiesAfterLatestTurnCompleted.length > 0 ? "activity-after-latest-turn-completed" : null, + ].filter((value): value is string => value !== null); + + return { + id: thread.id, + title: thread.title, + projectId: thread.projectId, + phase, + session: summarizeDebugSession(session), + latestTurn: summarizeDebugLatestTurn(latestTurn), + latestTurnSettled, + activeTurnId, + latestTurnId, + sessionActiveTurnMatchesLatestTurn, + isSessionRunning: session?.status === "running", + isLatestTurnRunning: latestTurn?.state === "running", + hasUnsettledLatestTurn: latestTurn !== null && !latestTurnSettled, + staleCompletedActiveTurn, + latestTurnCompletedButSessionRunning, + latestTurnReadyButSessionOwnsActiveTurn, + latestTurnRunningButSessionNotRunning, + streamingMessageCount: streamingMessages.length, + streamingMessageIds: streamingMessages.map((message) => message.id), + hasStreamingMessagesButNotRunning, + activeTurnMessageCount: activeTurnMessages.length, + activeTurnActivityCount: activeTurnActivities.length, + activityAfterLatestTurnCompletedCount: activitiesAfterLatestTurnCompleted.length, + redFlags, + latestActiveTurnMessage: + activeTurnMessages.length > 0 ? summarizeDebugMessage(activeTurnMessages.at(-1)!) : null, + latestActiveTurnActivity: + activeTurnActivities.length > 0 ? summarizeDebugActivity(activeTurnActivities.at(-1)!) : null, + latestActivityAfterLatestTurnCompleted: + activitiesAfterLatestTurnCompleted.length > 0 + ? summarizeDebugActivity(activitiesAfterLatestTurnCompleted.at(-1)!) + : null, + recentLifecycleActivities: activeTurnActivities + .filter(activityIsLifecycleRelevant) + .slice(-12) + .map(summarizeDebugActivity), + }; +} const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_FOLLOW_UP_QUEUE: FollowUpQueueItem[] = []; +const FOLLOW_UP_QUEUE_WATCHDOG_INTERVAL_MS = 1000; type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; readonly connectionState: "connecting" | "disconnected" | "error"; }; +function readComposerHandle( + composerRef: RefObject, +): ChatComposerHandle | null { + return composerRef.current; +} + type ThreadPlanCatalogEntry = Pick; function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { @@ -333,9 +538,6 @@ function formatOutgoingPrompt(params: { const promptEffort = resolvePromptInjectedEffort(caps, params.effort); return applyClaudePromptEffortPrefix(params.text, promptEffort); } -const SCRIPT_TERMINAL_COLS = 120; -const SCRIPT_TERMINAL_ROWS = 30; - type ChatViewProps = | { environmentId: EnvironmentId; @@ -354,13 +556,42 @@ type ChatViewProps = draftId: DraftId; }; -interface TerminalLaunchContext { +interface ComposerSendSnapshot { + promptText: string; + images: ComposerImageAttachment[]; + provider: ProviderDriverKind; + model: string | null; + providerModels: ReadonlyArray; + promptEffort: string | null; + modelSelection: ModelSelection; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; +} + +interface FollowUpQueueItem extends ComposerSendSnapshot { + id: string; threadId: ThreadId; - cwd: string; - worktreePath: string | null; + queuedAt: string; + expanded: boolean; + blockedReason: string | null; +} + +function revokeQueuedFollowUpPreviewUrls(item: FollowUpQueueItem): void { + for (const image of item.images) { + revokeBlobPreviewUrl(image.previewUrl); + } } -type PersistentTerminalLaunchContext = Pick; +function optimisticAttachmentsForSnapshot(snapshot: ComposerSendSnapshot) { + return snapshot.images.map((image) => ({ + type: "image" as const, + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + previewUrl: image.previewUrl, + })); +} function useLocalDispatchState(input: { activeThread: Thread | undefined; @@ -426,185 +657,10 @@ function useLocalDispatchState(input: { localDispatchStartedAt: localDispatch?.startedAt ?? null, isPreparingWorktree: localDispatch?.preparingWorktree ?? false, isSendBusy: localDispatch !== null && !serverAcknowledgedLocalDispatch, + serverAcknowledgedLocalDispatch, }; } -interface PersistentThreadTerminalDrawerProps { - threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; - threadId: ThreadId; - visible: boolean; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - splitShortcutLabel: string | undefined; - newShortcutLabel: string | undefined; - closeShortcutLabel: string | undefined; - keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; -} - -const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ - threadRef, - threadId, - visible, - launchContext, - focusRequestId, - splitShortcutLabel, - newShortcutLabel, - closeShortcutLabel, - keybindings, - onAddTerminalContext, -}: PersistentThreadTerminalDrawerProps) { - const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); - const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadKey, threadRef), - ); - const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); - const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); - const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal); - const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal); - const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal); - const [localFocusRequestId, setLocalFocusRequestId] = useState(0); - const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const effectiveWorktreePath = useMemo(() => { - if (launchContext !== null) { - return launchContext.worktreePath; - } - return worktreePath; - }, [launchContext, worktreePath]); - const cwd = useMemo( - () => - launchContext?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.cwd }, - worktreePath: effectiveWorktreePath, - }) - : null), - [effectiveWorktreePath, launchContext?.cwd, project], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, - worktreePath: effectiveWorktreePath, - }) - : {}, - [effectiveWorktreePath, project], - ); - - const bumpFocusRequestId = useCallback(() => { - if (!visible) { - return; - } - setLocalFocusRequestId((value) => value + 1); - }, [visible]); - - const setTerminalHeight = useCallback( - (height: number) => { - storeSetTerminalHeight(threadRef, height); - }, - [storeSetTerminalHeight, threadRef], - ); - - const splitTerminal = useCallback(() => { - storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); - bumpFocusRequestId(); - }, [bumpFocusRequestId, storeSplitTerminal, threadRef]); - - const createNewTerminal = useCallback(() => { - storeNewTerminal(threadRef, `terminal-${randomUUID()}`); - bumpFocusRequestId(); - }, [bumpFocusRequestId, storeNewTerminal, threadRef]); - - const activateTerminal = useCallback( - (terminalId: string) => { - storeSetActiveTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [bumpFocusRequestId, storeSetActiveTerminal, threadRef], - ); - - const closeTerminal = useCallback( - (terminalId: string) => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) return; - const isFinalTerminal = terminalState.terminalIds.length <= 1; - const fallbackExitWrite = () => - api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined); - - if ("close" in api.terminal && typeof api.terminal.close === "function") { - void (async () => { - if (isFinalTerminal) { - await api.terminal.clear({ threadId, terminalId }).catch(() => undefined); - } - await api.terminal.close({ - threadId, - terminalId, - deleteHistory: true, - }); - })().catch(() => fallbackExitWrite()); - } else { - void fallbackExitWrite(); - } - - storeCloseTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef], - ); - - const handleAddTerminalContext = useCallback( - (selection: TerminalContextSelection) => { - if (!visible) { - return; - } - onAddTerminalContext(selection); - }, - [onAddTerminalContext, visible], - ); - - if (!project || !terminalState.terminalOpen || !cwd) { - return null; - } - - return ( -
- -
- ); -}); - export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -656,9 +712,6 @@ export default function ChatView(props: ChatViewProps) { ); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); - const setComposerDraftTerminalContexts = useComposerDraftStore( - (store) => store.setTerminalContexts, - ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( @@ -682,7 +735,6 @@ export default function ChatView(props: ChatViewProps) { ); const promptRef = useRef(""); const composerImagesRef = useRef([]); - const composerTerminalContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -711,76 +763,75 @@ export default function ChatView(props: ChatViewProps) { // When set, the thread-change reset effect will open the sidebar instead of closing it. // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. const planSidebarOpenOnNextThreadRef = useRef(false); - const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); - const [terminalLaunchContext, setTerminalLaunchContext] = useState( - null, - ); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = useState(null); const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - {}, - LastInvokedScriptByProjectSchema, - [LEGACY_LAST_INVOKED_SCRIPT_BY_PROJECT_KEY], - ); const legendListRef = useRef(null); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); - const terminalOpenByThreadRef = useRef>({}); + const queueDispatchInFlightRef = useRef(false); + const [desktopDebugEnabled, setDesktopDebugEnabled] = useState(false); + const [desktopDebugRevision, setDesktopDebugRevision] = useState(0); + const followUpQueueDebugRef = useRef({ + watchdogIntervalMs: FOLLOW_UP_QUEUE_WATCHDOG_INTERVAL_MS, + watchdogTickCount: 0, + lastTickAt: null as string | null, + lastAttemptAt: null as string | null, + lastAttemptSource: null as string | null, + lastAttemptResult: null as string | null, + lastAttemptThreadId: null as string | null, + lastAttemptItemId: null as string | null, + }); + const [dispatchGateRevision, setDispatchGateRevision] = useState(0); + const setSendInFlight = useCallback((next: boolean) => { + if (sendInFlightRef.current === next) return; + sendInFlightRef.current = next; + setDispatchGateRevision((revision) => revision + 1); + }, []); + const setQueueDispatchInFlight = useCallback((next: boolean) => { + if (queueDispatchInFlightRef.current === next) return; + queueDispatchInFlightRef.current = next; + setDispatchGateRevision((revision) => revision + 1); + }, []); + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge?.getDebugEndpointState) { + return; + } - const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), - ); - const openTerminalThreadKeys = useTerminalStateStore( - useShallow((state) => - Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) => - nextTerminalState.terminalOpen ? [nextThreadKey] : [], - ), - ), - ); - const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); - const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); - const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); - const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); - const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); - const serverThreadKeys = useStore( - useShallow((state) => - selectThreadsAcrossEnvironments(state).map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ), - ); - const storeServerTerminalLaunchContext = useTerminalStateStore( - (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null, - ); - const storeClearTerminalLaunchContext = useTerminalStateStore( - (s) => s.clearTerminalLaunchContext, - ); - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftThreadKeys = useMemo( - () => - Object.values(draftThreadsByThreadKey).map((draftThread) => - scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), - ), - [draftThreadsByThreadKey], - ); - const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); - const mountedTerminalThreadRefs = useMemo( - () => - mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }), - [mountedTerminalThreadKeys], + let cancelled = false; + void bridge + .getDebugEndpointState() + .then((debugState) => { + if (!cancelled) { + setDesktopDebugEnabled(debugState.enabled); + } + }) + .catch(() => { + if (!cancelled) { + setDesktopDebugEnabled(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); + const dispatchFollowUpTurnStartRef = useRef<((item: FollowUpQueueItem) => Promise) | null>( + null, ); + const [followUpQueueByThreadId, setFollowUpQueueByThreadId] = useState< + Record + >({}); + const followUpQueueByThreadIdRef = useRef(followUpQueueByThreadId); + followUpQueueByThreadIdRef.current = followUpQueueByThreadId; const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) @@ -815,16 +866,61 @@ export default function ChatView(props: ChatViewProps) { const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; + // Compute the list of environments this logical project spans, used to + // drive the environment picker in BranchToolbar. + const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const allThreads = useStore(useShallow(selectThreadsAcrossEnvironments)); const activeThreadId = activeThread?.id ?? null; - const activeThreadRef = useMemo( - () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), - [activeThread], - ); - const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; - const existingOpenTerminalThreadKeys = useMemo(() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); + const recordFollowUpQueueDebugAttempt = useCallback( + ( + source: string, + result: string, + details: { readonly threadId?: ThreadId | null; readonly itemId?: string | null } = {}, + ) => { + const now = new Date().toISOString(); + followUpQueueDebugRef.current = { + ...followUpQueueDebugRef.current, + watchdogTickCount: + source === "watchdog" + ? followUpQueueDebugRef.current.watchdogTickCount + 1 + : followUpQueueDebugRef.current.watchdogTickCount, + lastTickAt: source === "watchdog" ? now : followUpQueueDebugRef.current.lastTickAt, + lastAttemptAt: now, + lastAttemptSource: source, + lastAttemptResult: result, + lastAttemptThreadId: details.threadId ?? activeThreadId, + lastAttemptItemId: details.itemId ?? null, + }; + if (desktopDebugEnabled) { + setDesktopDebugRevision((revision) => revision + 1); + } + }, + [activeThreadId, desktopDebugEnabled], + ); + const knownThreadIds = useMemo( + () => new Set(allThreads.map((thread) => thread.id)), + [allThreads], + ); + const previousActiveThreadIdRef = useRef(null); + useEffect(() => { + if (!activeThreadId) { + return; + } + + const previousActiveThreadId = previousActiveThreadIdRef.current; + if (previousActiveThreadId !== activeThreadId) { + previousActiveThreadIdRef.current = activeThreadId; + } + + setFollowUpQueueByThreadId((existing) => + rekeyQueuedFollowUpsForActiveThread({ + queuesByThreadId: existing, + activeThreadId, + previousActiveThreadId, + knownThreadIds, + }), + ); + }, [activeThreadId, knownThreadIds]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -839,21 +935,6 @@ export default function ChatView(props: ChatViewProps) { return threadIds; }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), ); - useEffect(() => { - setMountedTerminalThreadKeys((currentThreadIds) => { - const nextThreadIds = reconcileMountedTerminalThreadIds({ - currentThreadIds, - openThreadIds: existingOpenTerminalThreadKeys, - activeThreadId: activeThreadKey, - activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen), - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - }); - return currentThreadIds.length === nextThreadIds.length && - currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) - ? currentThreadIds - : nextThreadIds; - }); - }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) @@ -869,9 +950,6 @@ export default function ChatView(props: ChatViewProps) { return retainThreadDetailSubscription(environmentId, threadId); }, [environmentId, routeKind, threadId]); - // Compute the list of environments this logical project spans, used to - // drive the environment picker in BranchToolbar. - const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); @@ -1351,6 +1429,7 @@ export default function ChatView(props: ChatViewProps) { localDispatchStartedAt, isPreparingWorktree, isSendBusy, + serverAcknowledgedLocalDispatch, } = useLocalDispatchState({ activeThread, activeLatestTurn, @@ -1359,6 +1438,12 @@ export default function ChatView(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError: activeThread?.error, }); + useEffect(() => { + if (serverAcknowledgedLocalDispatch) { + setSendInFlight(false); + setQueueDispatchInFlight(false); + } + }, [serverAcknowledgedLocalDispatch, setQueueDispatchInFlight, setSendInFlight]); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -1404,6 +1489,11 @@ export default function ChatView(props: ChatViewProps) { for (const message of optimisticUserMessagesRef.current) { revokeUserMessagePreviewUrls(message); } + for (const items of Object.values(followUpQueueByThreadIdRef.current)) { + for (const item of items) { + revokeQueuedFollowUpPreviewUrls(item); + } + } }; }, [clearAttachmentPreviewHandoffs]); const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { @@ -1626,12 +1716,7 @@ export default function ChatView(props: ChatViewProps) { if (!completionSummary) return null; return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn); }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]); - const gitCwd = activeProject - ? projectScriptCwd({ - project: { cwd: activeProject.cwd }, - worktreePath: activeThread?.worktreePath ?? null, - }) - : null; + const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); @@ -1653,52 +1738,479 @@ export default function ChatView(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const activeProviderLiveSteerSupported = + activeProviderStatus?.runtimeCapabilities?.liveSteer === "supported"; + const activeFollowUpQueue = + activeThreadId !== null + ? (followUpQueueByThreadId[activeThreadId] ?? EMPTY_FOLLOW_UP_QUEUE) + : EMPTY_FOLLOW_UP_QUEUE; + const activeQueueTurnId = activeThread?.session?.activeTurnId ?? null; + const followUpQueuePhase = resolveFollowUpQueuePhase({ + phase, + latestTurn: activeLatestTurn, + activeTurnId: activeQueueTurnId, + }); + const followUpQueueUiIdle = followUpQueuePhase !== "running"; + const followUpQueueVisibleWorking = + followUpQueuePhase === "running" || isConnecting || isRevertingCheckpoint; + const firstActiveFollowUpQueueItem = activeFollowUpQueue[0] ?? null; + const followUpQueueDispatchInFlight = queueDispatchInFlightRef.current; + const followUpQueueCanStartTurn = canStartQueuedFollowUpTurn({ + queueLength: activeFollowUpQueue.length, + firstItemBlocked: firstActiveFollowUpQueueItem?.blockedReason != null, + isWorking: followUpQueueVisibleWorking, + isConnecting, + isEnvironmentUnavailable: activeEnvironmentUnavailable, + isDispatchInFlight: followUpQueueDispatchInFlight, + }); + const followUpQueueViewItems = useMemo( + () => + activeFollowUpQueue.map((item) => ({ + id: item.id, + preview: previewQueuedFollowUpText(item.promptText), + promptText: item.promptText, + images: item.images, + queuedAt: item.queuedAt, + expanded: item.expanded, + canExpand: canExpandQueuedFollowUpText(item.promptText) || item.images.length > 0, + blockedReason: item.blockedReason, + })), + [activeFollowUpQueue], + ); + const canSteerFollowUpQueue = + followUpQueuePhase === "running" && + activeProviderLiveSteerSupported && + !isConnecting && + !activeEnvironmentUnavailable && + !followUpQueueDispatchInFlight; + const canActivateRunningFollowUpQueueAction = + followUpQueuePhase === "running" && + activeThread?.session?.status === "running" && + !isConnecting && + !activeEnvironmentUnavailable && + !followUpQueueDispatchInFlight; + const followUpQueueActionLabel = queuedFollowUpActionLabel({ + phase: followUpQueuePhase, + liveSteerSupported: activeProviderLiveSteerSupported, + }); + const followUpQueueActionTitle = queuedFollowUpActionTitle({ + phase: followUpQueuePhase, + liveSteerSupported: activeProviderLiveSteerSupported, + }); + useEffect(() => { + if (activeFollowUpQueue.length > 0 && followUpQueueUiIdle && sendInFlightRef.current) { + setSendInFlight(false); + } + }, [activeFollowUpQueue.length, followUpQueueUiIdle, setSendInFlight]); + useEffect(() => { + if (!desktopDebugEnabled) { + return; + } + const bridge = window.desktopBridge; + if (!bridge?.publishDebugSnapshot) { + return; + } + + const firstItem = firstActiveFollowUpQueueItem; + const queueBlockers: string[] = []; + if (activeFollowUpQueue.length === 0) { + queueBlockers.push("queue-empty"); + } + if (firstItem?.blockedReason) { + queueBlockers.push("first-item-blocked"); + } + if (followUpQueueVisibleWorking) { + queueBlockers.push("thread-visible-working"); + } + if (isConnecting) { + queueBlockers.push("environment-connecting"); + } + if (activeEnvironmentUnavailable) { + queueBlockers.push("environment-unavailable"); + } + if (followUpQueueDispatchInFlight) { + queueBlockers.push("queue-dispatch-in-flight"); + } + + const recentMessages = activeThread?.messages.slice(-DEBUG_RECENT_MESSAGE_LIMIT) ?? []; + const recentActivities = activeThread?.activities.slice(-DEBUG_RECENT_ACTIVITY_LIMIT) ?? []; + const runtimeActivities = + activeThread?.activities.filter( + (activity) => activity.kind === "runtime.warning" || activity.kind === "runtime.error", + ) ?? []; + const queueEntries = Object.entries(followUpQueueByThreadIdRef.current); + const orphanQueueEntries = queueEntries.filter( + ([queuedThreadId, items]) => + queuedThreadId !== activeThreadId && + !knownThreadIds.has(queuedThreadId) && + items.length > 0, + ); + const staleCompletedActiveTurn = + activeThread?.session?.status === "running" && + activeThread.session.activeTurnId != null && + activeLatestTurn?.turnId === activeThread.session.activeTurnId && + activeLatestTurn.state === "completed" && + activeLatestTurn.completedAt != null; + const latestTurnId = activeLatestTurn?.turnId ?? null; + const latestTurnCompletedAt = activeLatestTurn?.completedAt ?? null; + const activitiesAfterLatestTurnCompleted = + activeThread && latestTurnId !== null && latestTurnCompletedAt !== null + ? activeThread.activities.filter( + (activity) => + activity.turnId === latestTurnId && activity.createdAt > latestTurnCompletedAt, + ) + : []; + const latestActivityAfterLatestTurnCompleted = + activitiesAfterLatestTurnCompleted.at(-1) ?? null; + const queuedThreadIds = new Set( + queueEntries + .filter(([, items]) => items.length > 0) + .map(([queuedThreadId]) => queuedThreadId), + ); + const lifecycleByThreadId = new Map( + allThreads.map((thread) => [thread.id, summarizeDebugThreadLifecycle(thread)] as const), + ); + const activeLifecycleSummary = activeThread + ? (lifecycleByThreadId.get(activeThread.id) ?? null) + : null; + const lifecycleSummaries = Array.from(lifecycleByThreadId.values()); + const interestingLifecycleThreads = lifecycleSummaries + .filter( + (thread) => + thread.id === activeThreadId || + queuedThreadIds.has(thread.id) || + thread.redFlags.length > 0 || + thread.isSessionRunning || + thread.isLatestTurnRunning || + thread.hasUnsettledLatestTurn || + thread.streamingMessageCount > 0 || + thread.session?.status === "error", + ) + .slice(0, DEBUG_INTERESTING_THREAD_LIMIT); + const lifecycleQueueRedFlags = [ + activeFollowUpQueue.length > 0 && followUpQueueUiIdle && !followUpQueueCanStartTurn + ? "queue-has-items-but-cannot-start-while-idle" + : null, + activeLifecycleSummary?.phase === "running" && !isWorking + ? "ui-idle-while-session-running" + : null, + activeLifecycleSummary !== null && + activeLifecycleSummary.streamingMessageCount > 0 && + followUpQueueUiIdle + ? "queue-sees-idle-while-message-streaming" + : null, + ].filter((value): value is string => value !== null); + + const snapshot: DesktopRendererDebugSnapshot = { + debugSnapshotVersion: DEBUG_SNAPSHOT_VERSION, + source: "ChatView", + capturedAt: new Date().toISOString(), + diagnostics: { + location: { + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + }, + visibilityState: document.visibilityState, + hasFocus: document.hasFocus(), + online: navigator.onLine, + }, + store: { + projectCount: allProjects.length, + threadCount: allThreads.length, + activeThreadCount: allThreads.filter((thread) => thread.archivedAt === null).length, + archivedThreadCount: allThreads.filter((thread) => thread.archivedAt !== null).length, + threadsWithSessions: allThreads.filter((thread) => thread.session !== null).length, + runningThreadIds: allThreads + .filter((thread) => thread.session?.status === "running") + .map((thread) => thread.id), + errorThreadIds: allThreads + .filter((thread) => thread.session?.status === "error" || thread.error !== null) + .map((thread) => thread.id), + messageRoleCounts: activeThread + ? countBy(activeThread.messages, (message) => message.role) + : {}, + activityKindCounts: activeThread + ? countBy(activeThread.activities, (activity) => activity.kind) + : {}, + }, + route: { + routeKind, + environmentId, + routeThreadId: threadId, + activeThreadId, + isServerThread, + isLocalDraftThread, + }, + project: activeProject + ? { + id: activeProject.id, + name: activeProject.name, + cwd: activeProject.cwd, + } + : null, + thread: activeThread + ? { + id: activeThread.id, + title: activeThread.title, + projectId: activeThread.projectId, + worktreePath: activeThread.worktreePath, + runtimeMode: activeThread.runtimeMode, + interactionMode: activeThread.interactionMode, + error: activeThread.error ?? null, + messageCount: activeThread.messages.length, + activityCount: activeThread.activities.length, + session: activeThread.session, + latestTurn: activeLatestTurn, + latestTurnSettled, + consistency: { + staleCompletedActiveTurn, + sessionActiveTurnMatchesLatestTurn: + activeThread.session?.activeTurnId != null && + activeLatestTurn?.turnId === activeThread.session.activeTurnId, + latestTurnCompletedButSessionRunning: + activeLatestTurn?.completedAt != null && activeThread.session?.status === "running", + activityAfterLatestTurnCompletedCount: activitiesAfterLatestTurnCompleted.length, + latestActivityAfterLatestTurnCompleted: + latestActivityAfterLatestTurnCompleted !== null + ? summarizeDebugActivity(latestActivityAfterLatestTurnCompleted) + : null, + }, + recentMessages: recentMessages.map(summarizeDebugMessage), + recentActivities: recentActivities.map(summarizeDebugActivity), + recentRuntimeEvents: runtimeActivities + .slice(-DEBUG_RECENT_RUNTIME_EVENT_LIMIT) + .map(summarizeDebugActivity), + turnDiffSummaries: activeThread.turnDiffSummaries + .slice(-10) + .map(summarizeDebugTurnDiff), + } + : null, + lifecycle: { + active: activeLifecycleSummary, + counts: { + sessionsRunning: lifecycleSummaries.filter((thread) => thread.isSessionRunning).length, + sessionsWithActiveTurn: lifecycleSummaries.filter( + (thread) => thread.activeTurnId !== null, + ).length, + latestTurnsRunning: lifecycleSummaries.filter((thread) => thread.isLatestTurnRunning) + .length, + unsettledLatestTurns: lifecycleSummaries.filter((thread) => thread.hasUnsettledLatestTurn) + .length, + threadsWithStreamingMessages: lifecycleSummaries.filter( + (thread) => thread.streamingMessageCount > 0, + ).length, + streamingMessages: lifecycleSummaries.reduce( + (total, thread) => total + thread.streamingMessageCount, + 0, + ), + staleCompletedActiveTurns: lifecycleSummaries.filter( + (thread) => thread.staleCompletedActiveTurn, + ).length, + latestCompletedButSessionRunning: lifecycleSummaries.filter( + (thread) => thread.latestTurnCompletedButSessionRunning, + ).length, + latestRunningButSessionNotRunning: lifecycleSummaries.filter( + (thread) => thread.latestTurnRunningButSessionNotRunning, + ).length, + redFlagThreads: lifecycleSummaries.filter((thread) => thread.redFlags.length > 0).length, + }, + interestingThreadLimit: DEBUG_INTERESTING_THREAD_LIMIT, + interestingThreads: interestingLifecycleThreads, + queueCoupling: { + activeThreadId, + activeQueueTurnId, + activeQueueLength: activeFollowUpQueue.length, + queueBlockers, + followUpQueuePhase, + followUpQueueUiIdle, + followUpQueueVisibleWorking, + followUpQueueCanStartTurn, + followUpQueueDispatchInFlight, + canSteerFollowUpQueue, + canActivateRunningFollowUpQueueAction, + followUpQueueActionLabel, + activeProviderLiveSteerSupported, + uiWorking: isWorking, + activeTurnInProgress: isWorking || !latestTurnSettled, + isConnecting, + isRevertingCheckpoint, + activeEnvironmentUnavailable, + redFlags: lifecycleQueueRedFlags, + }, + localDispatch: { + isSendBusy, + sendInFlightRef: sendInFlightRef.current, + queueDispatchInFlightRef: queueDispatchInFlightRef.current, + dispatchGateRevision, + desktopDebugRevision, + serverAcknowledgedLocalDispatch, + localDispatchStartedAt, + activePendingApprovalRequestId: activePendingApproval?.requestId ?? null, + activePendingUserInputRequestId: activePendingUserInput?.requestId ?? null, + }, + }, + provider: { + selectedProvider, + activeProviderInstanceId, + activeProviderLiveSteerSupported, + activeProviderStatus: activeProviderStatus + ? { + instanceId: activeProviderStatus.instanceId, + driver: activeProviderStatus.driver, + displayName: activeProviderStatus.displayName ?? null, + enabled: activeProviderStatus.enabled, + installed: activeProviderStatus.installed, + status: activeProviderStatus.status, + availability: activeProviderStatus.availability ?? "available", + unavailableReason: activeProviderStatus.unavailableReason ?? null, + message: activeProviderStatus.message ?? null, + checkedAt: activeProviderStatus.checkedAt, + runtimeCapabilities: activeProviderStatus.runtimeCapabilities ?? null, + } + : null, + }, + queue: { + activeThreadId, + length: activeFollowUpQueue.length, + firstItemId: firstItem?.id ?? null, + firstItemBlockedReason: firstItem?.blockedReason ?? null, + canStartTurn: followUpQueueCanStartTurn, + blockers: queueBlockers, + orphanQueues: Object.fromEntries( + orphanQueueEntries.map(([queuedThreadId, items]) => [ + queuedThreadId, + { + length: items.length, + itemIds: items.map((item) => item.id), + promptPreviews: items.map((item) => + previewQueuedFollowUpText(item.promptText).slice(0, 240), + ), + }, + ]), + ), + allQueues: Object.fromEntries( + Object.entries(followUpQueueByThreadIdRef.current).map(([queuedThreadId, items]) => [ + queuedThreadId, + { + length: items.length, + firstItemId: items[0]?.id ?? null, + blockedReasons: items.map((item) => item.blockedReason), + items: items.map((item, index) => ({ + index, + id: item.id, + threadId: item.threadId, + queuedAt: item.queuedAt, + blockedReason: item.blockedReason, + promptLength: item.promptText.length, + promptPreview: previewQueuedFollowUpText(item.promptText).slice(0, 240), + imageCount: item.images.length, + provider: item.provider, + model: item.model, + })), + }, + ]), + ), + dispatchDebug: followUpQueueDebugRef.current, + items: activeFollowUpQueue.map((item, index) => ({ + index, + id: item.id, + threadId: item.threadId, + queuedAt: item.queuedAt, + blockedReason: item.blockedReason, + expanded: item.expanded, + promptLength: item.promptText.length, + promptPreview: previewQueuedFollowUpText(item.promptText).slice(0, 240), + imageCount: item.images.length, + provider: item.provider, + model: item.model, + modelSelection: item.modelSelection, + runtimeMode: item.runtimeMode, + interactionMode: item.interactionMode, + })), + }, + gates: { + phase, + followUpQueuePhase, + followUpQueueUiIdle, + followUpQueueVisibleWorking, + followUpQueueCanStartTurn, + followUpQueueDispatchInFlight, + canSteerFollowUpQueue, + canActivateRunningFollowUpQueueAction, + followUpQueueActionLabel, + isWorking, + isSendBusy, + hasEnvironmentApi: readEnvironmentApi(environmentId) !== null, + hasDispatchFollowUpTurnStart: dispatchFollowUpTurnStartRef.current !== null, + sendInFlightRef: sendInFlightRef.current, + queueDispatchInFlightRef: queueDispatchInFlightRef.current, + dispatchGateRevision, + desktopDebugRevision, + isConnecting, + isRevertingCheckpoint, + activeEnvironmentUnavailable, + serverAcknowledgedLocalDispatch, + localDispatchStartedAt, + activeQueueTurnId, + activePendingApprovalRequestId: activePendingApproval?.requestId ?? null, + activePendingUserInputRequestId: activePendingUserInput?.requestId ?? null, + }, + }; + + void bridge.publishDebugSnapshot(snapshot).catch(() => undefined); + }, [ + activeEnvironmentUnavailable, + activeFollowUpQueue, + activeLatestTurn, + activePendingApproval?.requestId, + activePendingUserInput?.requestId, + activeProject, + activeProviderInstanceId, + activeProviderLiveSteerSupported, + activeProviderStatus, + activeQueueTurnId, + activeThread, + activeThreadId, + allProjects, + allThreads, + canActivateRunningFollowUpQueueAction, + canSteerFollowUpQueue, + desktopDebugEnabled, + desktopDebugRevision, + dispatchGateRevision, + environmentId, + firstActiveFollowUpQueueItem, + followUpQueueActionLabel, + followUpQueueCanStartTurn, + followUpQueueDispatchInFlight, + followUpQueuePhase, + followUpQueueUiIdle, + followUpQueueVisibleWorking, + isConnecting, + isLocalDraftThread, + isRevertingCheckpoint, + isSendBusy, + isServerThread, + isWorking, + knownThreadIds, + latestTurnSettled, + localDispatchStartedAt, + phase, + routeKind, + selectedProvider, + serverAcknowledgedLocalDispatch, + threadId, + ]); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; - const activeTerminalLaunchContext = - terminalLaunchContext?.threadId === activeThreadId - ? terminalLaunchContext - : (storeServerTerminalLaunchContext ?? null); // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const terminalShortcutLabelOptions = useMemo( - () => ({ - context: { - terminalFocus: true, - terminalOpen: Boolean(terminalState.terminalOpen), - }, - }), - [terminalState.terminalOpen], - ); - const nonTerminalShortcutLabelOptions = useMemo( - () => ({ - context: { - terminalFocus: false, - terminalOpen: Boolean(terminalState.terminalOpen), - }, - }), - [terminalState.terminalOpen], - ); - const terminalToggleShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.toggle"), - [keybindings], - ); - const splitTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), - [keybindings, terminalShortcutLabelOptions], - ); - const newTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), - [keybindings, terminalShortcutLabelOptions], - ); - const closeTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), - [keybindings, terminalShortcutLabelOptions], - ); + const shortcutLabelOptions = useMemo(() => ({ context: {} }), []); const diffPanelShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), - [keybindings, nonTerminalShortcutLabelOptions], + () => shortcutLabelForCommand(keybindings, "diff.toggle", shortcutLabelOptions), + [keybindings, shortcutLabelOptions], ); const onToggleDiff = useCallback(() => { if (!isServerThread) { @@ -1744,16 +2256,6 @@ export default function ChatView(props: ChatViewProps) { [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], ); - const activeTerminalGroup = - terminalState.terminalGroups.find( - (group) => group.id === terminalState.activeTerminalGroupId, - ) ?? - terminalState.terminalGroups.find((group) => - group.terminalIds.includes(terminalState.activeTerminalId), - ) ?? - null; - const hasReachedSplitLimit = - (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; @@ -1782,316 +2284,13 @@ export default function ChatView(props: ChatViewProps) { ); const focusComposer = useCallback(() => { - composerRef.current?.focusAtEnd(); - }, []); + readComposerHandle(composerRef)?.focusAtEnd(); + }, [composerRef]); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); - const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => { - composerRef.current?.addTerminalContext(selection); - }, []); - const setTerminalOpen = useCallback( - (open: boolean) => { - if (!activeThreadRef) return; - storeSetTerminalOpen(activeThreadRef, open); - }, - [activeThreadRef, storeSetTerminalOpen], - ); - const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadRef) return; - setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]); - const splitTerminal = useCallback(() => { - if (!activeThreadRef || hasReachedSplitLimit) return; - const terminalId = `terminal-${randomUUID()}`; - storeSplitTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]); - const createNewTerminal = useCallback(() => { - if (!activeThreadRef) return; - const terminalId = `terminal-${randomUUID()}`; - storeNewTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadRef, storeNewTerminal]); - const closeTerminal = useCallback( - (terminalId: string) => { - const api = readEnvironmentApi(environmentId); - if (!activeThreadId || !api) return; - const isFinalTerminal = terminalState.terminalIds.length <= 1; - const fallbackExitWrite = () => - api.terminal - .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) - .catch(() => undefined); - if ("close" in api.terminal && typeof api.terminal.close === "function") { - void (async () => { - if (isFinalTerminal) { - await api.terminal - .clear({ threadId: activeThreadId, terminalId }) - .catch(() => undefined); - } - await api.terminal.close({ - threadId: activeThreadId, - terminalId, - deleteHistory: true, - }); - })().catch(() => fallbackExitWrite()); - } else { - void fallbackExitWrite(); - } - if (activeThreadRef) { - storeCloseTerminal(activeThreadRef, terminalId); - } - setTerminalFocusRequestId((value) => value + 1); - }, - [ - activeThreadId, - activeThreadRef, - environmentId, - storeCloseTerminal, - terminalState.terminalIds.length, - ], - ); - const runProjectScript = useCallback( - async ( - script: ProjectScript, - options?: { - cwd?: string; - env?: Record; - worktreePath?: string | null; - preferNewTerminal?: boolean; - rememberAsLastInvoked?: boolean; - }, - ) => { - const api = readEnvironmentApi(environmentId); - if (!api || !activeThreadId || !activeProject || !activeThread) return; - if (options?.rememberAsLastInvoked !== false) { - setLastInvokedScriptByProjectId((current) => { - if (current[activeProject.id] === script.id) return current; - return { ...current, [activeProject.id]: script.id }; - }); - } - const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; - const baseTerminalId = - terminalState.activeTerminalId || - terminalState.terminalIds[0] || - DEFAULT_THREAD_TERMINAL_ID; - const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); - const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; - const shouldCreateNewTerminal = wantsNewTerminal; - const targetTerminalId = shouldCreateNewTerminal - ? `terminal-${randomUUID()}` - : baseTerminalId; - const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; - - setTerminalLaunchContext({ - threadId: activeThreadId, - cwd: targetCwd, - worktreePath: targetWorktreePath, - }); - setTerminalOpen(true); - if (!activeThreadRef) { - return; - } - if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadRef, targetTerminalId); - } else { - storeSetActiveTerminal(activeThreadRef, targetTerminalId); - } - setTerminalFocusRequestId((value) => value + 1); - - const runtimeEnv = projectScriptRuntimeEnv({ - project: { - cwd: activeProject.cwd, - }, - worktreePath: targetWorktreePath, - ...(options?.env ? { extraEnv: options.env } : {}), - }); - const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal - ? { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, - cols: SCRIPT_TERMINAL_COLS, - rows: SCRIPT_TERMINAL_ROWS, - } - : { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, - }; - - try { - await api.terminal.open(openTerminalInput); - await api.terminal.write({ - threadId: activeThreadId, - terminalId: targetTerminalId, - data: `${script.command}\r`, - }); - } catch (error) { - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } - }, - [ - activeProject, - activeThread, - activeThreadId, - activeThreadRef, - gitCwd, - setTerminalOpen, - setThreadError, - storeNewTerminal, - storeSetActiveTerminal, - setLastInvokedScriptByProjectId, - environmentId, - terminalState.activeTerminalId, - terminalState.runningTerminalIds, - terminalState.terminalIds, - ], - ); - - const persistProjectScripts = useCallback( - async (input: { - projectId: ProjectId; - projectCwd: string; - previousScripts: ProjectScript[]; - nextScripts: ProjectScript[]; - keybinding?: string | null; - keybindingCommand: KeybindingCommand; - }) => { - const api = readEnvironmentApi(environmentId); - if (!api) return; - - await api.orchestration.dispatchCommand({ - type: "project.meta.update", - commandId: newCommandId(), - projectId: input.projectId, - scripts: input.nextScripts, - }); - - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding: input.keybinding, - command: input.keybindingCommand, - }); - - if (isElectron && keybindingRule) { - const localApi = readLocalApi(); - if (!localApi) { - throw new Error("Local API unavailable."); - } - await localApi.server.upsertKeybinding(keybindingRule); - } - }, - [environmentId], - ); - const saveProjectScript = useCallback( - async (input: NewProjectScriptInput) => { - if (!activeProject) return; - const nextId = nextProjectScriptId( - input.name, - activeProject.scripts.map((script) => script.id), - ); - const nextScript: ProjectScript = { - id: nextId, - name: input.name, - command: input.command, - icon: input.icon, - runOnWorktreeCreate: input.runOnWorktreeCreate, - }; - const nextScripts = input.runOnWorktreeCreate - ? [ - ...activeProject.scripts.map((script) => - script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, - ), - nextScript, - ] - : [...activeProject.scripts, nextScript]; - - await persistProjectScripts({ - projectId: activeProject.id, - projectCwd: activeProject.cwd, - previousScripts: activeProject.scripts, - nextScripts, - keybinding: input.keybinding, - keybindingCommand: commandForProjectScript(nextId), - }); - }, - [activeProject, persistProjectScripts], - ); - const updateProjectScript = useCallback( - async (scriptId: string, input: NewProjectScriptInput) => { - if (!activeProject) return; - const existingScript = activeProject.scripts.find((script) => script.id === scriptId); - if (!existingScript) { - throw new Error("Script not found."); - } - - const updatedScript: ProjectScript = { - ...existingScript, - name: input.name, - command: input.command, - icon: input.icon, - runOnWorktreeCreate: input.runOnWorktreeCreate, - }; - const nextScripts = activeProject.scripts.map((script) => - script.id === scriptId - ? updatedScript - : input.runOnWorktreeCreate - ? { ...script, runOnWorktreeCreate: false } - : script, - ); - - await persistProjectScripts({ - projectId: activeProject.id, - projectCwd: activeProject.cwd, - previousScripts: activeProject.scripts, - nextScripts, - keybinding: input.keybinding, - keybindingCommand: commandForProjectScript(scriptId), - }); - }, - [activeProject, persistProjectScripts], - ); - const deleteProjectScript = useCallback( - async (scriptId: string) => { - if (!activeProject) return; - const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); - - const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; - - try { - await persistProjectScripts({ - projectId: activeProject.id, - projectCwd: activeProject.cwd, - previousScripts: activeProject.scripts, - nextScripts, - keybinding: null, - keybindingCommand: commandForProjectScript(scriptId), - }); - toastManager.add({ - type: "success", - title: `Deleted action "${deletedName ?? "Unknown"}"`, - }); - } catch (error) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not delete action", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - } - }, - [activeProject, persistProjectScripts], - ); const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { @@ -2204,28 +2403,40 @@ export default function ChatView(props: ChatViewProps) { [environmentId, serverThread], ); - // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. - const scrollToEnd = useCallback((animated = false) => { - legendListRef.current?.scrollToEnd?.({ animated }); - }, []); - // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during // thread switches. LegendList fires scroll events with isAtEnd=false while // initialScrollAtEnd is settling; hiding is always immediate. const showScrollDebouncer = useRef( new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); - const onIsAtEndChange = useCallback((isAtEnd: boolean) => { - if (isAtEndRef.current === isAtEnd) return; - isAtEndRef.current = isAtEnd; - if (isAtEnd) { - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - } else { - showScrollDebouncer.current.maybeExecute(); - } + const hideScrollToBottom = useCallback(() => { + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); }, []); + // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. + const scrollToEnd = useCallback( + (animated = false) => { + hideScrollToBottom(); + void legendListRef.current?.scrollToEnd?.({ animated }); + }, + [hideScrollToBottom], + ); + const onIsAtEndChange = useCallback( + (isAtEnd: boolean) => { + if (isAtEnd) { + hideScrollToBottom(); + return; + } + + if (isAtEndRef.current === false) return; + isAtEndRef.current = false; + showScrollDebouncer.current.maybeExecute(); + }, + [hideScrollToBottom], + ); + useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; @@ -2265,14 +2476,14 @@ export default function ChatView(props: ChatViewProps) { }, [activeThread?.id]); useEffect(() => { - if (!activeThread?.id || terminalState.terminalOpen) return; + if (!activeThread?.id) return; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposer, terminalState.terminalOpen]); + }, [activeThread?.id, focusComposer]); useEffect(() => { if (!activeThread?.id) return; @@ -2355,121 +2566,13 @@ export default function ChatView(props: ChatViewProps) { setPendingServerThreadBranch(undefined); }, [canOverrideServerThreadEnvMode]); - useEffect(() => { - if (!activeThreadId) { - setTerminalLaunchContext(null); - storeClearTerminalLaunchContext(routeThreadRef); - return; - } - setTerminalLaunchContext((current) => { - if (!current) return current; - if (current.threadId === activeThreadId) return current; - return null; - }); - }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]); - - useEffect(() => { - if (!activeThreadId || !activeProjectCwd) { - return; - } - setTerminalLaunchContext((current) => { - if (!current || current.threadId !== activeThreadId) { - return current; - } - const settledCwd = projectScriptCwd({ - project: { cwd: activeProjectCwd }, - worktreePath: activeThreadWorktreePath, - }); - if ( - settledCwd === current.cwd && - (activeThreadWorktreePath ?? null) === current.worktreePath - ) { - if (activeThreadRef) { - storeClearTerminalLaunchContext(activeThreadRef); - } - return null; - } - return current; - }); - }, [ - activeProjectCwd, - activeThreadId, - activeThreadRef, - activeThreadWorktreePath, - storeClearTerminalLaunchContext, - ]); - - useEffect(() => { - if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { - return; - } - const settledCwd = projectScriptCwd({ - project: { cwd: activeProjectCwd }, - worktreePath: activeThreadWorktreePath, - }); - if ( - settledCwd === storeServerTerminalLaunchContext.cwd && - (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath - ) { - if (activeThreadRef) { - storeClearTerminalLaunchContext(activeThreadRef); - } - } - }, [ - activeProjectCwd, - activeThreadId, - activeThreadRef, - activeThreadWorktreePath, - storeClearTerminalLaunchContext, - storeServerTerminalLaunchContext, - ]); - - useEffect(() => { - if (terminalState.terminalOpen) { - return; - } - if (activeThreadRef) { - storeClearTerminalLaunchContext(activeThreadRef); - } - setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); - }, [ - activeThreadId, - activeThreadRef, - storeClearTerminalLaunchContext, - terminalState.terminalOpen, - ]); - - useEffect(() => { - if (!activeThreadKey) return; - const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; - const current = Boolean(terminalState.terminalOpen); - - if (!previous && current) { - terminalOpenByThreadRef.current[activeThreadKey] = current; - setTerminalFocusRequestId((value) => value + 1); - return; - } else if (previous && !current) { - terminalOpenByThreadRef.current[activeThreadKey] = current; - const frame = window.requestAnimationFrame(() => { - focusComposer(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - } - - terminalOpenByThreadRef.current[activeThreadKey] = current; - }, [activeThreadKey, focusComposer, terminalState.terminalOpen]); - useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { return; } const shortcutContext = { - terminalFocus: isTerminalFocused(), - terminalOpen: Boolean(terminalState.terminalOpen), - modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, + modelPickerOpen: readComposerHandle(composerRef)?.isModelPickerOpen() ?? false, }; const command = resolveShortcutCommand(event, keybindings, { @@ -2477,41 +2580,6 @@ export default function ChatView(props: ChatViewProps) { }); if (!command) return; - if (command === "terminal.toggle") { - event.preventDefault(); - event.stopPropagation(); - toggleTerminalVisibility(); - return; - } - - if (command === "terminal.split") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalState.terminalOpen) { - setTerminalOpen(true); - } - splitTerminal(); - return; - } - - if (command === "terminal.close") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalState.terminalOpen) return; - closeTerminal(terminalState.activeTerminalId); - return; - } - - if (command === "terminal.new") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalState.terminalOpen) { - setTerminalOpen(true); - } - createNewTerminal(); - return; - } - if (command === "diff.toggle") { event.preventDefault(); event.stopPropagation(); @@ -2522,34 +2590,13 @@ export default function ChatView(props: ChatViewProps) { if (command === "modelPicker.toggle") { event.preventDefault(); event.stopPropagation(); - composerRef.current?.toggleModelPicker(); + readComposerHandle(composerRef)?.toggleModelPicker(); return; } - - const scriptId = projectScriptIdFromCommand(command); - if (!scriptId || !activeProject) return; - const script = activeProject.scripts.find((entry) => entry.id === scriptId); - if (!script) return; - event.preventDefault(); - event.stopPropagation(); - void runProjectScript(script); }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [ - activeProject, - terminalState.terminalOpen, - terminalState.activeTerminalId, - activeThreadId, - closeTerminal, - createNewTerminal, - setTerminalOpen, - runProjectScript, - splitTerminal, - keybindings, - onToggleDiff, - toggleTerminalVisibility, - ]); + }, [activeThreadId, composerRef, keybindings, onToggleDiff]); const onRevertToTurnCount = useCallback( async (turnCount: number) => { @@ -2610,6 +2657,323 @@ export default function ChatView(props: ChatViewProps) { ], ); + const readComposerSnapshotForDispatch = (): ComposerSendSnapshot | null => { + const sendCtx = composerRef.current?.getSendContext(); + if (!sendCtx) return null; + return { + promptText: promptRef.current, + images: [...sendCtx.images], + provider: sendCtx.selectedProvider, + model: sendCtx.selectedModel, + providerModels: sendCtx.selectedProviderModels, + promptEffort: sendCtx.selectedPromptEffort, + modelSelection: sendCtx.selectedModelSelection, + runtimeMode, + interactionMode, + }; + }; + + const buildAttachmentsForSnapshot = async ( + snapshot: ComposerSendSnapshot, + ): Promise => + Promise.all( + snapshot.images.map(async (image) => ({ + type: "image" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + })), + ); + + const outgoingTextForSnapshot = (snapshot: ComposerSendSnapshot): string => + formatOutgoingPrompt({ + provider: snapshot.provider, + model: snapshot.model, + models: snapshot.providerModels, + effort: snapshot.promptEffort, + text: snapshot.promptText || IMAGE_ONLY_BOOTSTRAP_PROMPT, + }); + + const clearActiveComposerContent = () => { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + }; + + const restoreComposerSnapshotForRetry = (snapshot: ComposerSendSnapshot) => { + const retryComposerImages = snapshot.images.map(cloneComposerImageForRetry); + promptRef.current = snapshot.promptText; + composerImagesRef.current = retryComposerImages; + setComposerDraftPrompt(composerDraftTarget, snapshot.promptText); + addComposerDraftImages(composerDraftTarget, retryComposerImages); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(snapshot.promptText, snapshot.promptText.length), + prompt: snapshot.promptText, + detectTrigger: true, + }); + }; + + const enqueueFollowUpSnapshot = ( + snapshot: ComposerSendSnapshot, + options?: { unsupportedSteerToast?: boolean }, + ) => { + if (!activeThread) return; + const queuedAt = new Date().toISOString(); + const item: FollowUpQueueItem = { + ...snapshot, + id: newMessageId(), + threadId: activeThread.id, + queuedAt, + expanded: false, + blockedReason: null, + }; + setFollowUpQueueByThreadId((existing) => ({ + ...existing, + [activeThread.id]: [...(existing[activeThread.id] ?? EMPTY_FOLLOW_UP_QUEUE), item], + })); + setThreadError(activeThread.id, null); + clearActiveComposerContent(); + scheduleComposerFocus(); + if (options?.unsupportedSteerToast) { + toastManager.add({ + type: "info", + title: "Queued follow-up", + description: "This provider can only queue follow-ups right now.", + }); + } + }; + + const removeFollowUpQueueItem = (targetThreadId: ThreadId, itemId: string, revoke: boolean) => { + const removed = followUpQueueByThreadIdRef.current[targetThreadId]?.find( + (item) => item.id === itemId, + ); + if (removed && revoke) { + revokeQueuedFollowUpPreviewUrls(removed); + } + setFollowUpQueueByThreadId((existing) => { + const current = existing[targetThreadId] ?? EMPTY_FOLLOW_UP_QUEUE; + const nextItems = current.filter((item) => item.id !== itemId); + if (nextItems.length === current.length) return existing; + const next = { ...existing }; + if (nextItems.length === 0) { + delete next[targetThreadId]; + } else { + next[targetThreadId] = nextItems; + } + return next; + }); + }; + + const blockFollowUpQueueItem = (targetThreadId: ThreadId, itemId: string, reason: string) => { + setFollowUpQueueByThreadId((existing) => { + const current = existing[targetThreadId] ?? EMPTY_FOLLOW_UP_QUEUE; + let changed = false; + const nextItems: FollowUpQueueItem[] = []; + for (const item of current) { + if (item.id !== itemId || item.blockedReason === reason) { + nextItems.push(item); + continue; + } + changed = true; + nextItems.push({ ...item, blockedReason: reason }); + } + if (!changed) return existing; + return { + ...existing, + [targetThreadId]: nextItems, + }; + }); + }; + + const dispatchFollowUpTurnStart = async (item: FollowUpQueueItem) => { + const api = readEnvironmentApi(environmentId); + if (!api) { + blockFollowUpQueueItem(item.threadId, item.id, "Cafe Code is not connected."); + return; + } + if (!activeThread) { + blockFollowUpQueueItem(item.threadId, item.id, "Open this thread before sending."); + return; + } + if (!activeProject) { + blockFollowUpQueueItem(item.threadId, item.id, "Project metadata is not loaded yet."); + return; + } + if (queueDispatchInFlightRef.current) { + return; + } + + setQueueDispatchInFlight(true); + setSendInFlight(true); + beginLocalDispatch({ preparingWorktree: false }); + + const messageIdForSend = newMessageId(); + const messageCreatedAt = new Date().toISOString(); + const outgoingMessageText = outgoingTextForSnapshot(item); + const optimisticAttachments = optimisticAttachmentsForSnapshot(item); + const turnAttachmentsPromise = buildAttachmentsForSnapshot(item); + + removeFollowUpQueueItem(item.threadId, item.id, false); + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + await legendListRef.current?.scrollToEnd?.({ animated: false }); + setOptimisticUserMessages((existing) => [ + ...existing, + { + id: messageIdForSend, + role: "user", + text: outgoingMessageText, + ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), + createdAt: messageCreatedAt, + streaming: false, + }, + ]); + + let turnStartSucceeded = false; + try { + await persistThreadSettingsForNextTurn({ + threadId: item.threadId, + createdAt: messageCreatedAt, + modelSelection: item.modelSelection, + runtimeMode: item.runtimeMode, + interactionMode: item.interactionMode, + }); + const turnAttachments = await turnAttachmentsPromise; + await api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: item.threadId, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: turnAttachments, + }, + modelSelection: item.modelSelection, + titleSeed: activeThread.title, + runtimeMode: item.runtimeMode, + interactionMode: item.interactionMode, + createdAt: messageCreatedAt, + }); + turnStartSucceeded = true; + } catch (err) { + setOptimisticUserMessages((existing) => { + const removed = existing.filter((message) => message.id === messageIdForSend); + for (const message of removed) { + revokeUserMessagePreviewUrls(message); + } + return existing.filter((message) => message.id !== messageIdForSend); + }); + setFollowUpQueueByThreadId((existing) => ({ + ...existing, + [item.threadId]: [ + { + ...item, + blockedReason: err instanceof Error ? err.message : "Failed to send queued follow-up.", + }, + ...(existing[item.threadId] ?? EMPTY_FOLLOW_UP_QUEUE), + ], + })); + setThreadError( + item.threadId, + err instanceof Error ? err.message : "Failed to send queued follow-up.", + ); + } finally { + setSendInFlight(false); + if (!turnStartSucceeded) { + setQueueDispatchInFlight(false); + resetLocalDispatch(); + } + } + }; + dispatchFollowUpTurnStartRef.current = dispatchFollowUpTurnStart; + + const dispatchSteerSnapshot = async ( + snapshot: ComposerSendSnapshot, + options?: { queuedItem?: FollowUpQueueItem }, + ) => { + const api = readEnvironmentApi(environmentId); + if (!api || !activeThread) return; + if (!options?.queuedItem && sendInFlightRef.current) return; + if (!activeProviderLiveSteerSupported || phase !== "running") { + if (!options?.queuedItem) { + enqueueFollowUpSnapshot(snapshot, { unsupportedSteerToast: true }); + } + return; + } + + setSendInFlight(true); + const messageIdForSend = newMessageId(); + const messageCreatedAt = new Date().toISOString(); + const outgoingMessageText = outgoingTextForSnapshot(snapshot); + const optimisticAttachments = optimisticAttachmentsForSnapshot(snapshot); + const turnAttachmentsPromise = buildAttachmentsForSnapshot(snapshot); + + if (options?.queuedItem) { + removeFollowUpQueueItem(options.queuedItem.threadId, options.queuedItem.id, false); + } + + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + await legendListRef.current?.scrollToEnd?.({ animated: false }); + setOptimisticUserMessages((existing) => [ + ...existing, + { + id: messageIdForSend, + role: "user", + text: outgoingMessageText, + ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), + createdAt: messageCreatedAt, + streaming: false, + }, + ]); + + try { + const turnAttachments = await turnAttachmentsPromise; + await api.orchestration.dispatchCommand({ + type: "thread.turn.steer", + commandId: newCommandId(), + threadId: activeThread.id, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: turnAttachments, + }, + createdAt: messageCreatedAt, + }); + setThreadError(activeThread.id, null); + if (!options?.queuedItem) { + clearActiveComposerContent(); + } + } catch (err) { + setOptimisticUserMessages((existing) => { + const removed = existing.filter((message) => message.id === messageIdForSend); + for (const message of removed) { + revokeUserMessagePreviewUrls(message); + } + return existing.filter((message) => message.id !== messageIdForSend); + }); + if (options?.queuedItem) { + setFollowUpQueueByThreadId((existing) => ({ + ...existing, + [options.queuedItem!.threadId]: [ + options.queuedItem!, + ...(existing[options.queuedItem!.threadId] ?? EMPTY_FOLLOW_UP_QUEUE), + ], + })); + } else if (promptRef.current.length === 0 && composerImagesRef.current.length === 0) { + restoreComposerSnapshotForRetry(snapshot); + } + setThreadError(activeThread.id, err instanceof Error ? err.message : "Failed to steer turn."); + } finally { + setSendInFlight(false); + } + }; + const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readEnvironmentApi(environmentId); @@ -2626,28 +2990,31 @@ export default function ChatView(props: ChatViewProps) { onAdvanceActivePendingUserInput(); return; } - const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) return; + const snapshot = readComposerSnapshotForDispatch(); + if (!snapshot) return; const { images: composerImages, - terminalContexts: composerTerminalContexts, - selectedProvider: ctxSelectedProvider, - selectedModel: ctxSelectedModel, - selectedProviderModels: ctxSelectedProviderModels, - selectedPromptEffort: ctxSelectedPromptEffort, - selectedModelSelection: ctxSelectedModelSelection, - } = sendCtx; - const promptForSend = promptRef.current; - const { - trimmedPrompt: trimmed, - sendableTerminalContexts: sendableComposerTerminalContexts, - expiredTerminalContextCount, - hasSendableContent, - } = deriveComposerSendState({ + provider: ctxSelectedProvider, + model: ctxSelectedModel, + providerModels: ctxSelectedProviderModels, + promptEffort: ctxSelectedPromptEffort, + modelSelection: ctxSelectedModelSelection, + } = snapshot; + const promptForSend = snapshot.promptText; + const { trimmedPrompt: trimmed, hasSendableContent } = deriveComposerSendState({ prompt: promptForSend, imageCount: composerImages.length, - terminalContexts: composerTerminalContexts, }); + const delivery = decideFollowUpDelivery({ + phase: followUpQueuePhase, + requestedSteer: false, + liveSteerSupported: activeProviderLiveSteerSupported, + }); + if (delivery === "queue") { + if (!hasSendableContent) return; + enqueueFollowUpSnapshot(snapshot); + return; + } if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, @@ -2663,9 +3030,7 @@ export default function ChatView(props: ChatViewProps) { return; } const standaloneSlashCommand = - composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 - ? parseStandaloneComposerSlashCommand(trimmed) - : null; + composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; @@ -2673,22 +3038,7 @@ export default function ChatView(props: ChatViewProps) { composerRef.current?.resetCursorState(); return; } - if (!hasSendableContent) { - if (expiredTerminalContextCount > 0) { - const toastCopy = buildExpiredTerminalContextToastCopy( - expiredTerminalContextCount, - "empty", - ); - toastManager.add( - stackedThreadToast({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }), - ); - } - return; - } + if (!hasSendableContent) return; if (!activeProject) return; const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; @@ -2706,15 +3056,11 @@ export default function ChatView(props: ChatViewProps) { return; } - sendInFlightRef.current = true; + setSendInFlight(true); beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); const composerImagesSnapshot = [...composerImages]; - const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; - const messageTextForSend = appendTerminalContextsToPrompt( - promptForSend, - composerTerminalContextsSnapshot, - ); + const messageTextForSend = promptForSend; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ @@ -2762,19 +3108,6 @@ export default function ChatView(props: ChatViewProps) { ]); setThreadError(threadIdForSend, null); - if (expiredTerminalContextCount > 0) { - const toastCopy = buildExpiredTerminalContextToastCopy( - expiredTerminalContextCount, - "omitted", - ); - toastManager.add( - stackedThreadToast({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }), - ); - } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -2792,8 +3125,6 @@ export default function ChatView(props: ChatViewProps) { if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; - } else if (composerTerminalContextsSnapshot.length > 0) { - titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else { titleSeed = "New thread"; } @@ -2878,8 +3209,7 @@ export default function ChatView(props: ChatViewProps) { if ( !turnStartSucceeded && promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 + composerImagesRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -2892,10 +3222,8 @@ export default function ChatView(props: ChatViewProps) { promptRef.current = promptForSend; const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; - composerTerminalContextsRef.current = composerTerminalContextsSnapshot; setComposerDraftPrompt(composerDraftTarget, promptForSend); addComposerDraftImages(composerDraftTarget, retryComposerImages); - setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), prompt: promptForSend, @@ -2907,19 +3235,256 @@ export default function ChatView(props: ChatViewProps) { err instanceof Error ? err.message : "Failed to send message.", ); }); - sendInFlightRef.current = false; + setSendInFlight(false); if (!turnStartSucceeded) { resetLocalDispatch(); } }; + const onSteer = async (e?: { preventDefault: () => void }) => { + e?.preventDefault(); + if ( + !activeThread || + isSendBusy || + isConnecting || + activeEnvironmentUnavailable || + sendInFlightRef.current + ) { + return; + } + if (activePendingProgress) { + onAdvanceActivePendingUserInput(); + return; + } + const snapshot = readComposerSnapshotForDispatch(); + if (!snapshot) return; + const { hasSendableContent } = deriveComposerSendState({ + prompt: snapshot.promptText, + imageCount: snapshot.images.length, + }); + if (!hasSendableContent) return; + const delivery = decideFollowUpDelivery({ + phase: followUpQueuePhase, + requestedSteer: true, + liveSteerSupported: activeProviderLiveSteerSupported, + }); + if (delivery === "send") { + await onSend(e); + return; + } + if (delivery === "queue-unsupported") { + enqueueFollowUpSnapshot(snapshot, { unsupportedSteerToast: true }); + return; + } + if (delivery === "queue") { + enqueueFollowUpSnapshot(snapshot); + return; + } + await dispatchSteerSnapshot(snapshot); + }; + + const onToggleFollowUpQueueItem = (itemId: string) => { + if (!activeThreadId) return; + setFollowUpQueueByThreadId((existing) => { + const current = existing[activeThreadId] ?? EMPTY_FOLLOW_UP_QUEUE; + if (current.length === 0) return existing; + let changed = false; + const nextItems: FollowUpQueueItem[] = []; + for (const item of current) { + if (item.id !== itemId) { + nextItems.push(item); + continue; + } + if (!canExpandQueuedFollowUpText(item.promptText)) { + changed = changed || item.expanded; + nextItems.push({ ...item, expanded: false }); + continue; + } + changed = true; + nextItems.push({ + ...item, + expanded: !item.expanded, + }); + } + if (!changed) return existing; + return { + ...existing, + [activeThreadId]: nextItems, + }; + }); + }; + + const dispatchFollowUpQueueInterrupt = async (item: FollowUpQueueItem) => { + const api = readEnvironmentApi(environmentId); + if (!api) { + recordFollowUpQueueDebugAttempt("manual-interrupt", "environment-api-missing", { + threadId: item.threadId, + itemId: item.id, + }); + toastManager.add({ + type: "error", + title: "Could not interrupt turn", + description: "Cafe Code is not connected.", + }); + return; + } + if (!activeThread || activeThread.id !== item.threadId) { + recordFollowUpQueueDebugAttempt("manual-interrupt", "thread-not-active", { + threadId: item.threadId, + itemId: item.id, + }); + toastManager.add({ + type: "info", + title: "Queued follow-up", + description: "Open this thread before interrupting its active turn.", + }); + return; + } + + const turnId = activeThread.session?.activeTurnId ?? undefined; + recordFollowUpQueueDebugAttempt("manual-interrupt", "interrupt-requested", { + threadId: item.threadId, + itemId: item.id, + }); + + try { + await api.orchestration.dispatchCommand({ + type: "thread.turn.interrupt", + commandId: newCommandId(), + threadId: item.threadId, + ...(turnId !== undefined ? { turnId } : {}), + createdAt: new Date().toISOString(), + }); + setThreadError(item.threadId, null); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to interrupt active turn."; + recordFollowUpQueueDebugAttempt("manual-interrupt", "interrupt-failed", { + threadId: item.threadId, + itemId: item.id, + }); + setThreadError(item.threadId, message); + toastManager.add({ + type: "error", + title: "Could not interrupt turn", + description: message, + }); + } + }; + + const onActivateFollowUpQueueItem = (itemId: string) => { + if (!activeThreadId) return; + const item = followUpQueueByThreadIdRef.current[activeThreadId]?.find( + (entry) => entry.id === itemId, + ); + if (!item) return; + + const action = decideQueuedFollowUpAction({ + phase: followUpQueuePhase, + liveSteerSupported: activeProviderLiveSteerSupported, + canDispatchNow: + followUpQueuePhase === "running" + ? canActivateRunningFollowUpQueueAction + : followUpQueueCanStartTurn, + }); + + if (action === "send") { + void dispatchFollowUpTurnStartRef.current?.(item); + return; + } + + if (action === "steer") { + void dispatchSteerSnapshot(item, { queuedItem: item }); + return; + } + + if (action === "interrupt") { + void dispatchFollowUpQueueInterrupt(item); + return; + } + + toastManager.add({ + type: "info", + title: "Queued follow-up", + description: + followUpQueuePhase === "running" + ? activeProviderLiveSteerSupported + ? "Cafe Code is not ready to steer this queued message yet." + : "Cafe Code is not ready to interrupt this turn yet." + : "Cafe Code will send this queued message when it is ready.", + }); + }; + + const onRemoveFollowUpQueueItem = (itemId: string) => { + if (!activeThreadId) return; + removeFollowUpQueueItem(activeThreadId, itemId, true); + }; + + const onClearFollowUpQueue = () => { + if (!activeThreadId) return; + const current = followUpQueueByThreadIdRef.current[activeThreadId] ?? EMPTY_FOLLOW_UP_QUEUE; + for (const item of current) { + revokeQueuedFollowUpPreviewUrls(item); + } + setFollowUpQueueByThreadId((existing) => { + if (!(activeThreadId in existing)) return existing; + const next = { ...existing }; + delete next[activeThreadId]; + return next; + }); + }; + + const tryDispatchNextQueuedFollowUp = useCallback( + (source = "state-change") => { + if (!activeThreadId) { + recordFollowUpQueueDebugAttempt(source, "no-active-thread", { threadId: null }); + return false; + } + const firstItem = followUpQueueByThreadIdRef.current[activeThreadId]?.[0]; + if (!firstItem) { + recordFollowUpQueueDebugAttempt(source, "queue-empty"); + return false; + } + if (!followUpQueueCanStartTurn) { + recordFollowUpQueueDebugAttempt(source, firstItem.blockedReason ?? "gate-blocked", { + itemId: firstItem.id, + }); + return false; + } + const dispatchFollowUpTurnStart = dispatchFollowUpTurnStartRef.current; + if (dispatchFollowUpTurnStart === null) { + recordFollowUpQueueDebugAttempt(source, "dispatch-ref-missing", { itemId: firstItem.id }); + return false; + } + recordFollowUpQueueDebugAttempt(source, "dispatch-started", { itemId: firstItem.id }); + void dispatchFollowUpTurnStart(firstItem); + return true; + }, + [activeThreadId, followUpQueueCanStartTurn, recordFollowUpQueueDebugAttempt], + ); + + useEffect(() => { + tryDispatchNextQueuedFollowUp(); + }, [activeFollowUpQueue, dispatchGateRevision, tryDispatchNextQueuedFollowUp]); + + useEffect(() => { + if (activeFollowUpQueue.length === 0) { + return; + } + const intervalId = window.setInterval(() => { + tryDispatchNextQueuedFollowUp("watchdog"); + }, FOLLOW_UP_QUEUE_WATCHDOG_INTERVAL_MS); + return () => window.clearInterval(intervalId); + }, [activeFollowUpQueue.length, tryDispatchNextQueuedFollowUp]); + const onInterrupt = async () => { const api = readEnvironmentApi(environmentId); if (!api || !activeThread) return; + const turnId = activeThread.session?.activeTurnId ?? undefined; await api.orchestration.dispatchCommand({ type: "thread.turn.interrupt", commandId: newCommandId(), threadId: activeThread.id, + ...(turnId !== undefined ? { turnId } : {}), createdAt: new Date().toISOString(), }); }; @@ -3021,9 +3586,9 @@ export default function ChatView(props: ChatViewProps) { }; }); promptRef.current = ""; - composerRef.current?.resetCursorState({ cursor: 0 }); + readComposerHandle(composerRef)?.resetCursorState({ cursor: 0 }); }, - [activePendingProgress?.activeQuestion, activePendingUserInput], + [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -3048,16 +3613,16 @@ export default function ChatView(props: ChatViewProps) { ), }, })); - const snapshot = composerRef.current?.readSnapshot(); + const snapshot = readComposerHandle(composerRef)?.readSnapshot(); if ( snapshot?.value !== value || snapshot.cursor !== nextCursor || snapshot.expandedCursor !== expandedCursor ) { - composerRef.current?.focusAt(nextCursor); + readComposerHandle(composerRef)?.focusAt(nextCursor); } }, - [activePendingUserInput], + [activePendingUserInput, composerRef], ); const onAdvanceActivePendingUserInput = useCallback(() => { @@ -3111,7 +3676,7 @@ export default function ChatView(props: ChatViewProps) { return; } - const sendCtx = composerRef.current?.getSendContext(); + const sendCtx = readComposerHandle(composerRef)?.getSendContext(); if (!sendCtx) { return; } @@ -3134,7 +3699,7 @@ export default function ChatView(props: ChatViewProps) { text: trimmed, }); - sendInFlightRef.current = true; + setSendInFlight(true); beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); @@ -3202,7 +3767,7 @@ export default function ChatView(props: ChatViewProps) { planSidebarDismissedForTurnRef.current = null; setPlanSidebarOpen(true); } - sendInFlightRef.current = false; + setSendInFlight(false); } catch (err) { setOptimisticUserMessages((existing) => existing.filter((message) => message.id !== messageIdForSend), @@ -3211,7 +3776,7 @@ export default function ChatView(props: ChatViewProps) { threadIdForSend, err instanceof Error ? err.message : "Failed to send plan follow-up.", ); - sendInFlightRef.current = false; + setSendInFlight(false); resetLocalDispatch(); } }, @@ -3226,8 +3791,10 @@ export default function ChatView(props: ChatViewProps) { resetLocalDispatch, runtimeMode, setComposerDraftInteractionMode, + setSendInFlight, setThreadError, autoOpenPlanSidebar, + composerRef, environmentId, ], ); @@ -3248,7 +3815,7 @@ export default function ChatView(props: ChatViewProps) { return; } - const sendCtx = composerRef.current?.getSendContext(); + const sendCtx = readComposerHandle(composerRef)?.getSendContext(); if (!sendCtx) { return; } @@ -3274,10 +3841,10 @@ export default function ChatView(props: ChatViewProps) { const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; - sendInFlightRef.current = true; + setSendInFlight(true); beginLocalDispatch({ preparingWorktree: false }); const finish = () => { - sendInFlightRef.current = false; + setSendInFlight(false); resetLocalDispatch(); }; @@ -3364,7 +3931,9 @@ export default function ChatView(props: ChatViewProps) { navigate, resetLocalDispatch, runtimeMode, + setSendInFlight, autoOpenPlanSidebar, + composerRef, environmentId, ]); @@ -3457,28 +4026,6 @@ export default function ChatView(props: ChatViewProps) { const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); - const onOpenTurnDiff = useCallback( - (turnId: TurnId, filePath?: string) => { - if (!isServerThread) { - return; - } - onDiffPanelOpen?.(); - void navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId, - threadId, - }, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return filePath - ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } - : { ...rest, diff: "1", diffTurnId: turnId }; - }, - }); - }, - [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], - ); // Both the Map and the revert handler are read from refs at call-time so // the callback reference is fully stable and never busts context identity. const revertTurnCountRef = useRef(revertTurnCountByUserMessageId); @@ -3515,29 +4062,14 @@ export default function ChatView(props: ChatViewProps) { > @@ -3565,16 +4097,12 @@ export default function ChatView(props: ChatViewProps) { timelineEntries={timelineEntries} completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} - turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} activeThreadEnvironmentId={activeThread.environmentId} - routeThreadKey={routeThreadKey} - onOpenTurnDiff={onOpenTurnDiff} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} - resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} @@ -3650,14 +4178,20 @@ export default function ChatView(props: ChatViewProps) { resolvedTheme={resolvedTheme} settings={settings} keybindings={keybindings} - terminalOpen={Boolean(terminalState.terminalOpen)} gitCwd={gitCwd} + followUpQueueItems={followUpQueueViewItems} + followUpQueueActionLabel={followUpQueueActionLabel} + followUpQueueActionTitle={followUpQueueActionTitle} promptRef={promptRef} composerImagesRef={composerImagesRef} - composerTerminalContextsRef={composerTerminalContextsRef} shouldAutoScrollRef={isAtEndRef} scheduleStickToBottom={scrollToEnd} onSend={onSend} + onSteer={onSteer} + onToggleFollowUpQueueItem={onToggleFollowUpQueueItem} + onActivateFollowUpQueueItem={onActivateFollowUpQueueItem} + onRemoveFollowUpQueueItem={onRemoveFollowUpQueueItem} + onClearFollowUpQueue={onClearFollowUpQueue} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} onRespondToApproval={onRespondToApproval} @@ -3741,23 +4275,6 @@ export default function ChatView(props: ChatViewProps) {
{/* end horizontal flex container */} - {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} {shouldUsePlanSidebarSheet ? ( store.toggleOpen); const keybindings = useServerKeybindings(); const composerHandleRef = useRef(null); - const routeTarget = useParams({ - strict: false, - select: (params) => resolveThreadRouteTarget(params), - }); - const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; - const terminalOpen = useTerminalStateStore((state) => - routeThreadRef - ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); - useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { if (event.defaultPrevented) return; const command = resolveShortcutCommand(event, keybindings, { - context: { - terminalFocus: isTerminalFocused(), - terminalOpen, - }, + context: {}, }); if (command !== "commandPalette.toggle") { return; @@ -361,7 +345,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, terminalOpen, toggleOpen]); + }, [keybindings, toggleOpen]); return ( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 5f46f281..44aa29ef 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -37,8 +37,6 @@ import { type Spread, } from "lexical"; import { - createContext, - use, useCallback, useEffect, useEffectEvent, @@ -58,10 +56,6 @@ import { selectionTouchesMentionBoundary, splitPromptIntoComposerSegments, } from "~/composer-editor-mentions"; -import { - INLINE_TERMINAL_CONTEXT_PLACEHOLDER, - type TerminalContextDraft, -} from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons"; import { @@ -71,7 +65,6 @@ import { COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, SKILL_CHIP_ICON_SVG, } from "./composerInlineChip"; -import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -112,21 +105,6 @@ type SerializedComposerSkillNode = Spread< SerializedLexicalNode >; -type SerializedComposerTerminalContextNode = Spread< - { - context: TerminalContextDraft; - type: "composer-terminal-context"; - version: 1; - }, - SerializedLexicalNode ->; - -const ComposerTerminalContextActionsContext = createContext<{ - onRemoveTerminalContext: (contextId: string) => void; -}>({ - onRemoveTerminalContext: () => {}, -}); - function ComposerMentionDecorator(props: { path: string }) { const theme = resolvedThemeFromDocument(); const chip = ( @@ -361,104 +339,16 @@ function $createComposerSkillNode( return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); } -function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { - return ; -} - -class ComposerTerminalContextNode extends DecoratorNode { - __context: TerminalContextDraft; - - static override getType(): string { - return "composer-terminal-context"; - } - - static override clone(node: ComposerTerminalContextNode): ComposerTerminalContextNode { - return new ComposerTerminalContextNode(node.__context, node.__key); - } - - static override importJSON( - serializedNode: SerializedComposerTerminalContextNode, - ): ComposerTerminalContextNode { - return $createComposerTerminalContextNode(serializedNode.context); - } - - constructor(context: TerminalContextDraft, key?: NodeKey) { - super(key); - this.__context = context; - } - - override exportJSON(): SerializedComposerTerminalContextNode { - return { - ...super.exportJSON(), - context: this.__context, - type: "composer-terminal-context", - version: 1, - }; - } - - override createDOM(): HTMLElement { - const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; - return dom; - } - - override updateDOM(): false { - return false; - } - - override getTextContent(): string { - return INLINE_TERMINAL_CONTEXT_PLACEHOLDER; - } - - override isInline(): true { - return true; - } - - override decorate(): React.ReactElement { - return ; - } -} - -function $createComposerTerminalContextNode( - context: TerminalContextDraft, -): ComposerTerminalContextNode { - return $applyNodeReplacement(new ComposerTerminalContextNode(context)); -} - -type ComposerInlineTokenNode = - | ComposerMentionNode - | ComposerSkillNode - | ComposerTerminalContextNode; +type ComposerInlineTokenNode = ComposerMentionNode | ComposerSkillNode; function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { - return ( - candidate instanceof ComposerMentionNode || - candidate instanceof ComposerSkillNode || - candidate instanceof ComposerTerminalContextNode - ); + return candidate instanceof ComposerMentionNode || candidate instanceof ComposerSkillNode; } function resolvedThemeFromDocument(): "light" | "dark" { return document.documentElement.classList.contains("dark") ? "dark" : "light"; } -function terminalContextSignature(contexts: ReadonlyArray): string { - return contexts - .map((context) => - [ - context.id, - context.threadId, - context.terminalId, - context.terminalLabel, - context.lineStart, - context.lineEnd, - context.createdAt, - context.text, - ].join("\u001f"), - ) - .join("\u001e"); -} - function skillSignature(skills: ReadonlyArray): string { return skills .map((skill) => @@ -820,7 +710,6 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { function $setComposerEditorPrompt( prompt: string, - terminalContexts: ReadonlyArray, skillMetadata: ReadonlyMap, ): void { const root = $getRoot(); @@ -828,7 +717,7 @@ function $setComposerEditorPrompt( const paragraph = $createParagraphNode(); root.append(paragraph); - const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); + const segments = splitPromptIntoComposerSegments(prompt); for (const segment of segments) { if (segment.type === "mention") { paragraph.append($createComposerMentionNode(segment.path)); @@ -845,26 +734,10 @@ function $setComposerEditorPrompt( ); continue; } - if (segment.type === "terminal-context") { - if (segment.context) { - paragraph.append($createComposerTerminalContextNode(segment.context)); - } - continue; - } $appendTextWithLineBreaks(paragraph, segment.text); } } -function collectTerminalContextIds(node: LexicalNode): string[] { - if (node instanceof ComposerTerminalContextNode) { - return [node.__context.id]; - } - if ($isElementNode(node)) { - return node.getChildren().flatMap((child) => collectTerminalContextIds(child)); - } - return []; -} - export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; @@ -873,25 +746,21 @@ export interface ComposerPromptEditorHandle { value: string; cursor: number; expandedCursor: number; - terminalContextIds: string[]; }; } interface ComposerPromptEditorProps { value: string; cursor: number; - terminalContexts: ReadonlyArray; skills: ReadonlyArray; disabled: boolean; placeholder: string; className?: string; - onRemoveTerminalContext: (contextId: string) => void; onChange: ( nextValue: string, nextCursor: number, expandedCursor: number, cursorAdjacentToMention: boolean, - terminalContextIds: string[], ) => void; onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", @@ -1053,7 +922,6 @@ function ComposerInlineTokenSelectionNormalizePlugin() { function ComposerInlineTokenBackspacePlugin() { const [editor] = useLexicalComposerContext(); - const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext); useEffect(() => { return editor.registerCommand( @@ -1065,19 +933,13 @@ function ComposerInlineTokenBackspacePlugin() { } const anchorNode = selection.anchor.getNode(); - const selectionOffset = $readSelectionOffsetFromEditorState(0); const removeInlineTokenNode = (candidate: unknown): boolean => { if (!isComposerInlineTokenNode(candidate)) { return false; } const tokenStart = getAbsoluteOffsetForPoint(candidate, 0); candidate.remove(); - if (candidate instanceof ComposerTerminalContextNode) { - onRemoveTerminalContext(candidate.__context.id); - $setSelectionAtComposerOffset(selectionOffset); - } else { - $setSelectionAtComposerOffset(tokenStart); - } + $setSelectionAtComposerOffset(tokenStart); event?.preventDefault(); return true; }; @@ -1113,17 +975,13 @@ function ComposerInlineTokenBackspacePlugin() { }, COMMAND_PRIORITY_HIGH, ); - }, [editor, onRemoveTerminalContext]); + }, [editor]); return null; } -function ComposerSurroundSelectionPlugin(props: { - terminalContexts: ReadonlyArray; - skills: ReadonlyArray; -}) { +function ComposerSurroundSelectionPlugin(props: { skills: ReadonlyArray }) { const [editor] = useLexicalComposerContext(); - const terminalContextsRef = useRef(props.terminalContexts); const skillMetadataRef = useRef(skillMetadataByName(props.skills)); const pendingSurroundSelectionRef = useRef<{ value: string; @@ -1136,10 +994,6 @@ function ComposerSurroundSelectionPlugin(props: { expandedEnd: number; } | null>(null); - useEffect(() => { - terminalContextsRef.current = props.terminalContexts; - }, [props.terminalContexts]); - useEffect(() => { skillMetadataRef.current = skillMetadataByName(props.skills); }, [props.skills]); @@ -1188,7 +1042,7 @@ function ComposerSurroundSelectionPlugin(props: { selectionSnapshot.expandedEnd, ); const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; - $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current); + $setComposerEditorPrompt(nextValue, skillMetadataRef.current); const selectionStart = collapseExpandedComposerCursor( nextValue, selectionSnapshot.expandedStart, @@ -1387,12 +1241,10 @@ function ComposerSurroundSelectionPlugin(props: { function ComposerPromptEditorInner({ value, cursor, - terminalContexts, skills, disabled, placeholder, className, - onRemoveTerminalContext, onChange, onCommandKeyDown, onPaste, @@ -1401,8 +1253,6 @@ function ComposerPromptEditorInner({ const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); const initialCursor = clampCollapsedComposerCursor(value, cursor); - const terminalContextsSignature = terminalContextSignature(terminalContexts); - const terminalContextsSignatureRef = useRef(terminalContextsSignature); const skillsSignature = skillSignature(skills); const skillsSignatureRef = useRef(skillsSignature); const skillMetadataRef = useRef(skillMetadataByName(skills)); @@ -1410,13 +1260,8 @@ function ComposerPromptEditorInner({ value, cursor: initialCursor, expandedCursor: expandCollapsedComposerCursor(value, initialCursor), - terminalContextIds: terminalContexts.map((context) => context.id), }); const isApplyingControlledUpdateRef = useRef(false); - const terminalContextActions = useMemo( - () => ({ onRemoveTerminalContext }), - [onRemoveTerminalContext], - ); useEffect(() => { onChangeRef.current = onChange; @@ -1433,12 +1278,10 @@ function ComposerPromptEditorInner({ useLayoutEffect(() => { const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; - const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; const skillsChanged = skillsSignatureRef.current !== skillsSignature; if ( previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor && - !contextsChanged && !skillsChanged ) { return; @@ -1448,23 +1291,20 @@ function ComposerPromptEditorInner({ value, cursor: normalizedCursor, expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), - terminalContextIds: terminalContexts.map((context) => context.id), }; - terminalContextsSignatureRef.current = terminalContextsSignature; skillsSignatureRef.current = skillsSignature; const rootElement = editor.getRootElement(); const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (previousSnapshot.value === value && !contextsChanged && !skillsChanged && !isFocused) { + if (previousSnapshot.value === value && !skillsChanged && !isFocused) { return; } isApplyingControlledUpdateRef.current = true; editor.update(() => { - const shouldRewriteEditorState = - previousSnapshot.value !== value || contextsChanged || skillsChanged; + const shouldRewriteEditorState = previousSnapshot.value !== value || skillsChanged; if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); + $setComposerEditorPrompt(value, skillMetadataRef.current); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); @@ -1473,7 +1313,7 @@ function ComposerPromptEditorInner({ queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [cursor, editor, skillsSignature, terminalContexts, terminalContextsSignature, value]); + }, [cursor, editor, skillsSignature, value]); const focusAt = useCallback( (nextCursor: number) => { @@ -1488,14 +1328,12 @@ function ComposerPromptEditorInner({ value: snapshotRef.current.value, cursor: boundedCursor, expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), - terminalContextIds: snapshotRef.current.terminalContextIds, }; onChangeRef.current( snapshotRef.current.value, boundedCursor, snapshotRef.current.expandedCursor, false, - snapshotRef.current.terminalContextIds, ); }, [editor], @@ -1505,7 +1343,6 @@ function ComposerPromptEditorInner({ value: string; cursor: number; expandedCursor: number; - terminalContextIds: string[]; } => { let snapshot = snapshotRef.current; editor.getEditorState().read(() => { @@ -1523,12 +1360,10 @@ function ComposerPromptEditorInner({ nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); - const terminalContextIds = collectTerminalContextIds($getRoot()); snapshot = { value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, - terminalContextIds, }; }); snapshotRef.current = snapshot; @@ -1571,14 +1406,11 @@ function ComposerPromptEditorInner({ nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); - const terminalContextIds = collectTerminalContextIds($getRoot()); const previousSnapshot = snapshotRef.current; if ( previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor && - previousSnapshot.expandedCursor === nextExpandedCursor && - previousSnapshot.terminalContextIds.length === terminalContextIds.length && - previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) + previousSnapshot.expandedCursor === nextExpandedCursor ) { return; } @@ -1589,86 +1421,68 @@ function ComposerPromptEditorInner({ value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, - terminalContextIds, }; const cursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current( - nextValue, - nextCursor, - nextExpandedCursor, - cursorAdjacentToMention, - terminalContextIds, - ); + onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention); }); }, []); return ( - -
- } - onPaste={onPaste} - /> - } - placeholder={ - terminalContexts.length > 0 ? null : ( -
- {placeholder} -
- ) - } - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - - -
-
+
+ } + onPaste={onPaste} + /> + } + placeholder={ +
+ {placeholder} +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + + +
); } export function ComposerPromptEditor({ value, cursor, - terminalContexts, skills, disabled, placeholder, className, - onRemoveTerminalContext, onChange, onCommandKeyDown, onPaste, editorRef, }: ComposerPromptEditorProps) { const initialValueRef = useRef(value); - const initialTerminalContextsRef = useRef(terminalContexts); const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); const initialConfig = useMemo( () => ({ namespace: "cafecode-composer-editor", editable: true, - nodes: [ComposerMentionNode, ComposerSkillNode, ComposerTerminalContextNode], + nodes: [ComposerMentionNode, ComposerSkillNode], editorState: () => { - $setComposerEditorPrompt( - initialValueRef.current, - initialTerminalContextsRef.current, - initialSkillMetadataRef.current, - ); + $setComposerEditorPrompt(initialValueRef.current, initialSkillMetadataRef.current); }, onError: (error) => { throw error; @@ -1682,11 +1496,9 @@ export function ComposerPromptEditor({ +
+ +
+ ); +} + export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { - const navigate = useNavigate(); const { resolvedTheme } = useTheme(); const settings = useSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); @@ -194,17 +200,13 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { () => new Set(), ); const patchViewportRef = useRef(null); - const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); - const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); - const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), }); const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); const diffOpen = diffSearch.diff === "1"; - const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useStore( useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), ); @@ -219,107 +221,48 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; const gitStatusQuery = useGitStatus({ - environmentId: activeThread?.environmentId ?? null, - cwd: activeCwd ?? null, + environmentId: diffOpen ? (activeThread?.environmentId ?? null) : null, + cwd: diffOpen ? (activeCwd ?? null) : null, }); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = - useTurnDiffSummaries(activeThread); - const orderedTurnDiffSummaries = useMemo( - () => - [...turnDiffSummaries].toSorted((left, right) => { - const leftTurnCount = - left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0; - const rightTurnCount = - right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0; - if (leftTurnCount !== rightTurnCount) { - return rightTurnCount - leftTurnCount; - } - return right.completedAt.localeCompare(left.completedAt); - }), - [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], - ); - - const selectedTurnId = diffSearch.diffTurnId ?? null; - const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; - const selectedTurn = - selectedTurnId === null - ? undefined - : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? - orderedTurnDiffSummaries[0]); - const selectedCheckpointTurnCount = - selectedTurn && - (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); - const selectedCheckpointRange = useMemo( - () => - typeof selectedCheckpointTurnCount === "number" - ? { - fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1), - toTurnCount: selectedCheckpointTurnCount, - } - : null, - [selectedCheckpointTurnCount], - ); - const conversationCheckpointTurnCount = useMemo(() => { - const turnCounts = orderedTurnDiffSummaries - .map( - (summary) => - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], - ) - .filter((value): value is number => typeof value === "number"); - if (turnCounts.length === 0) { - return undefined; - } - const latest = Math.max(...turnCounts); - return latest > 0 ? latest : undefined; - }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]); - const conversationCheckpointRange = useMemo( - () => - !selectedTurn && typeof conversationCheckpointTurnCount === "number" - ? { - fromTurnCount: 0, - toTurnCount: conversationCheckpointTurnCount, - } - : null, - [conversationCheckpointTurnCount, selectedTurn], - ); - const activeCheckpointRange = selectedTurn - ? selectedCheckpointRange - : conversationCheckpointRange; - const conversationCacheScope = useMemo(() => { - if (selectedTurn || orderedTurnDiffSummaries.length === 0) { - return null; - } - return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; - }, [orderedTurnDiffSummaries, selectedTurn]); - const activeCheckpointDiffQuery = useQuery( - checkpointDiffQueryOptions({ - environmentId: activeThread?.environmentId ?? null, - threadId: activeThreadId, - fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, - toTurnCount: activeCheckpointRange?.toTurnCount ?? null, - ignoreWhitespace: diffIgnoreWhitespace, - cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, - enabled: isGitRepo, - }), - ); - const selectedTurnCheckpointDiff = selectedTurn - ? activeCheckpointDiffQuery.data?.diff - : undefined; - const conversationCheckpointDiff = selectedTurn - ? undefined - : activeCheckpointDiffQuery.data?.diff; - const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading; - const checkpointDiffError = - activeCheckpointDiffQuery.error instanceof Error - ? activeCheckpointDiffQuery.error.message - : activeCheckpointDiffQuery.error - ? "Failed to load checkpoint diff." + const workingTreeFiles = gitStatusQuery.data?.workingTree.files ?? []; + const activeWorkingTreeDiffQuery = useQuery({ + queryKey: [ + "vcs", + "workingTreeDiff", + activeThread?.environmentId ?? null, + activeCwd ?? null, + diffIgnoreWhitespace, + ], + enabled: + diffOpen && isGitRepo && activeThread?.environmentId !== undefined && activeCwd !== undefined, + queryFn: async () => { + if (!activeThread?.environmentId || !activeCwd) { + throw new Error("Cannot load working tree diff without an active workspace."); + } + const api = readEnvironmentApi(activeThread.environmentId); + if (!api) { + throw new Error(`Environment API not found for environment ${activeThread.environmentId}.`); + } + return await api.vcs.workingTreeDiff({ + cwd: activeCwd, + ignoreWhitespace: diffIgnoreWhitespace, + }); + }, + }); + const workingTreeDiffError = + activeWorkingTreeDiffQuery.error instanceof Error + ? activeWorkingTreeDiffQuery.error.message + : activeWorkingTreeDiffQuery.error + ? "Failed to load current changes." : null; - const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; + const selectedFilePath = diffSearch.diffFilePath ?? null; + const selectedPatch = activeWorkingTreeDiffQuery.data?.diff; const hasResolvedPatch = typeof selectedPatch === "string"; - const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; + const hasNoTrackedPatch = hasResolvedPatch && selectedPatch.trim().length === 0; + const isUpdatingChanges = + diffOpen && (gitStatusQuery.isPending || activeWorkingTreeDiffQuery.isFetching); const renderablePatch = useMemo( () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`), [resolvedTheme, selectedPatch], @@ -353,9 +296,19 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { if (diffOpen && !previousDiffOpenRef.current) { setDiffWordWrap(settings.diffWordWrap); setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace); + void refreshGitStatus({ + environmentId: activeThread?.environmentId ?? null, + cwd: activeCwd ?? null, + }); } previousDiffOpenRef.current = diffOpen; - }, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]); + }, [ + activeCwd, + activeThread?.environmentId, + diffOpen, + settings.diffIgnoreWhitespace, + settings.diffWordWrap, + ]); useEffect(() => { if (!selectedFilePath || !patchViewportRef.current) { @@ -390,180 +343,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }); }, []); - const selectTurn = (turnId: TurnId) => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", diffTurnId: turnId }; - }, - }); - }; - const selectWholeConversation = () => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; - }, - }); - }; - const updateTurnStripScrollState = useCallback(() => { - const element = turnStripRef.current; - if (!element) { - setCanScrollTurnStripLeft(false); - setCanScrollTurnStripRight(false); - return; - } - - const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); - setCanScrollTurnStripLeft(element.scrollLeft > 4); - setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4); - }, []); - const scrollTurnStripBy = useCallback((offset: number) => { - const element = turnStripRef.current; - if (!element) return; - element.scrollBy({ left: offset, behavior: "smooth" }); - }, []); - const onTurnStripWheel = useCallback((event: ReactWheelEvent) => { - const element = turnStripRef.current; - if (!element) return; - if (element.scrollWidth <= element.clientWidth + 1) return; - if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; - - event.preventDefault(); - element.scrollBy({ left: event.deltaY, behavior: "auto" }); - }, []); - - useEffect(() => { - const element = turnStripRef.current; - if (!element) return; - - const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); - const onScroll = () => updateTurnStripScrollState(); - - element.addEventListener("scroll", onScroll, { passive: true }); - - const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); - resizeObserver.observe(element); - - return () => { - window.cancelAnimationFrame(frameId); - element.removeEventListener("scroll", onScroll); - resizeObserver.disconnect(); - }; - }, [updateTurnStripScrollState]); - - useEffect(() => { - const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); - - useEffect(() => { - const element = turnStripRef.current; - if (!element) return; - - const selectedChip = element.querySelector("[data-turn-chip-selected='true']"); - selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); - }, [selectedTurn?.turnId, selectedTurnId]); - const headerRow = ( <> -
- - -
- - {orderedTurnDiffSummaries.map((summary) => ( - - ))} +
+
Current changes
+
+ {workingTreeFiles.length === 0 + ? "No files changed" + : `${workingTreeFiles.length} file${workingTreeFiles.length === 1 ? "" : "s"} changed`}
@@ -618,39 +405,33 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { {!activeThread ? (
- Select a thread to inspect turn diffs. + Select a thread to inspect current changes.
) : !isGitRepo ? (
- Turn diffs are unavailable because this project is not a git repository. -
- ) : orderedTurnDiffSummaries.length === 0 ? ( -
- No completed turns yet. + Current changes are unavailable because this project is not a git repository.
) : ( - <> +
- {checkpointDiffError && !renderablePatch && ( + {workingTreeDiffError && !renderablePatch && (
-

{checkpointDiffError}

+

{workingTreeDiffError}

)} {!renderablePatch ? ( - isLoadingCheckpointDiff ? ( - - ) : ( -
-

- {hasNoNetChanges - ? "No net changes in this selection." - : "No patch available for this selection."} -

-
- ) +
+

+ {hasNoTrackedPatch + ? workingTreeFiles.length > 0 + ? "No tracked patch to show. Untracked files are visible in Git status but are not rendered as a patch here." + : "No uncommitted changes." + : "No patch available for the current working tree."} +

+
) : renderablePatch.kind === "files" ? ( )}
- + {isUpdatingChanges && } +
)}
); diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx deleted file mode 100644 index a0010656..00000000 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ /dev/null @@ -1,473 +0,0 @@ -import { scopeThreadRef } from "@cafecode/client-runtime"; -import { ThreadId } from "@cafecode/contracts"; -import { useState } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { render } from "vitest-browser-react"; - -const SHARED_THREAD_ID = ThreadId.make("thread-shared"); -const ENVIRONMENT_A = "environment-local" as never; -const ENVIRONMENT_B = "environment-remote" as never; -const GIT_CWD = "/repo/project"; -const BRANCH_NAME = "feature/toast-scope"; - -function createDeferredPromise() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - - const promise = new Promise((nextResolve, nextReject) => { - resolve = nextResolve; - reject = nextReject; - }); - - return { promise, resolve, reject }; -} - -const { - activeRunStackedActionDeferredRef, - activeDraftThreadRef, - hasServerThreadRef, - invalidateGitQueriesSpy, - refreshGitStatusSpy, - runStackedActionMutateAsyncSpy, - setDraftThreadContextSpy, - setThreadBranchSpy, - toastAddSpy, - toastCloseSpy, - toastPromiseSpy, - toastUpdateSpy, -} = vi.hoisted(() => ({ - activeRunStackedActionDeferredRef: { current: createDeferredPromise() }, - activeDraftThreadRef: { current: null as unknown }, - hasServerThreadRef: { current: true }, - invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), - refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), - runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), - setDraftThreadContextSpy: vi.fn(), - setThreadBranchSpy: vi.fn(), - toastAddSpy: vi.fn(() => "toast-1"), - toastCloseSpy: vi.fn(), - toastPromiseSpy: vi.fn(), - toastUpdateSpy: vi.fn(), -})); - -vi.mock("@tanstack/react-query", async () => { - const actual = - await vi.importActual("@tanstack/react-query"); - - return { - ...actual, - useIsMutating: vi.fn(() => 0), - useMutation: vi.fn((options: { __kind?: string }) => { - if (options.__kind === "run-stacked-action") { - return { - mutateAsync: runStackedActionMutateAsyncSpy, - isPending: false, - }; - } - - if (options.__kind === "pull") { - return { - mutateAsync: vi.fn(), - isPending: false, - }; - } - - return { - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - }; - }), - useQuery: vi.fn(() => ({ data: null, error: null })), - useQueryClient: vi.fn(() => ({})), - }; -}); - -vi.mock("~/components/ui/toast", () => ({ - toastManager: { - add: toastAddSpy, - close: toastCloseSpy, - promise: toastPromiseSpy, - update: toastUpdateSpy, - }, - stackedThreadToast: vi.fn((options: unknown) => options), -})); - -vi.mock("~/editorPreferences", () => ({ - openInPreferredEditor: vi.fn(), -})); - -vi.mock("~/lib/gitReactQuery", () => ({ - gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), - gitMutationKeys: { - publishRepository: vi.fn(() => ["publish-repository"]), - pull: vi.fn(() => ["pull"]), - runStackedAction: vi.fn(() => ["run-stacked-action"]), - }, - gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), - gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), - invalidateGitQueries: invalidateGitQueriesSpy, - sourceControlPublishRepositoryMutationOptions: vi.fn(() => ({ __kind: "publish-repository" })), -})); - -vi.mock("~/lib/gitStatusState", () => ({ - refreshGitStatus: refreshGitStatusSpy, - resetGitStatusStateForTests: () => undefined, - useGitStatus: vi.fn(() => ({ - data: { - isRepo: true, - sourceControlProvider: { - kind: "github", - name: "GitHub", - baseUrl: "https://github.com", - }, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: BRANCH_NAME, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 1, - behindCount: 0, - pr: null, - }, - error: null, - isPending: false, - })), -})); - -vi.mock("~/localApi", () => ({ - ensureLocalApi: vi.fn(() => { - throw new Error("ensureLocalApi not implemented in browser test"); - }), - readLocalApi: vi.fn(() => null), -})); - -vi.mock("~/composerDraftStore", async () => { - const draftStoreState = { - getDraftThreadByRef: () => activeDraftThreadRef.current, - getDraftSession: () => activeDraftThreadRef.current, - getDraftThread: () => activeDraftThreadRef.current, - getDraftSessionByLogicalProjectKey: () => null, - setDraftThreadContext: setDraftThreadContextSpy, - setLogicalProjectDraftThreadId: vi.fn(), - setProjectDraftThreadId: vi.fn(), - hasDraftThreadsInEnvironment: () => false, - clearDraftThread: vi.fn(), - }; - - return { - DraftId: { - makeUnsafe: (value: string) => value, - }, - useComposerDraftStore: Object.assign( - (selector: (state: unknown) => unknown) => selector(draftStoreState), - { getState: () => draftStoreState }, - ), - markPromotedDraftThread: vi.fn(), - markPromotedDraftThreadByRef: vi.fn(), - markPromotedDraftThreads: vi.fn(), - markPromotedDraftThreadsByRef: vi.fn(), - finalizePromotedDraftThreadByRef: vi.fn(), - finalizePromotedDraftThreadsByRef: vi.fn(), - }; -}); - -vi.mock("~/store", () => ({ - selectEnvironmentState: ( - state: { environmentStateById: Record }, - environmentId: string | null, - ) => { - if (!environmentId) { - throw new Error("Missing environment id"); - } - const environmentState = state.environmentStateById[environmentId]; - if (!environmentState) { - throw new Error(`Unknown environment: ${environmentId}`); - } - return environmentState; - }, - selectProjectsForEnvironment: () => [], - selectProjectsAcrossEnvironments: () => [], - selectThreadsForEnvironment: () => [], - selectThreadsAcrossEnvironments: () => [], - selectThreadShellsAcrossEnvironments: () => [], - selectSidebarThreadsAcrossEnvironments: () => [], - selectSidebarThreadsForProjectRef: () => [], - selectSidebarThreadsForProjectRefs: () => [], - selectBootstrapCompleteForActiveEnvironment: () => true, - selectProjectByRef: () => null, - selectThreadByRef: () => null, - selectSidebarThreadSummaryByRef: () => null, - selectThreadIdsByProjectRef: () => [], - useStore: (selector: (state: unknown) => unknown) => - selector({ - setThreadBranch: setThreadBranchSpy, - environmentStateById: { - [ENVIRONMENT_A]: { - threadShellById: hasServerThreadRef.current - ? { - [SHARED_THREAD_ID]: { - id: SHARED_THREAD_ID, - branch: BRANCH_NAME, - worktreePath: null, - }, - } - : {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - }, - [ENVIRONMENT_B]: { - threadShellById: hasServerThreadRef.current - ? { - [SHARED_THREAD_ID]: { - id: SHARED_THREAD_ID, - branch: BRANCH_NAME, - worktreePath: null, - }, - } - : {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - }, - }, - }), -})); - -vi.mock("~/terminal-links", () => ({ - resolvePathLinkTarget: vi.fn(), -})); - -import GitActionsControl from "./GitActionsControl"; - -function findButtonByText(text: string): HTMLButtonElement | null { - return (Array.from(document.querySelectorAll("button")).find((button) => - button.textContent?.includes(text), - ) ?? null) as HTMLButtonElement | null; -} - -function Harness() { - const [activeThreadRef, setActiveThreadRef] = useState( - scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), - ); - - return ( - <> - - - - ); -} - -describe("GitActionsControl thread-scoped progress toast", () => { - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - activeRunStackedActionDeferredRef.current = createDeferredPromise(); - activeDraftThreadRef.current = null; - hasServerThreadRef.current = true; - document.body.innerHTML = ""; - }); - - it("keeps an in-flight git action toast pinned to the thread ref that started it", async () => { - vi.useFakeTimers(); - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render(, { container: host }); - - try { - const quickActionButton = findButtonByText("Push & create PR"); - expect(quickActionButton, 'Unable to find button containing "Push & create PR"').toBeTruthy(); - if (!(quickActionButton instanceof HTMLButtonElement)) { - throw new Error('Unable to find button containing "Push & create PR"'); - } - quickActionButton.click(); - - expect(toastAddSpy).toHaveBeenCalledWith( - expect.objectContaining({ - data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, - title: "Pushing...", - type: "loading", - }), - ); - - await vi.advanceTimersByTimeAsync(1_000); - - expect(toastUpdateSpy).toHaveBeenLastCalledWith( - "toast-1", - expect.objectContaining({ - data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, - title: "Pushing...", - type: "loading", - }), - ); - - const switchEnvironmentButton = findButtonByText("Switch environment"); - expect( - switchEnvironmentButton, - 'Unable to find button containing "Switch environment"', - ).toBeTruthy(); - if (!(switchEnvironmentButton instanceof HTMLButtonElement)) { - throw new Error('Unable to find button containing "Switch environment"'); - } - switchEnvironmentButton.click(); - await vi.advanceTimersByTimeAsync(1_000); - - expect(toastUpdateSpy).toHaveBeenLastCalledWith( - "toast-1", - expect.objectContaining({ - data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, - title: "Pushing...", - type: "loading", - }), - ); - } finally { - activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup")); - await Promise.resolve(); - vi.useRealTimers(); - await screen.unmount(); - host.remove(); - } - }); - - it("debounces focus-driven git status refreshes", async () => { - vi.useFakeTimers(); - - const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); - let visibilityState: DocumentVisibilityState = "hidden"; - Object.defineProperty(document, "visibilityState", { - configurable: true, - get: () => visibilityState, - }); - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - , - { - container: host, - }, - ); - - try { - window.dispatchEvent(new Event("focus")); - visibilityState = "visible"; - document.dispatchEvent(new Event("visibilitychange")); - - expect(refreshGitStatusSpy).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(249); - expect(refreshGitStatusSpy).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); - expect(refreshGitStatusSpy).toHaveBeenCalledWith({ - environmentId: ENVIRONMENT_A, - cwd: GIT_CWD, - }); - } finally { - if (originalVisibilityState) { - Object.defineProperty(document, "visibilityState", originalVisibilityState); - } - vi.useRealTimers(); - await screen.unmount(); - host.remove(); - } - }); - - it("syncs the live branch into the active draft thread when no server thread exists", async () => { - hasServerThreadRef.current = false; - activeDraftThreadRef.current = { - threadId: SHARED_THREAD_ID, - environmentId: ENVIRONMENT_A, - branch: null, - worktreePath: null, - }; - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - , - { - container: host, - }, - ); - - try { - await Promise.resolve(); - - expect(setDraftThreadContextSpy).toHaveBeenCalledWith( - scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), - { - branch: BRANCH_NAME, - worktreePath: null, - }, - ); - expect(setThreadBranchSpy).not.toHaveBeenCalled(); - } finally { - await screen.unmount(); - host.remove(); - } - }); - - it("does not overwrite a selected base branch while a new worktree draft is being configured", async () => { - hasServerThreadRef.current = false; - activeDraftThreadRef.current = { - threadId: SHARED_THREAD_ID, - environmentId: ENVIRONMENT_A, - branch: "feature/base-branch", - worktreePath: null, - envMode: "worktree", - }; - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - , - { - container: host, - }, - ); - - try { - await Promise.resolve(); - - expect(setDraftThreadContextSpy).not.toHaveBeenCalled(); - expect(setThreadBranchSpy).not.toHaveBeenCalled(); - } finally { - await screen.unmount(); - host.remove(); - } - }); -}); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts deleted file mode 100644 index 6dbe8b64..00000000 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ /dev/null @@ -1,1133 +0,0 @@ -import type { VcsStatusResult } from "@cafecode/contracts"; -import { assert, describe, it } from "vitest"; -import { - buildGitActionProgressStages, - buildMenuItems, - requiresDefaultBranchConfirmation, - resolveAutoFeatureBranchName, - resolveDefaultBranchActionDialogCopy, - resolveLiveThreadBranchUpdate, - resolveQuickAction, - resolveThreadBranchUpdate, -} from "./GitActionsControl.logic"; - -function status(overrides: Partial = {}): VcsStatusResult { - return { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/test", - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - ...overrides, - }; -} - -describe("when: ref is clean and has an open PR", () => { - it("resolveQuickAction opens the existing PR", () => { - const quick = resolveQuickAction( - status({ - pr: { - number: 10, - title: "Open PR", - url: "https://example.com/pr/10", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { kind: "open_pr", label: "View PR", disabled: false }); - }); - - it("buildMenuItems disables commit/push and enables open PR", () => { - const items = buildMenuItems( - status({ - pr: { - number: 11, - title: "Existing PR", - url: "https://example.com/pr/11", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "View PR", - disabled: false, - icon: "pr", - kind: "open_pr", - }, - ]); - }); -}); - -describe("when: actions are busy", () => { - it("resolveQuickAction returns running disabled state", () => { - const quick = resolveQuickAction(status(), true); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Commit", - disabled: true, - hint: "Git action in progress.", - }); - }); - - it("buildMenuItems disables all actions", () => { - const items = buildMenuItems(status(), true); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: git status is unavailable", () => { - it("resolveQuickAction returns unavailable disabled state", () => { - const quick = resolveQuickAction(null, false); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Commit", - disabled: true, - hint: "Git status is unavailable.", - }); - }); - - it("buildMenuItems returns no menu items", () => { - const items = buildMenuItems(null, false); - assert.deepEqual(items, []); - }); -}); - -describe("when: ref is clean, ahead, and has an open PR", () => { - it("resolveQuickAction prefers push", () => { - const quick = resolveQuickAction( - status({ - aheadCount: 3, - pr: { - number: 13, - title: "Open PR", - url: "https://example.com/pr/13", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { kind: "run_action", action: "push", label: "Push" }); - }); - - it("buildMenuItems enables push and keeps open PR available", () => { - const items = buildMenuItems( - status({ - aheadCount: 2, - pr: { - number: 12, - title: "Existing PR", - url: "https://example.com/pr/12", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: false, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "View PR", - disabled: false, - icon: "pr", - kind: "open_pr", - }, - ]); - }); -}); - -describe("when: ref is clean, ahead, and has no open PR", () => { - it("resolveQuickAction pushes and creates a PR", () => { - const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); - assert.deepInclude(quick, { - kind: "run_action", - action: "create_pr", - label: "Push & create PR", - }); - }); - - it("buildMenuItems enables push and create PR, with commit disabled", () => { - const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: false, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: false, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: source control provider uses merge requests", () => { - it("uses GitLab MR terminology in quick actions and menu items", () => { - const gitlabStatus = status({ - aheadCount: 2, - sourceControlProvider: { - kind: "gitlab", - name: "GitLab", - baseUrl: "https://gitlab.com", - }, - }); - - const quick = resolveQuickAction(gitlabStatus, false); - const items = buildMenuItems(gitlabStatus, false); - - assert.deepInclude(quick, { - kind: "run_action", - action: "create_pr", - label: "Push & create MR", - }); - assert.deepInclude(items[2], { - id: "pr", - label: "Create MR", - }); - }); -}); - -describe("when: ref is clean, up to date, and has no open PR", () => { - it("enables create PR when synced with upstream but ahead of default", () => { - const syncedFeature = status({ - aheadCount: 0, - behindCount: 0, - aheadOfDefaultCount: 1, - pr: null, - }); - - const quick = resolveQuickAction(syncedFeature, false); - assert.deepInclude(quick, { - label: "Create PR", - disabled: false, - kind: "run_action", - action: "create_pr", - }); - - const items = buildMenuItems(syncedFeature, false); - assert.equal(items.find((item) => item.id === "pr")?.disabled, false); - }); - - it("resolveQuickAction returns disabled no-action state", () => { - const quick = resolveQuickAction( - status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }), - false, - ); - assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); - }); - - it("buildMenuItems disables commit, push, and create PR", () => { - const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, pr: null }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: ref is behind upstream", () => { - it("resolveQuickAction returns pull", () => { - const quick = resolveQuickAction(status({ behindCount: 2 }), false); - assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false }); - }); - - it("buildMenuItems disables push and create PR", () => { - const items = buildMenuItems(status({ behindCount: 1, pr: null }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: ref has diverged from upstream", () => { - it("resolveQuickAction returns a disabled sync hint", () => { - const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false); - assert.deepEqual(quick, { - label: "Sync ref", - disabled: true, - kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", - }); - }); -}); - -describe("when: working tree has local changes", () => { - it("resolveQuickAction returns commit, push, and create PR", () => { - const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push_pr", - label: "Commit, push & PR", - }); - }); - - it("resolveQuickAction falls back to commit when no origin remote exists", () => { - const quick = resolveQuickAction( - status({ hasWorkingTreeChanges: true, hasUpstream: false }), - false, - false, - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit", - label: "Commit", - disabled: false, - }); - }); - - it("resolveQuickAction returns commit and push when open PR exists", () => { - const quick = resolveQuickAction( - status({ - hasWorkingTreeChanges: true, - pr: { - number: 16, - title: "Existing PR", - url: "https://example.com/pr/16", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Commit & push", - }); - }); - - it("buildMenuItems enables commit and disables push and PR", () => { - const items = buildMenuItems(status({ hasWorkingTreeChanges: true }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: false, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); - - it("buildMenuItems enables push for ahead commits while local changes remain uncommitted", () => { - const items = buildMenuItems( - status({ - refName: "feature/test", - hasWorkingTreeChanges: true, - aheadCount: 1, - workingTree: { - files: [{ path: ".vercel/project.json", insertions: 1, deletions: 0 }], - insertions: 1, - deletions: 0, - }, - }), - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: false, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: false, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: on default ref without open PR", () => { - it("resolveQuickAction returns commit and push when local changes exist", () => { - const quick = resolveQuickAction( - status({ refName: "main", hasWorkingTreeChanges: true }), - false, - true, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Commit & push", - disabled: false, - }); - }); - - it("resolveQuickAction returns push when ref is ahead", () => { - const quick = resolveQuickAction( - status({ refName: "main", aheadCount: 2, pr: null }), - false, - true, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Push", - disabled: false, - }); - }); -}); - -describe("when: working tree has local changes and ref is behind upstream", () => { - it("resolveQuickAction still prefers commit, push, and create PR", () => { - const quick = resolveQuickAction( - status({ hasWorkingTreeChanges: true, behindCount: 1 }), - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push_pr", - label: "Commit, push & PR", - }); - }); - - it("buildMenuItems enables commit and keeps push and PR disabled", () => { - const items = buildMenuItems(status({ hasWorkingTreeChanges: true, behindCount: 2 }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: false, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: HEAD is detached and there are no local changes", () => { - it("resolveQuickAction shows detached head hint", () => { - const quick = resolveQuickAction( - status({ refName: null, hasWorkingTreeChanges: false, hasUpstream: false }), - false, - ); - assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); - }); - - it("buildMenuItems keeps commit, push, and PR disabled", () => { - const items = buildMenuItems(status({ refName: null, hasWorkingTreeChanges: false }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: ref has no upstream configured", () => { - it("resolveQuickAction is disabled when clean, no upstream, and no local commits are ahead", () => { - const quick = resolveQuickAction( - status({ hasUpstream: false, pr: null, aheadCount: 0 }), - false, - ); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Push", - hint: "No local commits to push.", - disabled: true, - }); - }); - - it("resolveQuickAction opens PR when clean, no upstream, no local commits are ahead, and PR exists", () => { - const quick = resolveQuickAction( - status({ - hasUpstream: false, - aheadCount: 0, - pr: { - number: 14, - title: "Existing PR", - url: "https://example.com/pr/14", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { - kind: "open_pr", - label: "View PR", - disabled: false, - }); - }); - - it("resolveQuickAction runs push when clean, no upstream, and local commits are ahead", () => { - const quick = resolveQuickAction( - status({ - hasUpstream: false, - aheadCount: 1, - pr: { - number: 15, - title: "Existing PR", - url: "https://example.com/pr/15", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "push", - label: "Push", - disabled: false, - }); - }); - - it("buildMenuItems disables push and create PR when no commits are ahead", () => { - const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 0 }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); - - it("resolveQuickAction runs push and create PR when no upstream and commits are ahead", () => { - const quick = resolveQuickAction( - status({ - hasUpstream: false, - aheadCount: 2, - pr: null, - }), - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "create_pr", - label: "Push & create PR", - disabled: false, - }); - }); - - it("resolveQuickAction publishes when no origin remote exists", () => { - const quick = resolveQuickAction( - status({ - hasUpstream: false, - aheadCount: 2, - pr: null, - }), - false, - false, - false, - ); - assert.deepEqual(quick, { - kind: "open_publish", - label: "Publish repository", - disabled: false, - }); - }); - - it("buildMenuItems enables create PR when no upstream and commits are ahead", () => { - const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 2 }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: false, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: false, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); - - it("buildMenuItems hides push and create PR when no origin remote exists", () => { - const items = buildMenuItems( - status({ hasUpstream: false, pr: null, aheadCount: 2 }), - false, - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - ]); - }); - - it("resolveQuickAction is disabled on default ref when no upstream exists and no commits are ahead", () => { - const quick = resolveQuickAction( - status({ - refName: "main", - hasUpstream: false, - aheadCount: 0, - pr: null, - }), - false, - true, - ); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Push", - hint: "No local commits to push.", - disabled: true, - }); - }); - - it("resolveQuickAction uses push-only on default ref when no upstream exists and commits are ahead", () => { - const quick = resolveQuickAction( - status({ - refName: "main", - hasUpstream: false, - aheadCount: 1, - pr: null, - }), - false, - true, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Push", - disabled: false, - }); - }); - - it("buildMenuItems still disables push and create PR when ref is behind", () => { - const items = buildMenuItems( - status({ - hasUpstream: false, - behindCount: 1, - aheadCount: 0, - pr: null, - }), - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("requiresDefaultBranchConfirmation", () => { - it("requires confirmation for push actions on default ref", () => { - assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); - assert.isTrue(requiresDefaultBranchConfirmation("push", true)); - assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true)); - assert.isTrue(requiresDefaultBranchConfirmation("commit_push", true)); - assert.isTrue(requiresDefaultBranchConfirmation("commit_push_pr", true)); - assert.isFalse(requiresDefaultBranchConfirmation("commit_push", false)); - assert.isFalse(requiresDefaultBranchConfirmation("push", false)); - }); -}); - -describe("resolveDefaultBranchActionDialogCopy", () => { - it("uses push-only copy when pushing without a commit", () => { - const copy = resolveDefaultBranchActionDialogCopy({ - action: "commit_push", - branchName: "main", - includesCommit: false, - }); - - assert.deepEqual(copy, { - title: "Push to default ref?", - description: - 'This action will push local commits on "main". You can continue on this ref or create a feature ref and run the same action there.', - continueLabel: "Push to main", - }); - }); - - it("uses push-and-pr copy when creating a PR without a commit", () => { - const copy = resolveDefaultBranchActionDialogCopy({ - action: "commit_push_pr", - branchName: "main", - includesCommit: false, - }); - - assert.deepEqual(copy, { - title: "Push & create PR from default ref?", - description: - 'This action will push local commits and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', - continueLabel: "Push & create PR", - }); - }); - - it("keeps commit copy when the action includes a commit", () => { - const copy = resolveDefaultBranchActionDialogCopy({ - action: "commit_push_pr", - branchName: "main", - includesCommit: true, - }); - - assert.deepEqual(copy, { - title: "Commit, push & create PR from default ref?", - description: - 'This action will commit, push, and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', - continueLabel: "Commit, push & create PR", - }); - }); -}); - -describe("buildGitActionProgressStages", () => { - it("shows only push progress for explicit push actions", () => { - const stages = buildGitActionProgressStages({ - action: "push", - hasCustomCommitMessage: false, - hasWorkingTreeChanges: false, - pushTarget: "origin/feature/test", - }); - assert.deepEqual(stages, ["Pushing to origin/feature/test..."]); - }); - - it("shows push and PR progress for create-pr actions that still need a push", () => { - const stages = buildGitActionProgressStages({ - action: "create_pr", - hasCustomCommitMessage: false, - hasWorkingTreeChanges: false, - pushTarget: "origin/feature/test", - shouldPushBeforePr: true, - }); - assert.deepEqual(stages, [ - "Pushing to origin/feature/test...", - "Preparing PR...", - "Generating PR content...", - "Creating pull request...", - ]); - }); - - it("shows only PR progress when create-pr can skip the push", () => { - const stages = buildGitActionProgressStages({ - action: "create_pr", - hasCustomCommitMessage: false, - hasWorkingTreeChanges: false, - shouldPushBeforePr: false, - }); - assert.deepEqual(stages, [ - "Preparing PR...", - "Generating PR content...", - "Creating pull request...", - ]); - }); - - it("includes commit stages for commit+push when working tree is dirty", () => { - const stages = buildGitActionProgressStages({ - action: "commit_push", - hasCustomCommitMessage: false, - hasWorkingTreeChanges: true, - pushTarget: "origin/feature/test", - }); - assert.deepEqual(stages, [ - "Generating commit message...", - "Committing...", - "Pushing to origin/feature/test...", - ]); - }); - - it("includes granular PR stages for commit+push+PR actions", () => { - const stages = buildGitActionProgressStages({ - action: "commit_push_pr", - hasCustomCommitMessage: true, - hasWorkingTreeChanges: true, - pushTarget: "origin/feature/test", - }); - assert.deepEqual(stages, [ - "Committing...", - "Pushing to origin/feature/test...", - "Preparing PR...", - "Generating PR content...", - "Creating pull request...", - ]); - }); -}); - -describe("resolveThreadBranchUpdate", () => { - it("returns a branch update when the action created a new branch", () => { - const update = resolveThreadBranchUpdate({ - action: "commit_push_pr", - branch: { - status: "created", - name: "feature/fix-toast-copy", - }, - commit: { - status: "created", - commitSha: "89abcdef01234567", - subject: "feat: add ref sync", - }, - push: { status: "pushed", branch: "feature/fix-toast-copy" }, - pr: { status: "skipped_not_requested" }, - toast: { - title: "Pushed 89abcde to origin/feature/fix-toast-copy", - cta: { kind: "none" }, - }, - }); - - assert.deepEqual(update, { - branch: "feature/fix-toast-copy", - }); - }); - - it("returns null when the action stayed on the existing branch", () => { - const update = resolveThreadBranchUpdate({ - action: "commit_push", - branch: { - status: "skipped_not_requested", - }, - commit: { - status: "created", - commitSha: "89abcdef01234567", - subject: "feat: add ref sync", - }, - push: { status: "pushed", branch: "feature/fix-toast-copy" }, - pr: { status: "skipped_not_requested" }, - toast: { - title: "Pushed 89abcde to origin/feature/fix-toast-copy", - cta: { kind: "none" }, - }, - }); - - assert.equal(update, null); - }); -}); - -describe("resolveLiveThreadBranchUpdate", () => { - it("returns a branch update when live git status differs from stored thread metadata", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-ref", - gitStatus: status({ refName: "effect-atom" }), - }); - - assert.deepEqual(update, { - branch: "effect-atom", - }); - }); - - it("returns null when live git status is unavailable", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-ref", - gitStatus: null, - }); - - assert.equal(update, null); - }); - - it("returns null when the stored thread ref already matches git status", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "effect-atom", - gitStatus: status({ refName: "effect-atom" }), - }); - - assert.equal(update, null); - }); - - it("returns null when git status is detached HEAD but the thread already has a ref", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "effect-atom", - gitStatus: status({ refName: null }), - }); - - assert.equal(update, null); - }); - - it("does not regress a semantic thread ref back to a temporary worktree ref", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "t3code/github-query-rate-limit", - gitStatus: status({ refName: "t3code/bda76797" }), - }); - - assert.equal(update, null); - }); -}); - -describe("resolveAutoFeatureBranchName", () => { - it("uses semantic preferred ref names when available", () => { - const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); - assert.equal(ref, "feature/fix-toast-copy"); - }); - - it("normalizes preferred names that already include a ref namespace", () => { - const ref = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); - assert.equal(ref, "feature/refine-toolbar-actions"); - }); - - it("increments suffix when the preferred ref name already exists", () => { - const ref = resolveAutoFeatureBranchName( - ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"], - "fix toast copy", - ); - assert.equal(ref, "feature/fix-toast-copy-3"); - }); - - it("treats existing ref names as case-insensitive for collision checks", () => { - const ref = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1"); - assert.equal(ref, "feature/ticket-1-2"); - }); - - it("falls back to feature/update when no preferred name is provided", () => { - const ref = resolveAutoFeatureBranchName(["main"]); - assert.equal(ref, "feature/update"); - }); -}); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts deleted file mode 100644 index 5229b8be..00000000 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ /dev/null @@ -1,407 +0,0 @@ -import type { - GitRunStackedActionResult, - GitStackedAction, - VcsStatusResult, -} from "@cafecode/contracts"; -import { isTemporaryWorktreeBranch } from "@cafecode/shared/git"; -import { - DEFAULT_CHANGE_REQUEST_TERMINOLOGY, - getChangeRequestTerminology, - type ChangeRequestTerminology, -} from "../sourceControlPresentation"; - -export type GitActionIconName = "commit" | "push" | "pr"; - -export type GitDialogAction = "commit" | "push" | "create_pr"; - -export interface GitActionMenuItem { - id: "commit" | "push" | "pr"; - label: string; - disabled: boolean; - icon: GitActionIconName; - kind: "open_dialog" | "open_pr"; - dialogAction?: GitDialogAction; -} - -export interface GitQuickAction { - label: string; - disabled: boolean; - kind: "run_action" | "run_pull" | "open_pr" | "open_publish" | "show_hint"; - action?: GitStackedAction; - hint?: string; -} - -export interface DefaultBranchActionDialogCopy { - title: string; - description: string; - continueLabel: string; -} - -export type DefaultBranchConfirmableAction = - | "push" - | "create_pr" - | "commit_push" - | "commit_push_pr"; - -function resolveChangeRequestTerminology( - gitStatus: VcsStatusResult | null, -): ChangeRequestTerminology { - return gitStatus?.sourceControlProvider - ? getChangeRequestTerminology(gitStatus.sourceControlProvider) - : DEFAULT_CHANGE_REQUEST_TERMINOLOGY; -} - -export function buildGitActionProgressStages(input: { - action: GitStackedAction; - hasCustomCommitMessage: boolean; - hasWorkingTreeChanges: boolean; - pushTarget?: string; - featureBranch?: boolean; - shouldPushBeforePr?: boolean; - terminology?: ChangeRequestTerminology; -}): string[] { - const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; - const branchStages = input.featureBranch ? ["Preparing feature ref..."] : []; - const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; - const prStages = [ - `Preparing ${terminology.shortLabel}...`, - `Generating ${terminology.shortLabel} content...`, - `Creating ${terminology.singular}...`, - ]; - - if (input.action === "push") { - return [pushStage]; - } - if (input.action === "create_pr") { - return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages; - } - - const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; - const commitStages = !shouldIncludeCommitStages - ? [] - : input.hasCustomCommitMessage - ? ["Committing..."] - : ["Generating commit message...", "Committing..."]; - if (input.action === "commit") { - return [...branchStages, ...commitStages]; - } - if (input.action === "commit_push") { - return [...branchStages, ...commitStages, pushStage]; - } - return [...branchStages, ...commitStages, pushStage, ...prStages]; -} - -export function buildMenuItems( - gitStatus: VcsStatusResult | null, - isBusy: boolean, - hasPrimaryRemote = true, -): GitActionMenuItem[] { - if (!gitStatus) return []; - const terminology = resolveChangeRequestTerminology(gitStatus); - - const hasBranch = gitStatus.refName !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.pr?.state === "open"; - const isBehind = gitStatus.behindCount > 0; - const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; - const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream; - const canCommit = !isBusy && hasChanges; - const canPush = - !isBusy && - hasBranch && - !isBehind && - gitStatus.aheadCount > 0 && - (gitStatus.hasUpstream || canPushWithoutUpstream); - const canCreatePr = - !isBusy && - hasBranch && - !hasChanges && - !hasOpenPr && - hasDefaultBranchDelta && - !isBehind && - (gitStatus.hasUpstream || canPushWithoutUpstream); - const canOpenPr = !isBusy && hasOpenPr; - - const commitItem: GitActionMenuItem = { - id: "commit", - label: "Commit", - disabled: !canCommit, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }; - - if (!hasPrimaryRemote) { - return [commitItem]; - } - - return [ - commitItem, - { - id: "push", - label: "Push", - disabled: !canPush, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - hasOpenPr - ? { - id: "pr", - label: `View ${terminology.shortLabel}`, - disabled: !canOpenPr, - icon: "pr", - kind: "open_pr", - } - : { - id: "pr", - label: `Create ${terminology.shortLabel}`, - disabled: !canCreatePr, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]; -} - -export function resolveQuickAction( - gitStatus: VcsStatusResult | null, - isBusy: boolean, - isDefaultRef = false, - hasPrimaryRemote = true, -): GitQuickAction { - if (isBusy) { - return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; - } - - if (!gitStatus) { - return { - label: "Commit", - disabled: true, - kind: "show_hint", - hint: "Git status is unavailable.", - }; - } - - const hasBranch = gitStatus.refName !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.pr?.state === "open"; - const isAhead = gitStatus.aheadCount > 0; - const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; - const isBehind = gitStatus.behindCount > 0; - const isDiverged = isAhead && isBehind; - const terminology = resolveChangeRequestTerminology(gitStatus); - - if (!hasBranch) { - return { - label: "Commit", - disabled: true, - kind: "show_hint", - hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`, - }; - } - - if (hasChanges) { - if (!gitStatus.hasUpstream && !hasPrimaryRemote) { - return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; - } - if (hasOpenPr || isDefaultRef) { - return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; - } - return { - label: `Commit, push & ${terminology.shortLabel}`, - disabled: false, - kind: "run_action", - action: "commit_push_pr", - }; - } - - if (!gitStatus.hasUpstream) { - if (!hasPrimaryRemote) { - if (hasOpenPr && !isAhead) { - return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; - } - return { - label: "Publish repository", - disabled: false, - kind: "open_publish", - }; - } - if (!isAhead) { - if (hasOpenPr) { - return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; - } - return { - label: "Push", - disabled: true, - kind: "show_hint", - hint: "No local commits to push.", - }; - } - if (hasOpenPr || isDefaultRef) { - return { - label: "Push", - disabled: false, - kind: "run_action", - action: isDefaultRef ? "commit_push" : "push", - }; - } - return { - label: `Push & create ${terminology.shortLabel}`, - disabled: false, - kind: "run_action", - action: "create_pr", - }; - } - - if (isDiverged) { - return { - label: "Sync ref", - disabled: true, - kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", - }; - } - - if (isBehind) { - return { - label: "Pull", - disabled: false, - kind: "run_pull", - }; - } - - if (isAhead) { - if (hasOpenPr || isDefaultRef) { - return { - label: "Push", - disabled: false, - kind: "run_action", - action: isDefaultRef ? "commit_push" : "push", - }; - } - return { - label: `Push & create ${terminology.shortLabel}`, - disabled: false, - kind: "run_action", - action: "create_pr", - }; - } - - if (hasOpenPr && gitStatus.hasUpstream) { - return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; - } - - if (hasDefaultBranchDelta && !isDefaultRef) { - return { - label: `Create ${terminology.shortLabel}`, - disabled: false, - kind: "run_action", - action: "create_pr", - }; - } - - return { - label: "Commit", - disabled: true, - kind: "show_hint", - hint: "Branch is up to date. No action needed.", - }; -} - -export function requiresDefaultBranchConfirmation( - action: GitStackedAction, - isDefaultRef: boolean, -): boolean { - if (!isDefaultRef) return false; - return ( - action === "push" || - action === "create_pr" || - action === "commit_push" || - action === "commit_push_pr" - ); -} - -export function resolveDefaultBranchActionDialogCopy(input: { - action: DefaultBranchConfirmableAction; - branchName: string; - includesCommit: boolean; - terminology?: ChangeRequestTerminology; -}): DefaultBranchActionDialogCopy { - const branchLabel = input.branchName; - const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`; - const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; - - if (input.action === "push" || input.action === "commit_push") { - if (input.includesCommit) { - return { - title: "Commit & push to default ref?", - description: `This action will commit and push changes${suffix}`, - continueLabel: `Commit & push to ${branchLabel}`, - }; - } - return { - title: "Push to default ref?", - description: `This action will push local commits${suffix}`, - continueLabel: `Push to ${branchLabel}`, - }; - } - - if (input.includesCommit) { - return { - title: `Commit, push & create ${terminology.shortLabel} from default ref?`, - description: `This action will commit, push, and create a ${terminology.singular}${suffix}`, - continueLabel: `Commit, push & create ${terminology.shortLabel}`, - }; - } - return { - title: `Push & create ${terminology.shortLabel} from default ref?`, - description: `This action will push local commits and create a ${terminology.singular}${suffix}`, - continueLabel: `Push & create ${terminology.shortLabel}`, - }; -} - -export function resolveThreadBranchUpdate( - result: GitRunStackedActionResult, -): { branch: string } | null { - if (result.branch.status !== "created" || !result.branch.name) { - return null; - } - - return { - branch: result.branch.name, - }; -} - -export function resolveLiveThreadBranchUpdate(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; -}): { branch: string | null } | null { - if (!input.gitStatus) { - return null; - } - - if (input.gitStatus.refName === null && input.threadBranch !== null) { - return null; - } - - if (input.threadBranch === input.gitStatus.refName) { - return null; - } - - if ( - input.threadBranch !== null && - input.gitStatus.refName !== null && - !isTemporaryWorktreeBranch(input.threadBranch) && - isTemporaryWorktreeBranch(input.gitStatus.refName) - ) { - return null; - } - - return { - branch: input.gitStatus.refName, - }; -} - -// Re-export from shared for backwards compatibility in this module's exports -export { resolveAutoFeatureBranchName } from "@cafecode/shared/git"; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx deleted file mode 100644 index c85b2879..00000000 --- a/apps/web/src/components/GitActionsControl.tsx +++ /dev/null @@ -1,1985 +0,0 @@ -import { type ScopedThreadRef } from "@cafecode/contracts"; -import type { - GitActionProgressEvent, - GitRunStackedActionResult, - GitStackedAction, - SourceControlCloneProtocol, - SourceControlProviderDiscoveryItem, - SourceControlProviderKind, - SourceControlPublishRepositoryResult, - SourceControlRepositoryVisibility, - VcsStatusResult, -} from "@cafecode/contracts"; -import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; -import * as Option from "effect/Option"; -import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { flushSync } from "react-dom"; -import { - CheckIcon, - ChevronDownIcon, - CloudUploadIcon, - ExternalLinkIcon, - GitCommitIcon, - InfoIcon, - LockIcon, - GlobeIcon, -} from "lucide-react"; -import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons"; -import { RadioGroup } from "~/components/ui/radio-group"; -import { Spinner } from "~/components/ui/spinner"; -import { cn } from "~/lib/utils"; -import { - buildGitActionProgressStages, - buildMenuItems, - type GitActionIconName, - type GitActionMenuItem, - type GitQuickAction, - type DefaultBranchConfirmableAction, - requiresDefaultBranchConfirmation, - resolveDefaultBranchActionDialogCopy, - resolveLiveThreadBranchUpdate, - resolveQuickAction, - resolveThreadBranchUpdate, -} from "./GitActionsControl.logic"; -import { AnimatedHeight } from "./AnimatedHeight"; -import { Button } from "~/components/ui/button"; -import { Checkbox } from "~/components/ui/checkbox"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "~/components/ui/dialog"; -import { Group, GroupSeparator } from "~/components/ui/group"; -import { Input } from "~/components/ui/input"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; -import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; -import { ScrollArea } from "~/components/ui/scroll-area"; -import { Textarea } from "~/components/ui/textarea"; -import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -import { openInPreferredEditor } from "~/editorPreferences"; -import { - gitInitMutationOptions, - gitMutationKeys, - gitPullMutationOptions, - gitRunStackedActionMutationOptions, - sourceControlPublishRepositoryMutationOptions, -} from "~/lib/gitReactQuery"; -import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; -import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; -import { newCommandId, randomUUID } from "~/lib/utils"; -import { resolvePathLinkTarget } from "~/terminal-links"; -import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; -import { readEnvironmentApi } from "~/environmentApi"; -import { readLocalApi } from "~/localApi"; -import { getSourceControlPresentation } from "~/sourceControlPresentation"; -import { useStore } from "~/store"; -import { createThreadSelectorByRef } from "~/storeSelectors"; - -interface GitActionsControlProps { - gitCwd: string | null; - activeThreadRef: ScopedThreadRef | null; - draftId?: DraftId; -} - -interface PendingDefaultBranchAction { - action: DefaultBranchConfirmableAction; - branchName: string; - includesCommit: boolean; - commitMessage?: string; - onConfirmed?: () => void; - filePaths?: string[]; -} - -type PublishProviderKind = Extract< - SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" ->; - -type GitActionToastId = ReturnType; - -interface ActiveGitActionProgress { - toastId: GitActionToastId; - toastData: ThreadToastData | undefined; - actionId: string; - title: string; - phaseStartedAtMs: number | null; - hookStartedAtMs: number | null; - hookName: string | null; - lastOutputLine: string | null; - currentPhaseLabel: string | null; -} - -interface RunGitActionWithToastInput { - action: GitStackedAction; - commitMessage?: string; - onConfirmed?: () => void; - skipDefaultBranchPrompt?: boolean; - statusOverride?: VcsStatusResult | null; - featureBranch?: boolean; - progressToastId?: GitActionToastId; - filePaths?: string[]; -} - -const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; - -const PUBLISH_PROVIDER_OPTIONS = [ - { - value: "github", - label: "GitHub", - description: "github.com", - host: "github.com", - pathPlaceholder: "owner/repo", - Icon: GitHubIcon, - }, - { - value: "gitlab", - label: "GitLab", - description: "gitlab.com", - host: "gitlab.com", - pathPlaceholder: "group/project", - Icon: GitLabIcon, - }, - { - value: "bitbucket", - label: "Bitbucket", - description: "bitbucket.org", - host: "bitbucket.org", - pathPlaceholder: "workspace/repository", - Icon: BitbucketIcon, - }, - { - value: "azure-devops", - label: "Azure DevOps", - description: "dev.azure.com", - host: "dev.azure.com", - pathPlaceholder: "project/repository", - Icon: AzureDevOpsIcon, - }, -] as const satisfies ReadonlyArray<{ - readonly value: PublishProviderKind; - readonly label: string; - readonly description: string; - readonly host: string; - readonly pathPlaceholder: string; - readonly Icon: typeof GitHubIcon; -}>; - -function publishProviderOption(provider: PublishProviderKind) { - return ( - PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ?? - PUBLISH_PROVIDER_OPTIONS[0] - ); -} - -function isPublishProviderKind( - provider: SourceControlProviderKind, -): provider is PublishProviderKind { - return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider); -} - -function getPublishProviderReadiness(input: { - provider: PublishProviderKind; - sourceControlProviders: ReadonlyArray; -}): { readonly ready: boolean; readonly hint: string | null } { - const discovered = input.sourceControlProviders.find( - (provider) => provider.kind === input.provider, - ); - if (!discovered) { - return { - ready: false, - hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", - }; - } - if (discovered.status !== "available") { - return { ready: false, hint: discovered.installHint }; - } - if (discovered.auth.status === "unauthenticated") { - return { - ready: false, - hint: - Option.getOrNull(discovered.auth.detail) ?? - `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, - }; - } - return { ready: true, hint: null }; -} - -function formatElapsedDescription(startedAtMs: number | null): string | undefined { - if (startedAtMs === null) { - return undefined; - } - const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); - if (elapsedSeconds < 60) { - return `Running for ${elapsedSeconds}s`; - } - const minutes = Math.floor(elapsedSeconds / 60); - const seconds = elapsedSeconds % 60; - return `Running for ${minutes}m ${seconds}s`; -} - -function resolveProgressDescription(progress: ActiveGitActionProgress): string | undefined { - if (progress.lastOutputLine) { - return progress.lastOutputLine; - } - return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs); -} - -function getMenuActionDisabledReason({ - item, - gitStatus, - isBusy, - hasPrimaryRemote, -}: { - item: GitActionMenuItem; - gitStatus: VcsStatusResult | null; - isBusy: boolean; - hasPrimaryRemote: boolean; -}): string | null { - if (!item.disabled) return null; - if (isBusy) return "Git action in progress."; - if (!gitStatus) return "Git status is unavailable."; - - const hasBranch = gitStatus.refName !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.pr?.state === "open"; - const isAhead = gitStatus.aheadCount > 0; - const isBehind = gitStatus.behindCount > 0; - const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology; - - if (item.id === "commit") { - if (!hasChanges) { - return "Worktree is clean. Make changes before committing."; - } - return "Commit is currently unavailable."; - } - - if (item.id === "push") { - if (!hasBranch) { - return "Detached HEAD: checkout a refName before pushing."; - } - if (hasChanges) { - return "Commit or stash local changes before pushing."; - } - if (isBehind) { - return "Branch is behind upstream. Pull/rebase before pushing."; - } - if (!gitStatus.hasUpstream && !hasPrimaryRemote) { - return 'Add an "origin" remote before pushing.'; - } - if (!isAhead) { - return "No local commits to push."; - } - return "Push is currently unavailable."; - } - - if (hasOpenPr) { - return `View ${terminology.singular} is currently unavailable.`; - } - if (!hasBranch) { - return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; - } - if (hasChanges) { - return `Commit local changes before creating a ${terminology.singular}.`; - } - if (!gitStatus.hasUpstream && !hasPrimaryRemote) { - return `Add an "origin" remote before creating a ${terminology.singular}.`; - } - if (!isAhead) { - return `No local commits to include in a ${terminology.singular}.`; - } - if (isBehind) { - return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; - } - return `Create ${terminology.singular} is currently unavailable.`; -} - -const COMMIT_DIALOG_TITLE = "Commit changes"; -const COMMIT_DIALOG_DESCRIPTION = - "Review and confirm your commit. Leave the message blank to auto-generate one."; - -function GitActionItemIcon({ - icon, - SourceControlIcon, -}: { - icon: GitActionIconName; - SourceControlIcon: ReturnType["Icon"]; -}) { - if (icon === "commit") return ; - if (icon === "push") return ; - return ; -} - -function GitQuickActionIcon({ - quickAction, - SourceControlIcon, -}: { - quickAction: GitQuickAction; - SourceControlIcon: ReturnType["Icon"]; -}) { - const iconClassName = "size-3.5"; - if (quickAction.kind === "open_pr") return ; - if (quickAction.kind === "open_publish") return ; - if (quickAction.kind === "run_pull") return ; - if (quickAction.kind === "run_action") { - if (quickAction.action === "commit") return ; - if (quickAction.action === "push" || quickAction.action === "commit_push") { - return ; - } - return ; - } - if (quickAction.label === "Commit") return ; - return ; -} - -interface PublishRepositoryDialogProps { - readonly open: boolean; - readonly onOpenChange: (open: boolean) => void; - readonly environmentId: ScopedThreadRef["environmentId"] | null; - readonly gitCwd: string; -} - -function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { - const queryClient = useQueryClient(); - const navigate = useNavigate(); - const sourceControlDiscovery = useSourceControlDiscovery(); - const [publishProvider, setPublishProvider] = useState("github"); - const [publishRepository, setPublishRepository] = useState(""); - const [publishVisibility, setPublishVisibility] = - useState("private"); - const [publishRemoteName, setPublishRemoteName] = useState("origin"); - const [publishProtocol, setPublishProtocol] = useState("ssh"); - const [publishWizardStep, setPublishWizardStep] = useState(0); - const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); - const [publishError, setPublishError] = useState(null); - const [publishResult, setPublishResult] = useState( - null, - ); - const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); - const publishRepositoryMutation = useMutation( - sourceControlPublishRepositoryMutationOptions({ - environmentId: props.environmentId, - cwd: props.gitCwd, - queryClient, - }), - ); - const publishAccountByProvider = useMemo(() => { - const accounts: Record = { - github: null, - gitlab: null, - bitbucket: null, - "azure-devops": null, - }; - for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { - if (isPublishProviderKind(provider.kind)) { - accounts[provider.kind] = Option.getOrNull(provider.auth.account); - } - } - return accounts; - }, [sourceControlDiscovery.data]); - const publishProviderReadiness = useMemo(() => { - const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; - return Object.fromEntries( - PUBLISH_PROVIDER_OPTIONS.map((option) => [ - option.value, - getPublishProviderReadiness({ - provider: option.value, - sourceControlProviders, - }), - ]), - ) as Record; - }, [sourceControlDiscovery.data]); - const hasReadyPublishProvider = useMemo( - () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness[option.value].ready), - [publishProviderReadiness], - ); - const sortedPublishProviderOptions = useMemo( - () => - PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => { - const leftReady = publishProviderReadiness[left.value].ready; - const rightReady = publishProviderReadiness[right.value].ready; - if (leftReady !== rightReady) { - return leftReady ? -1 : 1; - } - return left.label.localeCompare(right.label); - }), - [publishProviderReadiness], - ); - const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; - const publishRepositoryPrefill = publishAccountByProvider[publishProvider] - ? `${publishAccountByProvider[publishProvider]}/` - : ""; - const currentPublishProvider = publishProviderOption(publishProvider); - const publishHost = currentPublishProvider.host; - const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; - const publishProviderLabel = currentPublishProvider.label; - const publishWizardSteps = ["Provider", "Repository", "Summary"] as const; - const publishWizardStepSummaries = [ - publishProviderLabel, - publishResult?.repository.nameWithOwner ?? null, - null, - ] as const; - - useEffect(() => { - if (!props.open || hasUserEditedPublishRepository) { - return; - } - setPublishRepository(publishRepositoryPrefill); - }, [hasUserEditedPublishRepository, props.open, publishRepositoryPrefill]); - - const canSubmitPublishRepository = useMemo(() => { - if (!selectedPublishProviderReadiness.ready) return false; - if (publishRepositoryMutation.isPending) return false; - const repositoryParts = publishRepository.trim().split("/"); - const owner = repositoryParts[0]?.trim() ?? ""; - const rest = repositoryParts.slice(1); - const name = rest.join("/").trim(); - return owner.length > 0 && name.length > 0; - }, [publishRepository, publishRepositoryMutation.isPending, selectedPublishProviderReadiness]); - - useEffect(() => { - if (!props.open) { - return; - } - if (publishProviderReadiness[publishProvider].ready) { - return; - } - const firstReadyProvider = PUBLISH_PROVIDER_OPTIONS.find( - (option) => publishProviderReadiness[option.value].ready, - ); - if (firstReadyProvider) { - setPublishProvider(firstReadyProvider.value); - } - }, [props.open, publishProvider, publishProviderReadiness]); - - const submitPublishRepository = useCallback(() => { - if (!canSubmitPublishRepository) { - return; - } - - setPublishError(null); - - void publishRepositoryMutation - .mutateAsync({ - provider: publishProvider, - repository: publishRepository.trim(), - visibility: publishVisibility, - remoteName: publishRemoteName.trim() || "origin", - protocol: publishProtocol, - }) - .then((result) => { - flushSync(() => { - setPublishResult(result); - setPublishWizardStep(2); - }); - void refreshGitStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( - () => undefined, - ); - }) - .catch((err: unknown) => { - setPublishError(err instanceof Error ? err.message : "An error occurred."); - }); - }, [ - canSubmitPublishRepository, - props.environmentId, - props.gitCwd, - publishProtocol, - publishProvider, - publishRemoteName, - publishRepository, - publishRepositoryMutation, - publishVisibility, - ]); - - const resetState = useCallback(() => { - setPublishRemoteName("origin"); - setPublishRepository(""); - setHasUserEditedPublishRepository(false); - setPublishWizardStep(0); - setPublishAdvancedOpen(false); - setPublishError(null); - setPublishResult(null); - }, []); - - const handleOpenChange = useCallback( - (open: boolean) => { - props.onOpenChange(open); - if (!open) { - resetState(); - } - }, - [props, resetState], - ); - - const openSourceControlSettings = useCallback(() => { - handleOpenChange(false); - void navigate({ to: "/settings/source-control" }); - }, [handleOpenChange, navigate]); - - return ( - - -
- - Publish repository - - Pick where to host it, then point us at a repo to push to. - -
- {publishWizardSteps.map((label, index) => { - const isComplete = index < publishWizardStep; - const isClickable = - publishWizardStep !== 2 && - index < publishWizardSteps.length - 1 && - index <= publishWizardStep; - return ( - - ); - })} -
-
- - - -
- - Provider - - setPublishProvider(value as PublishProviderKind)} - aria-labelledby="publish-provider-cards-label" - className="grid grid-cols-2 gap-2.5" - > - {sortedPublishProviderOptions.map((option) => { - const readiness = publishProviderReadiness[option.value]; - const isSelected = publishProvider === option.value && readiness.ready; - if (!readiness.ready) { - return ( -
- - - {option.label} - - - { - event.preventDefault(); - event.stopPropagation(); - openSourceControlSettings(); - }} - > - Setup Required - - } - /> - - {readiness.hint ?? - "Open Settings -> Source Control to configure this provider."} - - -
- ); - } - - return ( - - - - {option.label} - - - ); - })} -
-
- -
-
- -
- - - {publishHost}/ - - { - setPublishRepository(event.target.value); - setHasUserEditedPublishRepository(true); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - submitPublishRepository(); - } - }} - placeholder={publishPathPlaceholder} - disabled={publishRepositoryMutation.isPending} - className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" - /> -
-
- -
- - Visibility - - - setPublishVisibility(value as SourceControlRepositoryVisibility) - } - aria-labelledby="publish-visibility-cards-label" - disabled={publishRepositoryMutation.isPending} - className="grid grid-cols-2 gap-2.5" - > - {[ - { - value: "private" as const, - label: "Private", - description: "Only invited people", - Icon: LockIcon, - }, - { - value: "public" as const, - label: "Public", - description: "Anyone on the web", - Icon: GlobeIcon, - }, - ].map((option) => { - const isSelected = publishVisibility === option.value; - return ( - - - - - {option.label} - - - {option.description} - - - - ); - })} - -
- -
- - {publishAdvancedOpen ? ( -
- -
- - Protocol - - - setPublishProtocol(value as SourceControlCloneProtocol) - } - aria-labelledby="publish-protocol-label" - disabled={publishRepositoryMutation.isPending} - className="grid grid-cols-2 gap-2" - > - {(["ssh", "https"] as const).map((value) => { - const isSelected = publishProtocol === value; - return ( - - {value === "ssh" ? "SSH" : "HTTPS"} - - ); - })} - -
-
- ) : null} -
- - {publishRepositoryMutation.isPending ? ( -
- - Publishing repository to {publishProviderLabel}... -
- ) : null} - {publishError && !publishRepositoryMutation.isPending ? ( -
-

Publish failed

-

{publishError}

-
- ) : null} -
- -
- {publishResult ? ( - <> -
- - - -

- {publishResult.status === "pushed" - ? "Repository published" - : "Repository created"} -

-

- {publishResult.status === "pushed" - ? `${publishResult.branch} is now live on ${publishProviderLabel}.` - : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`} -

-
-
- - - {publishResult.repository.nameWithOwner} - -
- - - ) : ( -
- Publish result unavailable. -
- )} -
-
-
- - - {publishWizardStep === 2 ? ( - - ) : ( - <> - - {publishWizardStep < 1 ? ( - - ) : ( - - )} - - )} - -
-
-
- ); -} - -export default function GitActionsControl({ - gitCwd, - activeThreadRef, - draftId, -}: GitActionsControlProps) { - const activeEnvironmentId = activeThreadRef?.environmentId ?? null; - const threadToastData = useMemo( - () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), - [activeThreadRef], - ); - const activeServerThreadSelector = useMemo( - () => createThreadSelectorByRef(activeThreadRef), - [activeThreadRef], - ); - const activeServerThread = useStore(activeServerThreadSelector); - const activeDraftThread = useComposerDraftStore((store) => - draftId - ? store.getDraftSession(draftId) - : activeThreadRef - ? store.getDraftThreadByRef(activeThreadRef) - : null, - ); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const setThreadBranch = useStore((store) => store.setThreadBranch); - const queryClient = useQueryClient(); - const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); - const [dialogCommitMessage, setDialogCommitMessage] = useState(""); - const [excludedFiles, setExcludedFiles] = useState>(new Set()); - const [isEditingFiles, setIsEditingFiles] = useState(false); - const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); - const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = - useState(null); - const activeGitActionProgressRef = useRef(null); - let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise; - - const updateActiveProgressToast = useCallback(() => { - const progress = activeGitActionProgressRef.current; - if (!progress) { - return; - } - toastManager.update(progress.toastId, { - type: "loading", - title: progress.title, - description: resolveProgressDescription(progress), - timeout: 0, - data: progress.toastData, - }); - }, []); - - const persistThreadBranchSync = useCallback( - (branch: string | null) => { - if (!activeThreadRef) { - return; - } - - if (activeServerThread) { - if (activeServerThread.branch === branch) { - return; - } - - const worktreePath = activeServerThread.worktreePath; - const api = readEnvironmentApi(activeThreadRef.environmentId); - if (api) { - void api.orchestration - .dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadRef.threadId, - branch, - worktreePath, - }) - .catch(() => undefined); - } - - setThreadBranch(activeThreadRef, branch, worktreePath); - return; - } - - if (!activeDraftThread || activeDraftThread.branch === branch) { - return; - } - - setDraftThreadContext(draftId ?? activeThreadRef, { - branch, - worktreePath: activeDraftThread.worktreePath, - }); - }, - [ - activeDraftThread, - activeServerThread, - activeThreadRef, - draftId, - setDraftThreadContext, - setThreadBranch, - ], - ); - - const syncThreadBranchAfterGitAction = useCallback( - (result: GitRunStackedActionResult) => { - const branchUpdate = resolveThreadBranchUpdate(result); - if (!branchUpdate) { - return; - } - - persistThreadBranchSync(branchUpdate.branch); - }, - [persistThreadBranchSync], - ); - - const { data: gitStatus = null, error: gitStatusError } = useGitStatus({ - environmentId: activeEnvironmentId, - cwd: gitCwd, - }); - const sourceControlPresentation = useMemo( - () => getSourceControlPresentation(gitStatus?.sourceControlProvider), - [gitStatus?.sourceControlProvider], - ); - const changeRequestTerminology = sourceControlPresentation.terminology; - const SourceControlIcon = sourceControlPresentation.Icon; - // Default to true while loading so we don't flash init controls. - const isRepo = gitStatus?.isRepo ?? true; - const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; - const gitStatusForActions = gitStatus; - - const allFiles = gitStatusForActions?.workingTree.files ?? []; - const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); - const allSelected = excludedFiles.size === 0; - const noneSelected = selectedFiles.length === 0; - - const initMutation = useMutation( - gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), - ); - - const runImmediateGitActionMutation = useMutation( - gitRunStackedActionMutationOptions({ - environmentId: activeEnvironmentId, - cwd: gitCwd, - queryClient, - }), - ); - const pullMutation = useMutation( - gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), - ); - - const isRunStackedActionRunning = - useIsMutating({ - mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd), - }) > 0; - const isPullRunning = - useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; - const isPublishRunning = - useIsMutating({ - mutationKey: gitMutationKeys.publishRepository(activeEnvironmentId, gitCwd), - }) > 0; - const isGitActionRunning = isRunStackedActionRunning || isPullRunning || isPublishRunning; - const isSelectingWorktreeBase = - !activeServerThread && - activeDraftThread?.envMode === "worktree" && - activeDraftThread.worktreePath === null; - - useEffect(() => { - if (isGitActionRunning || isSelectingWorktreeBase) { - return; - } - - const branchUpdate = resolveLiveThreadBranchUpdate({ - threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, - gitStatus: gitStatusForActions, - }); - if (!branchUpdate) { - return; - } - - persistThreadBranchSync(branchUpdate.branch); - }, [ - activeServerThread?.branch, - activeDraftThread?.branch, - gitStatusForActions, - isGitActionRunning, - isSelectingWorktreeBase, - persistThreadBranchSync, - ]); - - const isDefaultRef = useMemo(() => { - return gitStatusForActions?.isDefaultRef ?? false; - }, [gitStatusForActions?.isDefaultRef]); - - const gitActionMenuItems = useMemo( - () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote), - [gitStatusForActions, hasPrimaryRemote, isGitActionRunning], - ); - const quickAction = useMemo( - () => - resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), - [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], - ); - const quickActionDisabledReason = quickAction.disabled - ? (quickAction.hint ?? "This action is currently unavailable.") - : null; - const pendingDefaultBranchActionCopy = pendingDefaultBranchAction - ? resolveDefaultBranchActionDialogCopy({ - action: pendingDefaultBranchAction.action, - branchName: pendingDefaultBranchAction.branchName, - includesCommit: pendingDefaultBranchAction.includesCommit, - terminology: changeRequestTerminology, - }) - : null; - - useEffect(() => { - const interval = window.setInterval(() => { - if (!activeGitActionProgressRef.current) { - return; - } - updateActiveProgressToast(); - }, 1000); - - return () => { - window.clearInterval(interval); - }; - }, [updateActiveProgressToast]); - - useEffect(() => { - if (gitCwd === null) { - return; - } - - let refreshTimeout: number | null = null; - const scheduleRefreshCurrentGitStatus = () => { - if (refreshTimeout !== null) { - window.clearTimeout(refreshTimeout); - } - refreshTimeout = window.setTimeout(() => { - refreshTimeout = null; - void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( - () => undefined, - ); - }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); - }; - const handleVisibilityChange = () => { - if (document.visibilityState === "visible") { - scheduleRefreshCurrentGitStatus(); - } - }; - - window.addEventListener("focus", scheduleRefreshCurrentGitStatus); - document.addEventListener("visibilitychange", handleVisibilityChange); - - return () => { - if (refreshTimeout !== null) { - window.clearTimeout(refreshTimeout); - } - window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); - document.removeEventListener("visibilitychange", handleVisibilityChange); - }; - }, [activeEnvironmentId, gitCwd]); - - const openExistingPr = useCallback(async () => { - const api = readLocalApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Link opening is unavailable.", - data: threadToastData, - }); - return; - } - const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; - if (!prUrl) { - toastManager.add({ - type: "error", - title: "No open pull request found.", - data: threadToastData, - }); - return; - } - void api.shell.openExternal(prUrl).catch((err: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open pull request link", - description: err instanceof Error ? err.message : "An error occurred.", - ...(threadToastData !== undefined ? { data: threadToastData } : {}), - }), - ); - }); - }, [gitStatusForActions, threadToastData]); - - runGitActionWithToast = useEffectEvent( - async ({ - action, - commitMessage, - onConfirmed, - skipDefaultBranchPrompt = false, - statusOverride, - featureBranch = false, - progressToastId, - filePaths, - }: RunGitActionWithToastInput) => { - const actionStatus = statusOverride ?? gitStatusForActions; - const actionBranch = actionStatus?.refName ?? null; - const actionIsDefaultBranch = featureBranch ? false : isDefaultRef; - const actionCanCommit = - action === "commit" || action === "commit_push" || action === "commit_push_pr"; - const includesCommit = - actionCanCommit && - (action === "commit" || !!actionStatus?.hasWorkingTreeChanges || featureBranch); - if ( - !skipDefaultBranchPrompt && - requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && - actionBranch - ) { - if ( - action !== "push" && - action !== "create_pr" && - action !== "commit_push" && - action !== "commit_push_pr" - ) { - return; - } - setPendingDefaultBranchAction({ - action, - branchName: actionBranch, - includesCommit, - ...(commitMessage ? { commitMessage } : {}), - ...(onConfirmed ? { onConfirmed } : {}), - ...(filePaths ? { filePaths } : {}), - }); - return; - } - onConfirmed?.(); - - const progressStages = buildGitActionProgressStages({ - action, - hasCustomCommitMessage: !!commitMessage?.trim(), - hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, - featureBranch, - terminology: changeRequestTerminology, - shouldPushBeforePr: - action === "create_pr" && - (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), - }); - const scopedToastData = threadToastData ? { ...threadToastData } : undefined; - const actionId = randomUUID(); - const resolvedProgressToastId = - progressToastId ?? - toastManager.add({ - type: "loading", - title: progressStages[0] ?? "Running git action...", - description: "Waiting for Git...", - timeout: 0, - data: scopedToastData, - }); - - activeGitActionProgressRef.current = { - toastId: resolvedProgressToastId, - toastData: scopedToastData, - actionId, - title: progressStages[0] ?? "Running git action...", - phaseStartedAtMs: null, - hookStartedAtMs: null, - hookName: null, - lastOutputLine: null, - currentPhaseLabel: progressStages[0] ?? "Running git action...", - }; - - if (progressToastId) { - toastManager.update(progressToastId, { - type: "loading", - title: progressStages[0] ?? "Running git action...", - description: "Waiting for Git...", - timeout: 0, - data: scopedToastData, - }); - } - - const applyProgressEvent = (event: GitActionProgressEvent) => { - const progress = activeGitActionProgressRef.current; - if (!progress) { - return; - } - if (gitCwd && event.cwd !== gitCwd) { - return; - } - if (progress.actionId !== event.actionId) { - return; - } - - const now = Date.now(); - switch (event.kind) { - case "action_started": - progress.phaseStartedAtMs = now; - progress.hookStartedAtMs = null; - progress.hookName = null; - progress.lastOutputLine = null; - break; - case "phase_started": - progress.title = event.label; - progress.currentPhaseLabel = event.label; - progress.phaseStartedAtMs = now; - progress.hookStartedAtMs = null; - progress.hookName = null; - progress.lastOutputLine = null; - break; - case "hook_started": - progress.title = `Running ${event.hookName}...`; - progress.hookName = event.hookName; - progress.hookStartedAtMs = now; - progress.lastOutputLine = null; - break; - case "hook_output": - progress.lastOutputLine = event.text; - break; - case "hook_finished": - progress.title = progress.currentPhaseLabel ?? "Committing..."; - progress.hookName = null; - progress.hookStartedAtMs = null; - progress.lastOutputLine = null; - break; - case "action_finished": - // Let the resolved mutation update the toast so we keep the - // elapsed description visible until the final success state renders. - return; - case "action_failed": - // Let the rejected mutation publish the error toast to avoid a - // transient intermediate state before the final failure message. - return; - } - - updateActiveProgressToast(); - }; - - const promise = runImmediateGitActionMutation.mutateAsync({ - actionId, - action, - ...(commitMessage ? { commitMessage } : {}), - ...(featureBranch ? { featureBranch } : {}), - ...(filePaths ? { filePaths } : {}), - onProgress: applyProgressEvent, - }); - - try { - const result = await promise; - activeGitActionProgressRef.current = null; - syncThreadBranchAfterGitAction(result); - const closeResultToast = () => { - toastManager.close(resolvedProgressToastId); - }; - - const toastCta = result.toast.cta; - let toastActionProps: { - children: string; - onClick: () => void; - } | null = null; - if (toastCta.kind === "run_action") { - toastActionProps = { - children: toastCta.label, - onClick: () => { - closeResultToast(); - void runGitActionWithToast({ - action: toastCta.action.kind, - }); - }, - }; - } else if (toastCta.kind === "open_pr") { - toastActionProps = { - children: toastCta.label, - onClick: () => { - const api = readLocalApi(); - if (!api) return; - closeResultToast(); - void api.shell.openExternal(toastCta.url); - }, - }; - } - - const successToastData = { - ...scopedToastData, - dismissAfterVisibleMs: 10_000, - }; - - if (toastActionProps) { - toastManager.update( - resolvedProgressToastId, - stackedThreadToast({ - type: "success", - title: result.toast.title, - description: result.toast.description, - timeout: 0, - actionProps: toastActionProps, - data: successToastData, - }), - ); - } else { - toastManager.update(resolvedProgressToastId, { - type: "success", - title: result.toast.title, - description: result.toast.description, - timeout: 0, - data: successToastData, - }); - } - } catch (err) { - activeGitActionProgressRef.current = null; - toastManager.update( - resolvedProgressToastId, - stackedThreadToast({ - type: "error", - title: "Action failed", - description: err instanceof Error ? err.message : "An error occurred.", - ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), - }), - ); - } - }, - ); - - const continuePendingDefaultBranchAction = () => { - if (!pendingDefaultBranchAction) return; - const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; - setPendingDefaultBranchAction(null); - void runGitActionWithToast({ - action, - ...(commitMessage ? { commitMessage } : {}), - ...(onConfirmed ? { onConfirmed } : {}), - ...(filePaths ? { filePaths } : {}), - skipDefaultBranchPrompt: true, - }); - }; - - const checkoutFeatureBranchAndContinuePendingAction = () => { - if (!pendingDefaultBranchAction) return; - const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; - setPendingDefaultBranchAction(null); - void runGitActionWithToast({ - action, - ...(commitMessage ? { commitMessage } : {}), - ...(onConfirmed ? { onConfirmed } : {}), - ...(filePaths ? { filePaths } : {}), - featureBranch: true, - skipDefaultBranchPrompt: true, - }); - }; - - const runDialogActionOnNewBranch = () => { - if (!isCommitDialogOpen) return; - const commitMessage = dialogCommitMessage.trim(); - - setIsCommitDialogOpen(false); - setDialogCommitMessage(""); - setExcludedFiles(new Set()); - setIsEditingFiles(false); - - void runGitActionWithToast({ - action: "commit", - ...(commitMessage ? { commitMessage } : {}), - ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), - featureBranch: true, - skipDefaultBranchPrompt: true, - }); - }; - - const runQuickAction = () => { - if (quickAction.kind === "open_pr") { - void openExistingPr(); - return; - } - if (quickAction.kind === "open_publish") { - setIsPublishDialogOpen(true); - return; - } - if (quickAction.kind === "run_pull") { - const promise = pullMutation.mutateAsync(); - void toastManager.promise< - Awaited>, - ThreadToastData - >(promise, { - loading: { title: "Pulling...", data: threadToastData }, - success: (result) => ({ - title: result.status === "pulled" ? "Pulled" : "Already up to date", - description: - result.status === "pulled" - ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}` - : `${result.refName} is already synchronized.`, - data: threadToastData, - }), - error: (err) => ({ - title: "Pull failed", - description: err instanceof Error ? err.message : "An error occurred.", - data: threadToastData, - }), - }); - void promise.catch(() => undefined); - return; - } - if (quickAction.kind === "show_hint") { - toastManager.add({ - type: "info", - title: quickAction.label, - description: quickAction.hint, - data: threadToastData, - }); - return; - } - if (quickAction.action) { - void runGitActionWithToast({ action: quickAction.action }); - } - }; - - const openDialogForMenuItem = (item: GitActionMenuItem) => { - if (item.disabled) return; - if (item.kind === "open_pr") { - void openExistingPr(); - return; - } - if (item.dialogAction === "push") { - void runGitActionWithToast({ action: "push" }); - return; - } - if (item.dialogAction === "create_pr") { - void runGitActionWithToast({ action: "create_pr" }); - return; - } - setExcludedFiles(new Set()); - setIsEditingFiles(false); - setIsCommitDialogOpen(true); - }; - - const runDialogAction = () => { - if (!isCommitDialogOpen) return; - const commitMessage = dialogCommitMessage.trim(); - setIsCommitDialogOpen(false); - setDialogCommitMessage(""); - setExcludedFiles(new Set()); - setIsEditingFiles(false); - void runGitActionWithToast({ - action: "commit", - ...(commitMessage ? { commitMessage } : {}), - ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), - }); - }; - - const openChangedFileInEditor = useCallback( - (filePath: string) => { - const api = readLocalApi(); - if (!api || !gitCwd) { - toastManager.add({ - type: "error", - title: "Editor opening is unavailable.", - data: threadToastData, - }); - return; - } - const target = resolvePathLinkTarget(filePath, gitCwd); - void openInPreferredEditor(api, target).catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - ...(threadToastData !== undefined ? { data: threadToastData } : {}), - }), - ); - }); - }, - [gitCwd, threadToastData], - ); - - const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; - - if (!gitCwd) return null; - - return ( - <> - {!isRepo ? ( - - ) : ( - - {quickActionDisabledReason ? ( - - - } - > - - - {quickAction.label} - - - - {quickActionDisabledReason} - - - ) : ( - - )} - - { - if (open) { - void refreshGitStatus({ - environmentId: activeEnvironmentId, - cwd: gitCwd, - }).catch(() => undefined); - } - }} - > - } - disabled={isGitActionRunning} - > - - - {gitActionMenuItems.map((item) => { - const disabledReason = getMenuActionDisabledReason({ - item, - gitStatus: gitStatusForActions, - isBusy: isGitActionRunning, - hasPrimaryRemote, - }); - if (item.disabled && disabledReason) { - return ( - - } - > - - - {item.label} - - - - {disabledReason} - - - ); - } - - return ( - { - openDialogForMenuItem(item); - }} - > - - {item.label} - - ); - })} - {canPublishRepository ? ( - { - setIsPublishDialogOpen(true); - }} - > - - Publish repository... - - ) : null} - {gitStatusForActions?.refName === null && ( -

- Detached HEAD: create and checkout a refName to enable push and pull request - actions. -

- )} - {gitStatusForActions && - gitStatusForActions.refName !== null && - !gitStatusForActions.hasWorkingTreeChanges && - gitStatusForActions.behindCount > 0 && - gitStatusForActions.aheadCount === 0 && ( -

- Behind upstream. Pull/rebase first. -

- )} - {gitStatusError && ( -

{gitStatusError.message}

- )} -
-
-
- )} - - { - if (!open) { - setIsCommitDialogOpen(false); - setDialogCommitMessage(""); - setExcludedFiles(new Set()); - setIsEditingFiles(false); - } - }} - > - - - {COMMIT_DIALOG_TITLE} - {COMMIT_DIALOG_DESCRIPTION} - - -
-
- Branch - - - {gitStatusForActions?.refName ?? "(detached HEAD)"} - - {isDefaultRef && ( - - Warning: default refName - - )} - -
-
-
-
- {isEditingFiles && allFiles.length > 0 && ( - { - setExcludedFiles( - allSelected ? new Set(allFiles.map((f) => f.path)) : new Set(), - ); - }} - /> - )} - Files - {!allSelected && !isEditingFiles && ( - - ({selectedFiles.length} of {allFiles.length}) - - )} -
- {allFiles.length > 0 && ( - - )} -
- {!gitStatusForActions || allFiles.length === 0 ? ( -

none

- ) : ( -
- -
- {allFiles.map((file) => { - const isExcluded = excludedFiles.has(file.path); - return ( -
- {isEditingFiles && ( - { - setExcludedFiles((prev) => { - const next = new Set(prev); - if (next.has(file.path)) { - next.delete(file.path); - } else { - next.add(file.path); - } - return next; - }); - }} - /> - )} - -
- ); - })} -
-
-
- - +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} - - / - - -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)} - -
-
- )} -
-
-
-

Commit message (optional)

-