History

chore: effect rpc and layer based startup (#929)

Julius Marminge committed 7 months ago

Commit d8aa2f81 parent 64d6938116 files · +6676 −6648Browse files

Unified · Split

116 files
  1. .oxfmtrc.json +1 −0
  2. .plans/effect-atom.md +89 −0
  3. apps/desktop/scripts/dev-electron.mjs +2 −2
  4. apps/desktop/src/main.ts +1 −1
  5. apps/server/integration/OrchestrationEngineHarness.integration.ts +4 −1
  6. apps/server/integration/orchestrationEngine.integration.test.ts +1 −1
  7. apps/server/package.json +5 −6
  8. apps/server/src/bin.ts +17 −0
  9. apps/server/src/checkpointing/Errors.ts +1 −1
  10. apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +1 −1
  11. apps/server/src/checkpointing/Layers/CheckpointStore.ts +1 −1
  12. apps/server/src/cli-config.test.ts +318 −0
  13. apps/server/src/cli.test.ts +37 −0
  14. apps/server/src/cli.ts +292 −0
  15. apps/server/src/config.ts +26 −6
  16. apps/server/src/git/Errors.ts +0 −67
  17. apps/server/src/git/Layers/ClaudeTextGeneration.ts +1 −1
  18. apps/server/src/git/Layers/CodexTextGeneration.test.ts +1 −1
  19. apps/server/src/git/Layers/CodexTextGeneration.ts +1 −1
  20. apps/server/src/git/Layers/GitCore.test.ts +1 −1
  21. apps/server/src/git/Layers/GitCore.ts +4 −3
  22. apps/server/src/git/Layers/GitHubCli.ts +1 −1
  23. apps/server/src/git/Layers/GitManager.test.ts +1 −1
  24. apps/server/src/git/Layers/GitManager.ts +2 −2
  25. apps/server/src/git/Prompts.test.ts +1 −1
  26. apps/server/src/git/Services/GitCore.ts +1 −1
  27. apps/server/src/git/Services/GitHubCli.ts +1 −1
  28. apps/server/src/git/Services/GitManager.ts +1 −1
  29. apps/server/src/git/Services/TextGeneration.ts +1 −1
  30. apps/server/src/git/Utils.ts +1 −1
  31. apps/server/src/http.ts +197 −0
  32. apps/server/src/index.ts +0 −23
  33. apps/server/src/keybindings.test.ts +1 −1
  34. apps/server/src/keybindings.ts +1 −13
  35. apps/server/src/logger.ts +0 −103
  36. apps/server/src/main.test.ts +0 −413
  37. apps/server/src/main.ts +0 −456
  38. apps/server/src/open.ts +3 −6
  39. apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +7 −16
  40. apps/server/src/orchestration/Layers/OrchestrationEngine.ts +1 −1
  41. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +1 −3
  42. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +1 −1
  43. apps/server/src/orchestration/Normalizer.ts +121 −0
  44. apps/server/src/projectFaviconRoute.test.ts +0 −189
  45. apps/server/src/projectFaviconRoute.ts +0 −76
  46. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +3 −0
  47. apps/server/src/provider/Layers/ClaudeAdapter.ts +1 −1
  48. apps/server/src/provider/Layers/ClaudeProvider.ts +2 −1
  49. apps/server/src/provider/Layers/CodexProvider.ts +2 −1
  50. apps/server/src/provider/makeManagedServerProvider.ts +1 −1
  51. apps/server/src/server.test.ts +1391 −0
  52. apps/server/src/server.ts +234 −0
  53. apps/server/src/serverLayers.ts +0 −161
  54. apps/server/src/serverLifecycleEvents.test.ts +42 −0
  55. apps/server/src/serverLifecycleEvents.ts +53 −0
  56. apps/server/src/serverLogger.ts +7 −11
  57. apps/server/src/serverRuntimeStartup.test.ts +49 −0
  58. apps/server/src/serverRuntimeStartup.ts +334 −0
  59. apps/server/src/serverSettings.ts +3 −13
  60. apps/server/src/terminal/Services/Manager.ts +39 −65
  61. apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +3 −1
  62. apps/server/src/workspace/Layers/WorkspaceEntries.ts +27 −2
  63. apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +3 −2
  64. apps/server/src/ws.ts +343 −0
  65. apps/server/src/wsServer.test.ts +0 −2069
  66. apps/server/src/wsServer.ts +0 −998
  67. apps/server/src/wsServer/pushBus.test.ts +0 −101
  68. apps/server/src/wsServer/pushBus.ts +0 −114
  69. apps/server/src/wsServer/readiness.ts +0 −36
  70. apps/server/tsdown.config.ts +1 −1
  71. apps/server/vitest.config.ts +7 −2
  72. apps/web/package.json +1 −0
  73. apps/web/src/components/ChatView.browser.tsx +65 −64
  74. apps/web/src/components/ChatView.tsx +10 −11
  75. apps/web/src/components/GitActionsControl.tsx +57 −68
  76. apps/web/src/components/KeybindingsToast.browser.tsx +41 −44
  77. apps/web/src/components/ProjectFavicon.tsx +6 −21
  78. apps/web/src/components/Sidebar.tsx +3 −9
  79. apps/web/src/components/settings/SettingsPanels.tsx +10 −11
  80. apps/web/src/hooks/useSettings.ts +10 −18
  81. apps/web/src/lib/gitReactQuery.ts +15 −10
  82. apps/web/src/lib/serverReactQuery.ts +0 −24
  83. apps/web/src/lib/utils.ts +39 −0
  84. apps/web/src/nativeApi.ts +6 −1
  85. apps/web/src/router.ts +7 −1
  86. apps/web/src/routes/__root.tsx +123 −102
  87. apps/web/src/routes/_chat.tsx +2 −7
  88. apps/web/src/rpc/atomRegistry.tsx +14 −0
  89. apps/web/src/rpc/client.test.ts +231 −0
  90. apps/web/src/rpc/client.ts +33 −0
  91. apps/web/src/rpc/protocol.ts +27 −0
  92. apps/web/src/rpc/serverState.test.ts +311 −0
  93. apps/web/src/rpc/serverState.ts +280 −0
  94. apps/web/src/rpc/serverStateBootstrap.tsx +10 −0
  95. apps/web/src/wsNativeApi.test.ts +178 −280
  96. apps/web/src/wsNativeApi.ts +46 −175
  97. apps/web/src/wsRpcClient.ts +207 −0
  98. apps/web/src/wsTransport.test.ts +297 −110
  99. apps/web/src/wsTransport.ts +93 −271
  100. apps/web/test/wsRpcHarness.ts +169 −0
  101. apps/web/tsconfig.json +1 −1
  102. bun.lock +9 −3
  103. package.json +2 −0
  104. packages/contracts/src/editor.ts +5 −0
  105. packages/contracts/src/git.ts +54 −0
  106. packages/contracts/src/index.ts +1 −1
  107. packages/contracts/src/ipc.ts +0 −5
  108. packages/contracts/src/keybindings.ts +22 −6
  109. packages/contracts/src/orchestration.ts +40 −4
  110. packages/contracts/src/project.ts +16 −0
  111. packages/contracts/src/rpc.ts +359 −0
  112. packages/contracts/src/server.ts +100 −2
  113. packages/contracts/src/settings.ts +13 −0
  114. packages/contracts/src/terminal.ts +71 −0
  115. packages/contracts/src/ws.test.ts +0 −157
  116. packages/contracts/src/ws.ts +0 −257
.oxfmtrc.json+1−0
@@ -1,6 +1,7 @@
11 {
22 "$schema": "./node_modules/oxfmt/configuration_schema.json",
33 "ignorePatterns": [
4+ ".reference",
45 ".plans",
56 "dist",
67 "dist-electron",
.plans/effect-atom.md+89−0
@@ -0,0 +1,89 @@
1+# Replace React Query With AtomRpc + Atom State
2+
3+## Summary
4+- 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).
5+- Keep Zustand for orchestration read model and UI state.
6+- Keep a narrow `desktopBridge` adapter for dialogs, menus, external links, theme, and updater APIs.
7+- 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.
8+
9+## Target Architecture
10+- 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`.
11+- Define one `AtomRpc.Service` for `WsRpcGroup` in `rpc/client.ts`.
12+- Add `rpc/invalidation.ts` with explicit scoped invalidation keys: `git:${cwd}`, `project:${cwd}`, `checkpoint:${threadId}`, `server-config`.
13+- Add `platform/desktopBridge.ts` as the only browser/desktop facade.
14+- 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.
15+
16+## Phase 1: Infrastructure First
17+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.
18+2. Build the AtomRpc client on top of that layer.
19+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.
20+4. Replace manual registry wiring with one app-level registry provider based on `@effect/atom-react`.
21+5. Land this as a no-behavior-change PR.
22+
23+## Phase 2: Replace `wsNativeApi`-Owned Push State
24+1. Migrate welcome/config/provider/settings state first, because it is already atom-shaped and is the lowest-risk way to delete `wsNativeApi` responsibilities.
25+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`.
26+3. Keep the current hook names for one PR: `useServerConfig`, `useServerSettings`, `useServerProviders`, `useServerKeybindings`, `useServerWelcomeSubscription`, `useServerConfigUpdatedSubscription`.
27+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).
28+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.
29+
30+## Phase 3: Replace React Query Domain By Domain
31+1. Replace [gitReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/gitReactQuery.ts) first.
32+2. Add `rpc/gitAtoms.ts` and `rpc/useGit.ts` with `useGitStatus`, `useGitBranches`, `useResolvePullRequest`, and `useGitMutation`.
33+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}`.
34+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.
35+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}`.
36+6. Defer the desktop updater until the last phase.
37+
38+## Phase 4: Move Root Invalidation Off `queryClient`
39+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.
40+2. Keep Zustand orchestration/event application unchanged.
41+3. Map current effects exactly:
42+- git or checkpoint-affecting orchestration events touch `checkpoint:${threadId}`
43+- file creation/deletion/restoration touches `project:${cwd}`
44+- config-affecting server events touch `server-config`
45+
46+## Phase 5: Remove Imperative `NativeApi` Usage
47+1. Create narrow modules instead of a replacement mega-facade:
48+- `rpc/orchestrationActions.ts`
49+- `rpc/terminalActions.ts`
50+- `rpc/gitActions.ts`
51+- `rpc/projectActions.ts`
52+- `platform/desktopBridge.ts`
53+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.
54+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.
55+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.
56+
57+## Phase 6: Remove React Query Completely
58+1. Delete `@tanstack/react-query` from `apps/web/package.json`.
59+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).
60+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`.
61+4. Delete the old query-option tests.
62+
63+## Public Interfaces And Types
64+- Preserve the current server-state hook names during the transition.
65+- Add permanent domain hooks: `useGitStatus`, `useGitBranches`, `useResolvePullRequest`, `useProjectSearchEntries`, `useCheckpointDiff`, `useDesktopUpdateState`.
66+- Do not expose raw AtomRpc clients to components.
67+- Do not add Suspense as part of this migration.
68+- Final boundary is direct RPC for server features plus `desktopBridge` for local desktop features.
69+
70+## Test Plan
71+- Add unit tests for `rpc/serverState.ts`: snapshot bootstrapping, stream replay, provider/settings updates.
72+- Add unit tests for git/project/checkpoint hooks: loading, error mapping, retry behavior, invalidation, keep-previous-result behavior.
73+- 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`.
74+- 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.
75+- Acceptance scenarios:
76+- welcome still bootstraps snapshot and navigation
77+- keybindings toast still responds to config stream updates
78+- git status/branches refresh after checkout/pull/worktree actions
79+- PR resolve dialog keeps cached result while typing
80+- `@` path search refreshes after file mutations and orchestration events
81+- diff panel refreshes when checkpoints arrive
82+- desktop updater still reflects push events and button actions
83+
84+## Assumptions And Defaults
85+- Zustand stays in scope; only `react-query` is being removed.
86+- `desktopBridge` remains the only non-RPC boundary.
87+- The migration lands as 5-6 small PRs, each green independently.
88+- Invalidations are explicit and scoped; do not recreate a global cache client abstraction.
89+- Orchestration recovery/order logic stays as-is; only the data-fetching and mutation layer changes.
apps/desktop/scripts/dev-electron.mjs+2−2
@@ -10,11 +10,11 @@ const devServerUrl = `http://localhost:${port}`; · 9 unmodified lines
1010 const requiredFiles = [
1111 "dist-electron/main.js",
1212 "dist-electron/preload.js",
13− "../server/dist/index.mjs",
13+ "../server/dist/bin.mjs",
1414 ];
1515 const watchedDirectories = [
1616 { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) },
17− { directory: "../server/dist", files: new Set(["index.mjs"]) },
17+ { directory: "../server/dist", files: new Set(["bin.mjs"]) },
1818 ];
1919 const forcedShutdownTimeoutMs = 1_500;
2020 const restartDebounceMs = 120;
apps/desktop/src/main.ts+1−1
@@ -399,7 +399,7 @@ function resolveAboutCommitHash(): string | null { · 398 unmodified lines
399399 }
400400
401401 function resolveBackendEntry(): string {
402− return Path.join(resolveAppRoot(), "apps/server/dist/index.mjs");
402+ return Path.join(resolveAppRoot(), "apps/server/dist/bin.mjs");
403403 }
404404
405405 function resolveBackendCwd(): string {
apps/server/integration/OrchestrationEngineHarness.integration.ts+4−1
@@ -69,6 +69,7 @@ import { · 68 unmodified lines
6969 } from "./TestProviderAdapter.integration.ts";
7070 import { deriveServerPaths, ServerConfig } from "../src/config.ts";
7171 import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts";
72+import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts";
7273
7374 function runGit(cwd: string, args: ReadonlyArray<string>) {
7475 return execFileSync("git", args, {
@@ -125,7 +126,7 @@ function waitFor<A, E>( · 50 unmodified lines
125126 read: Effect.Effect<A, E>,
126127 predicate: (value: A) => boolean,
127128 description: string,
128− timeoutMs = 10_000,
129+ timeoutMs = 40_000,
129130 ): Effect.Effect<A, never> {
130131 const RETRY_SIGNAL = "wait_for_retry";
131132 const retryIntervalMs = 10;
@@ -320,10 +321,12 @@ export const makeOrchestrationIntegrationHarness = ( · 188 unmodified lines
320321 Layer.provideMerge(runtimeServicesLayer),
321322 Layer.provideMerge(
322323 WorkspaceEntriesLive.pipe(
324+ Layer.provide(WorkspacePathsLive),
323325 Layer.provideMerge(gitCoreLayer),
324326 Layer.provide(NodeServices.layer),
325327 ),
326328 ),
329+ Layer.provideMerge(WorkspacePathsLive),
327330 );
328331 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
329332 Layer.provideMerge(runtimeIngestionLayer),
apps/server/integration/orchestrationEngine.integration.test.ts+1−1
@@ -57,7 +57,7 @@ function waitForSync<A>( · 56 unmodified lines
5757 read: () => A,
5858 predicate: (value: A) => boolean,
5959 description: string,
60− timeoutMs = 3000,
60+ timeoutMs = 10_000,
6161 ): Effect.Effect<A, never> {
6262 return Effect.gen(function* () {
6363 const deadline = Date.now() + timeoutMs;
apps/server/package.json+5−6
@@ -8,29 +8,29 @@ · 7 unmodified lines
88 "directory": "apps/server"
99 },
1010 "bin": {
11− "t3": "./dist/index.mjs"
11+ "t3": "./dist/bin.mjs"
1212 },
1313 "files": [
1414 "dist"
1515 ],
1616 "type": "module",
1717 "scripts": {
18− "dev": "bun run src/index.ts",
18+ "dev": "bun run src/bin.ts",
1919 "build": "node scripts/cli.ts build",
20− "start": "node dist/index.mjs",
20+ "start": "node dist/bin.mjs",
2121 "prepare": "effect-language-service patch",
2222 "typecheck": "tsc --noEmit",
2323 "test": "vitest run"
2424 },
2525 "dependencies": {
2626 "@anthropic-ai/claude-agent-sdk": "^0.2.77",
27+ "@effect/platform-bun": "catalog:",
2728 "@effect/platform-node": "catalog:",
2829 "@effect/sql-sqlite-bun": "catalog:",
2930 "@pierre/diffs": "^1.1.0-beta.16",
3031 "effect": "catalog:",
3132 "node-pty": "^1.1.0",
32− "open": "^10.1.0",
33− "ws": "^8.18.0"
33+ "open": "^10.1.0"
3434 },
3535 "devDependencies": {
3636 "@effect/language-service": "catalog:",
@@ -40,7 +40,6 @@ · 3 unmodified lines
4040 "@t3tools/web": "workspace:*",
4141 "@types/bun": "catalog:",
4242 "@types/node": "catalog:",
43− "@types/ws": "^8.5.13",
4443 "tsdown": "catalog:",
4544 "typescript": "catalog:",
4645 "vitest": "catalog:"
apps/server/src/bin.ts+17−0
@@ -0,0 +1,17 @@
1+import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import { Command } from "effect/unstable/cli";
6+
7+import { NetService } from "@t3tools/shared/Net";
8+import { cli } from "./cli";
9+import { version } from "../package.json" with { type: "json" };
10+
11+const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
12+
13+Command.run(cli, { version }).pipe(
14+ Effect.scoped,
15+ Effect.provide(CliRuntimeLayer),
16+ NodeRuntime.runMain,
17+);
apps/server/src/checkpointing/Errors.ts+1−1
@@ -1,6 +1,6 @@
11 import { Schema } from "effect";
22 import type { ProjectionRepositoryError } from "../persistence/Errors.ts";
3−import { GitCommandError } from "../git/Errors.ts";
3+import { GitCommandError } from "@t3tools/contracts";
44
55 /**
66 * CheckpointUnavailableError - Expected checkpoint does not exist.
apps/server/src/checkpointing/Layers/CheckpointStore.test.ts+1−1
@@ -10,7 +10,7 @@ import { CheckpointStoreLive } from "./CheckpointStore.ts"; · 9 unmodified lines
1010 import { CheckpointStore } from "../Services/CheckpointStore.ts";
1111 import { GitCoreLive } from "../../git/Layers/GitCore.ts";
1212 import { GitCore } from "../../git/Services/GitCore.ts";
13−import { GitCommandError } from "../../git/Errors.ts";
13+import { GitCommandError } from "@t3tools/contracts";
1414 import { ServerConfig } from "../../config.ts";
1515 import { ThreadId } from "@t3tools/contracts";
1616
apps/server/src/checkpointing/Layers/CheckpointStore.ts+1−1
@@ -14,7 +14,7 @@ import { randomUUID } from "node:crypto"; · 13 unmodified lines
1414 import { Effect, Layer, FileSystem, Path } from "effect";
1515
1616 import { CheckpointInvariantError } from "../Errors.ts";
17−import { GitCommandError } from "../../git/Errors.ts";
17+import { GitCommandError } from "@t3tools/contracts";
1818 import { GitCore } from "../../git/Services/GitCore.ts";
1919 import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
2020 import { CheckpointRef } from "@t3tools/contracts";
apps/server/src/cli-config.test.ts+318−0
@@ -0,0 +1,318 @@
1+import os from "node:os";
2+
3+import { assert, expect, it } from "@effect/vitest";
4+import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect";
5+
6+import { NetService } from "@t3tools/shared/Net";
7+import * as NodeServices from "@effect/platform-node/NodeServices";
8+import { deriveServerPaths } from "./config";
9+import { resolveServerConfig } from "./cli";
10+
11+it.layer(NodeServices.layer)("cli config resolution", (it) => {
12+ const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) {
13+ const fs = yield* FileSystem.FileSystem;
14+ const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
15+ yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`);
16+ const { fd } = yield* fs.open(filePath, { flag: "r" });
17+ return fd;
18+ });
19+
20+ it.effect("falls back to effect/config values when flags are omitted", () =>
21+ Effect.gen(function* () {
22+ const { join } = yield* Path.Path;
23+ const baseDir = join(os.tmpdir(), "t3-cli-config-env-base");
24+ const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
25+ const resolved = yield* resolveServerConfig(
26+ {
27+ mode: Option.none(),
28+ port: Option.none(),
29+ host: Option.none(),
30+ baseDir: Option.none(),
31+ devUrl: Option.none(),
32+ noBrowser: Option.none(),
33+ authToken: Option.none(),
34+ bootstrapFd: Option.none(),
35+ autoBootstrapProjectFromCwd: Option.none(),
36+ logWebSocketEvents: Option.none(),
37+ },
38+ Option.none(),
39+ ).pipe(
40+ Effect.provide(
41+ Layer.mergeAll(
42+ ConfigProvider.layer(
43+ ConfigProvider.fromEnv({
44+ env: {
45+ T3CODE_LOG_LEVEL: "Warn",
46+ T3CODE_MODE: "desktop",
47+ T3CODE_PORT: "4001",
48+ T3CODE_HOST: "0.0.0.0",
49+ T3CODE_HOME: baseDir,
50+ VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
51+ T3CODE_NO_BROWSER: "true",
52+ T3CODE_AUTH_TOKEN: "env-token",
53+ T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
54+ T3CODE_LOG_WS_EVENTS: "true",
55+ },
56+ }),
57+ ),
58+ NetService.layer,
59+ ),
60+ ),
61+ );
62+
63+ expect(resolved).toEqual({
64+ logLevel: "Warn",
65+ mode: "desktop",
66+ port: 4001,
67+ cwd: process.cwd(),
68+ baseDir,
69+ ...derivedPaths,
70+ host: "0.0.0.0",
71+ staticDir: undefined,
72+ devUrl: new URL("http://127.0.0.1:5173"),
73+ noBrowser: true,
74+ authToken: "env-token",
75+ autoBootstrapProjectFromCwd: false,
76+ logWebSocketEvents: true,
77+ });
78+ }),
79+ );
80+
81+ it.effect("uses CLI flags when provided", () =>
82+ Effect.gen(function* () {
83+ const { join } = yield* Path.Path;
84+ const baseDir = join(os.tmpdir(), "t3-cli-config-flags-base");
85+ const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
86+ const resolved = yield* resolveServerConfig(
87+ {
88+ mode: Option.some("web"),
89+ port: Option.some(8788),
90+ host: Option.some("127.0.0.1"),
91+ baseDir: Option.some(baseDir),
92+ devUrl: Option.some(new URL("http://127.0.0.1:4173")),
93+ noBrowser: Option.some(true),
94+ authToken: Option.some("flag-token"),
95+ bootstrapFd: Option.none(),
96+ autoBootstrapProjectFromCwd: Option.some(true),
97+ logWebSocketEvents: Option.some(true),
98+ },
99+ Option.some("Debug"),
100+ ).pipe(
101+ Effect.provide(
102+ Layer.mergeAll(
103+ ConfigProvider.layer(
104+ ConfigProvider.fromEnv({
105+ env: {
106+ T3CODE_LOG_LEVEL: "Warn",
107+ T3CODE_MODE: "desktop",
108+ T3CODE_PORT: "4001",
109+ T3CODE_HOST: "0.0.0.0",
110+ T3CODE_HOME: join(os.tmpdir(), "ignored-base"),
111+ VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
112+ T3CODE_NO_BROWSER: "false",
113+ T3CODE_AUTH_TOKEN: "ignored-token",
114+ T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
115+ T3CODE_LOG_WS_EVENTS: "false",
116+ },
117+ }),
118+ ),
119+ NetService.layer,
120+ ),
121+ ),
122+ );
123+
124+ expect(resolved).toEqual({
125+ logLevel: "Debug",
126+ mode: "web",
127+ port: 8788,
128+ cwd: process.cwd(),
129+ baseDir,
130+ ...derivedPaths,
131+ host: "127.0.0.1",
132+ staticDir: undefined,
133+ devUrl: new URL("http://127.0.0.1:4173"),
134+ noBrowser: true,
135+ authToken: "flag-token",
136+ autoBootstrapProjectFromCwd: true,
137+ logWebSocketEvents: true,
138+ });
139+ }),
140+ );
141+
142+ it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () =>
143+ Effect.gen(function* () {
144+ const { join } = yield* Path.Path;
145+ const baseDir = "/tmp/t3-bootstrap-home";
146+ const fd = yield* openBootstrapFd({
147+ mode: "desktop",
148+ port: 4888,
149+ host: "127.0.0.2",
150+ t3Home: baseDir,
151+ devUrl: "http://127.0.0.1:5173",
152+ noBrowser: true,
153+ authToken: "bootstrap-token",
154+ autoBootstrapProjectFromCwd: false,
155+ logWebSocketEvents: true,
156+ });
157+ const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
158+
159+ const resolved = yield* resolveServerConfig(
160+ {
161+ mode: Option.none(),
162+ port: Option.none(),
163+ host: Option.none(),
164+ baseDir: Option.none(),
165+ devUrl: Option.none(),
166+ noBrowser: Option.none(),
167+ authToken: Option.none(),
168+ bootstrapFd: Option.none(),
169+ autoBootstrapProjectFromCwd: Option.none(),
170+ logWebSocketEvents: Option.none(),
171+ },
172+ Option.none(),
173+ ).pipe(
174+ Effect.provide(
175+ Layer.mergeAll(
176+ ConfigProvider.layer(
177+ ConfigProvider.fromEnv({
178+ env: {
179+ T3CODE_BOOTSTRAP_FD: String(fd),
180+ },
181+ }),
182+ ),
183+ NetService.layer,
184+ ),
185+ ),
186+ );
187+
188+ expect(resolved).toEqual({
189+ logLevel: "Info",
190+ mode: "desktop",
191+ port: 4888,
192+ cwd: process.cwd(),
193+ baseDir,
194+ ...derivedPaths,
195+ host: "127.0.0.2",
196+ staticDir: undefined,
197+ devUrl: new URL("http://127.0.0.1:5173"),
198+ noBrowser: true,
199+ authToken: "bootstrap-token",
200+ autoBootstrapProjectFromCwd: false,
201+ logWebSocketEvents: true,
202+ });
203+ assert.equal(join(baseDir, "dev"), resolved.stateDir);
204+ }),
205+ );
206+
207+ it.effect("creates derived runtime directories during config resolution", () =>
208+ Effect.gen(function* () {
209+ const fs = yield* FileSystem.FileSystem;
210+ const path = yield* Path.Path;
211+ const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-dirs-" });
212+
213+ const resolved = yield* resolveServerConfig(
214+ {
215+ mode: Option.some("desktop"),
216+ port: Option.some(4888),
217+ host: Option.none(),
218+ baseDir: Option.some(baseDir),
219+ devUrl: Option.some(new URL("http://127.0.0.1:5173")),
220+ noBrowser: Option.none(),
221+ authToken: Option.none(),
222+ bootstrapFd: Option.none(),
223+ autoBootstrapProjectFromCwd: Option.none(),
224+ logWebSocketEvents: Option.none(),
225+ },
226+ Option.none(),
227+ ).pipe(
228+ Effect.provide(
229+ Layer.mergeAll(
230+ ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
231+ NetService.layer,
232+ ),
233+ ),
234+ );
235+
236+ for (const directory of [
237+ resolved.stateDir,
238+ resolved.logsDir,
239+ resolved.providerLogsDir,
240+ resolved.terminalLogsDir,
241+ resolved.attachmentsDir,
242+ resolved.worktreesDir,
243+ path.dirname(resolved.serverLogPath),
244+ ]) {
245+ expect(yield* fs.exists(directory)).toBe(true);
246+ }
247+ }),
248+ );
249+
250+ it.effect("applies flag then env precedence over bootstrap envelope values", () =>
251+ Effect.gen(function* () {
252+ const { join } = yield* Path.Path;
253+ const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins");
254+ const fd = yield* openBootstrapFd({
255+ mode: "desktop",
256+ port: 4888,
257+ host: "127.0.0.2",
258+ t3Home: "/tmp/t3-bootstrap-home",
259+ devUrl: "http://127.0.0.1:5173",
260+ noBrowser: false,
261+ authToken: "bootstrap-token",
262+ autoBootstrapProjectFromCwd: false,
263+ logWebSocketEvents: false,
264+ });
265+ const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
266+
267+ const resolved = yield* resolveServerConfig(
268+ {
269+ mode: Option.none(),
270+ port: Option.some(8788),
271+ host: Option.some("127.0.0.1"),
272+ baseDir: Option.none(),
273+ devUrl: Option.some(new URL("http://127.0.0.1:4173")),
274+ noBrowser: Option.none(),
275+ authToken: Option.some("flag-token"),
276+ bootstrapFd: Option.none(),
277+ autoBootstrapProjectFromCwd: Option.none(),
278+ logWebSocketEvents: Option.none(),
279+ },
280+ Option.some("Debug"),
281+ ).pipe(
282+ Effect.provide(
283+ Layer.mergeAll(
284+ ConfigProvider.layer(
285+ ConfigProvider.fromEnv({
286+ env: {
287+ T3CODE_MODE: "web",
288+ T3CODE_BOOTSTRAP_FD: String(fd),
289+ T3CODE_HOME: baseDir,
290+ T3CODE_NO_BROWSER: "true",
291+ T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
292+ T3CODE_LOG_WS_EVENTS: "true",
293+ },
294+ }),
295+ ),
296+ NetService.layer,
297+ ),
298+ ),
299+ );
300+
301+ expect(resolved).toEqual({
302+ logLevel: "Debug",
303+ mode: "web",
304+ port: 8788,
305+ cwd: process.cwd(),
306+ baseDir,
307+ ...derivedPaths,
308+ host: "127.0.0.1",
309+ staticDir: undefined,
310+ devUrl: new URL("http://127.0.0.1:4173"),
311+ noBrowser: true,
312+ authToken: "flag-token",
313+ autoBootstrapProjectFromCwd: true,
314+ logWebSocketEvents: true,
315+ });
316+ }),
317+ );
318+});
apps/server/src/cli.test.ts+37−0
@@ -0,0 +1,37 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { NetService } from "@t3tools/shared/Net";
3+import { assert, it } from "@effect/vitest";
4+import * as Effect from "effect/Effect";
5+import * as Layer from "effect/Layer";
6+import * as CliError from "effect/unstable/cli/CliError";
7+import { Command } from "effect/unstable/cli";
8+
9+import { cli } from "./cli.ts";
10+
11+const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
12+
13+it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
14+ it.effect("accepts the built-in lowercase log-level flag values", () =>
15+ Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe(
16+ Effect.provide(CliRuntimeLayer),
17+ ),
18+ );
19+
20+ it.effect("rejects invalid log-level casing before launching the server", () =>
21+ Effect.gen(function* () {
22+ const error = yield* Command.runWith(cli, { version: "0.0.0" })([
23+ "--log-level",
24+ "Debug",
25+ ]).pipe(Effect.provide(CliRuntimeLayer), Effect.flip);
26+
27+ if (!CliError.isCliError(error)) {
28+ assert.fail(`Expected CliError, got ${String(error)}`);
29+ }
30+ if (error._tag !== "InvalidValue") {
31+ assert.fail(`Expected InvalidValue, got ${error._tag}`);
32+ }
33+ assert.equal(error.option, "log-level");
34+ assert.equal(error.value, "Debug");
35+ }),
36+ );
37+});
apps/server/src/cli.ts+292−0
@@ -0,0 +1,292 @@
1+import { NetService } from "@t3tools/shared/Net";
2+import { Config, Effect, LogLevel, Option, Schema } from "effect";
3+import { Command, Flag, GlobalFlag } from "effect/unstable/cli";
4+
5+import {
6+ DEFAULT_PORT,
7+ deriveServerPaths,
8+ ensureServerDirectories,
9+ resolveStaticDir,
10+ ServerConfig,
11+ RuntimeMode,
12+ type ServerConfigShape,
13+} from "./config";
14+import { readBootstrapEnvelope } from "./bootstrap";
15+import { resolveBaseDir } from "./os-jank";
16+import { runServer } from "./server";
17+
18+const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
19+
20+const BootstrapEnvelopeSchema = Schema.Struct({
21+ mode: Schema.optional(RuntimeMode),
22+ port: Schema.optional(PortSchema),
23+ host: Schema.optional(Schema.String),
24+ t3Home: Schema.optional(Schema.String),
25+ devUrl: Schema.optional(Schema.URLFromString),
26+ noBrowser: Schema.optional(Schema.Boolean),
27+ authToken: Schema.optional(Schema.String),
28+ autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
29+ logWebSocketEvents: Schema.optional(Schema.Boolean),
30+});
31+
32+const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
33+ Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
34+ Flag.optional,
35+);
36+const portFlag = Flag.integer("port").pipe(
37+ Flag.withSchema(PortSchema),
38+ Flag.withDescription("Port for the HTTP/WebSocket server."),
39+ Flag.optional,
40+);
41+const hostFlag = Flag.string("host").pipe(
42+ Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),
43+ Flag.optional,
44+);
45+const baseDirFlag = Flag.string("base-dir").pipe(
46+ Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."),
47+ Flag.optional,
48+);
49+const devUrlFlag = Flag.string("dev-url").pipe(
50+ Flag.withSchema(Schema.URLFromString),
51+ Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."),
52+ Flag.optional,
53+);
54+const noBrowserFlag = Flag.boolean("no-browser").pipe(
55+ Flag.withDescription("Disable automatic browser opening."),
56+ Flag.optional,
57+);
58+const authTokenFlag = Flag.string("auth-token").pipe(
59+ Flag.withDescription("Auth token required for WebSocket connections."),
60+ Flag.withAlias("token"),
61+ Flag.optional,
62+);
63+const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(
64+ Flag.withSchema(Schema.Int),
65+ Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),
66+ Flag.optional,
67+);
68+const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
69+ Flag.withDescription(
70+ "Create a project for the current working directory on startup when missing.",
71+ ),
72+ Flag.optional,
73+);
74+const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(
75+ Flag.withDescription(
76+ "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).",
77+ ),
78+ Flag.withAlias("log-ws-events"),
79+ Flag.optional,
80+);
81+
82+const EnvServerConfig = Config.all({
83+ logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
84+ mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe(
85+ Config.option,
86+ Config.map(Option.getOrUndefined),
87+ ),
88+ port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
89+ host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
90+ t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
91+ devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
92+ noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
93+ Config.option,
94+ Config.map(Option.getOrUndefined),
95+ ),
96+ authToken: Config.string("T3CODE_AUTH_TOKEN").pipe(
97+ Config.option,
98+ Config.map(Option.getOrUndefined),
99+ ),
100+ bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(
101+ Config.option,
102+ Config.map(Option.getOrUndefined),
103+ ),
104+ autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
105+ Config.option,
106+ Config.map(Option.getOrUndefined),
107+ ),
108+ logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe(
109+ Config.option,
110+ Config.map(Option.getOrUndefined),
111+ ),
112+});
113+
114+interface CliServerFlags {
115+ readonly mode: Option.Option<RuntimeMode>;
116+ readonly port: Option.Option<number>;
117+ readonly host: Option.Option<string>;
118+ readonly baseDir: Option.Option<string>;
119+ readonly devUrl: Option.Option<URL>;
120+ readonly noBrowser: Option.Option<boolean>;
121+ readonly authToken: Option.Option<string>;
122+ readonly bootstrapFd: Option.Option<number>;
123+ readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
124+ readonly logWebSocketEvents: Option.Option<boolean>;
125+}
126+
127+const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
128+ Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
129+
130+const resolveOptionPrecedence = <Value>(
131+ ...values: ReadonlyArray<Option.Option<Value>>
132+): Option.Option<Value> => Option.firstSomeOf(values);
133+
134+export const resolveServerConfig = (
135+ flags: CliServerFlags,
136+ cliLogLevel: Option.Option<LogLevel.LogLevel>,
137+) =>
138+ Effect.gen(function* () {
139+ const { findAvailablePort } = yield* NetService;
140+ const env = yield* EnvServerConfig;
141+ const bootstrapFd = Option.getOrUndefined(flags.bootstrapFd) ?? env.bootstrapFd;
142+ const bootstrapEnvelope =
143+ bootstrapFd !== undefined
144+ ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
145+ : Option.none();
146+
147+ const mode: RuntimeMode = Option.getOrElse(
148+ resolveOptionPrecedence(
149+ flags.mode,
150+ Option.fromUndefinedOr(env.mode),
151+ Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.mode)),
152+ ),
153+ () => "web",
154+ );
155+
156+ const port = yield* Option.match(
157+ resolveOptionPrecedence(
158+ flags.port,
159+ Option.fromUndefinedOr(env.port),
160+ Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.port)),
161+ ),
162+ {
163+ onSome: (value) => Effect.succeed(value),
164+ onNone: () => {
165+ if (mode === "desktop") {
166+ return Effect.succeed(DEFAULT_PORT);
167+ }
168+ return findAvailablePort(DEFAULT_PORT);
169+ },
170+ },
171+ );
172+ const devUrl = Option.getOrElse(
173+ resolveOptionPrecedence(
174+ flags.devUrl,
175+ Option.fromUndefinedOr(env.devUrl),
176+ Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.devUrl)),
177+ ),
178+ () => undefined,
179+ );
180+ const baseDir = yield* resolveBaseDir(
181+ Option.getOrUndefined(
182+ resolveOptionPrecedence(
183+ flags.baseDir,
184+ Option.fromUndefinedOr(env.t3Home),
185+ Option.flatMap(bootstrapEnvelope, (bootstrap) =>
186+ Option.fromUndefinedOr(bootstrap.t3Home),
187+ ),
188+ ),
189+ ),
190+ );
191+ const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
192+ yield* ensureServerDirectories(derivedPaths);
193+ const noBrowser = resolveBooleanFlag(
194+ flags.noBrowser,
195+ Option.getOrElse(
196+ resolveOptionPrecedence(
197+ Option.fromUndefinedOr(env.noBrowser),
198+ Option.flatMap(bootstrapEnvelope, (bootstrap) =>
199+ Option.fromUndefinedOr(bootstrap.noBrowser),
200+ ),
201+ ),
202+ () => mode === "desktop",
203+ ),
204+ );
205+ const authToken = Option.getOrUndefined(
206+ resolveOptionPrecedence(
207+ flags.authToken,
208+ Option.fromUndefinedOr(env.authToken),
209+ Option.flatMap(bootstrapEnvelope, (bootstrap) =>
210+ Option.fromUndefinedOr(bootstrap.authToken),
211+ ),
212+ ),
213+ );
214+ const autoBootstrapProjectFromCwd = resolveBooleanFlag(
215+ flags.autoBootstrapProjectFromCwd,
216+ Option.getOrElse(
217+ resolveOptionPrecedence(
218+ Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
219+ Option.flatMap(bootstrapEnvelope, (bootstrap) =>
220+ Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd),
221+ ),
222+ ),
223+ () => mode === "web",
224+ ),
225+ );
226+ const logWebSocketEvents = resolveBooleanFlag(
227+ flags.logWebSocketEvents,
228+ Option.getOrElse(
229+ resolveOptionPrecedence(
230+ Option.fromUndefinedOr(env.logWebSocketEvents),
231+ Option.flatMap(bootstrapEnvelope, (bootstrap) =>
232+ Option.fromUndefinedOr(bootstrap.logWebSocketEvents),
233+ ),
234+ ),
235+ () => Boolean(devUrl),
236+ ),
237+ );
238+ const staticDir = devUrl ? undefined : yield* resolveStaticDir();
239+ const host = Option.getOrElse(
240+ resolveOptionPrecedence(
241+ flags.host,
242+ Option.fromUndefinedOr(env.host),
243+ Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)),
244+ ),
245+ () => (mode === "desktop" ? "127.0.0.1" : undefined),
246+ );
247+ const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel);
248+
249+ const config: ServerConfigShape = {
250+ logLevel,
251+ mode,
252+ port,
253+ cwd: process.cwd(),
254+ baseDir,
255+ ...derivedPaths,
256+ host,
257+ staticDir,
258+ devUrl,
259+ noBrowser,
260+ authToken,
261+ autoBootstrapProjectFromCwd,
262+ logWebSocketEvents,
263+ };
264+
265+ return config;
266+ });
267+
268+const commandFlags = {
269+ mode: modeFlag,
270+ port: portFlag,
271+ host: hostFlag,
272+ baseDir: baseDirFlag,
273+ devUrl: devUrlFlag,
274+ noBrowser: noBrowserFlag,
275+ authToken: authTokenFlag,
276+ bootstrapFd: bootstrapFdFlag,
277+ autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
278+ logWebSocketEvents: logWebSocketEventsFlag,
279+} as const;
280+
281+const rootCommand = Command.make("t3", commandFlags).pipe(
282+ Command.withDescription("Run the T3 Code server."),
283+ Command.withHandler((flags) =>
284+ Effect.gen(function* () {
285+ const logLevel = yield* GlobalFlag.LogLevel;
286+ const config = yield* resolveServerConfig(flags, logLevel);
287+ return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
288+ }),
289+ ),
290+);
291+
292+export const cli = rootCommand;
apps/server/src/config.ts+26−6
@@ -6,11 +6,12 @@ · 5 unmodified lines
66 *
77 * @module ServerConfig
88 */
9−import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
9+import { Effect, FileSystem, Layer, LogLevel, Path, Schema, ServiceMap } from "effect";
1010
1111 export const DEFAULT_PORT = 3773;
1212
13−export type RuntimeMode = "web" | "desktop";
13+export const RuntimeMode = Schema.Literals(["web", "desktop"]);
14+export type RuntimeMode = typeof RuntimeMode.Type;
1415
1516 /**
1617 * ServerDerivedPaths - Derived paths from the base directory.
@@ -34,6 +35,7 @@ export interface ServerDerivedPaths { · 17 unmodified lines
3435 * ServerConfigShape - Process/runtime configuration required by the server.
3536 */
3637 export interface ServerConfigShape extends ServerDerivedPaths {
38+ readonly logLevel: LogLevel.LogLevel;
3739 readonly mode: RuntimeMode;
3840 readonly port: number;
3941 readonly host: string | undefined;
@@ -73,6 +75,26 @@ export const deriveServerPaths = Effect.fn(function* ( · 33 unmodified lines
7375 };
7476 });
7577
78+export const ensureServerDirectories = Effect.fn(function* (derivedPaths: ServerDerivedPaths) {
79+ const fs = yield* FileSystem.FileSystem;
80+ const path = yield* Path.Path;
81+
82+ yield* Effect.all(
83+ [
84+ fs.makeDirectory(derivedPaths.stateDir, { recursive: true }),
85+ fs.makeDirectory(derivedPaths.logsDir, { recursive: true }),
86+ fs.makeDirectory(derivedPaths.providerLogsDir, { recursive: true }),
87+ fs.makeDirectory(derivedPaths.terminalLogsDir, { recursive: true }),
88+ fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }),
89+ fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }),
90+ fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),
91+ fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),
92+ fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }),
93+ ],
94+ { concurrency: "unbounded" },
95+ );
96+});
97+
7698 /**
7799 * ServerConfig - Service tag for server runtime configuration.
78100 */
@@ -91,12 +113,10 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS · 12 unmodified lines
91113 ? baseDirOrPrefix
92114 : yield* fs.makeTempDirectoryScoped({ prefix: baseDirOrPrefix.prefix });
93115 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
94−
95− yield* fs.makeDirectory(derivedPaths.stateDir, { recursive: true });
96− yield* fs.makeDirectory(derivedPaths.logsDir, { recursive: true });
97− yield* fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true });
116+ yield* ensureServerDirectories(derivedPaths);
98117
99118 return {
119+ logLevel: "Error",
100120 cwd,
101121 baseDir,
102122 ...derivedPaths,
apps/server/src/git/Errors.ts+0−67
@@ -1,67 +0,0 @@
1−import { Schema } from "effect";
2−
3−/**
4− * GitCommandError - Git command execution failed.
5− */
6−export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()("GitCommandError", {
7− operation: Schema.String,
8− command: Schema.String,
9− cwd: Schema.String,
10− detail: Schema.String,
11− cause: Schema.optional(Schema.Defect),
12−}) {
13− override get message(): string {
14− return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`;
15− }
16−}
17−
18−/**
19− * GitHubCliError - GitHub CLI execution or authentication failed.
20− */
21−export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", {
22− operation: Schema.String,
23− detail: Schema.String,
24− cause: Schema.optional(Schema.Defect),
25−}) {
26− override get message(): string {
27− return `GitHub CLI failed in ${this.operation}: ${this.detail}`;
28− }
29−}
30−
31−/**
32− * TextGenerationError - Commit or PR text generation failed.
33− */
34−export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationError>()(
35− "TextGenerationError",
36− {
37− operation: Schema.String,
38− detail: Schema.String,
39− cause: Schema.optional(Schema.Defect),
40− },
41−) {
42− override get message(): string {
43− return `Text generation failed in ${this.operation}: ${this.detail}`;
44− }
45−}
46−
47−/**
48− * GitManagerError - Stacked Git workflow orchestration failed.
49− */
50−export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", {
51− operation: Schema.String,
52− detail: Schema.String,
53− cause: Schema.optional(Schema.Defect),
54−}) {
55− override get message(): string {
56− return `Git manager failed in ${this.operation}: ${this.detail}`;
57− }
58−}
59−
60−/**
61− * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration.
62− */
63−export type GitManagerServiceError =
64− | GitManagerError
65− | GitCommandError
66− | GitHubCliError
67− | TextGenerationError;
apps/server/src/git/Layers/ClaudeTextGeneration.ts+1−1
@@ -14,7 +14,7 @@ import { ClaudeModelSelection } from "@t3tools/contracts"; · 13 unmodified lines
1414 import { resolveApiModelId } from "@t3tools/shared/model";
1515 import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1616
17−import { TextGenerationError } from "../Errors.ts";
17+import { TextGenerationError } from "@t3tools/contracts";
1818 import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
1919 import {
2020 buildBranchNamePrompt,
apps/server/src/git/Layers/CodexTextGeneration.test.ts+1−1
@@ -5,7 +5,7 @@ import { expect } from "vitest"; · 4 unmodified lines
55
66 import { ServerConfig } from "../../config.ts";
77 import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
8−import { TextGenerationError } from "../Errors.ts";
8+import { TextGenerationError } from "@t3tools/contracts";
99 import { TextGeneration } from "../Services/TextGeneration.ts";
1010 import { ServerSettingsService } from "../../serverSettings.ts";
1111
apps/server/src/git/Layers/CodexTextGeneration.ts+1−1
@@ -8,7 +8,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar · 7 unmodified lines
88
99 import { resolveAttachmentPath } from "../../attachmentStore.ts";
1010 import { ServerConfig } from "../../config.ts";
11−import { TextGenerationError } from "../Errors.ts";
11+import { TextGenerationError } from "@t3tools/contracts";
1212 import {
1313 type BranchNameGenerationInput,
1414 type ThreadTitleGenerationResult,
apps/server/src/git/Layers/GitCore.test.ts+1−1
@@ -8,7 +8,7 @@ import { describe, expect, vi } from "vitest"; · 7 unmodified lines
88
99 import { GitCoreLive, makeGitCore } from "./GitCore.ts";
1010 import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
11−import { GitCommandError } from "../Errors.ts";
11+import { GitCommandError } from "@t3tools/contracts";
1212 import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
1313 import { ServerConfig } from "../../config.ts";
1414
apps/server/src/git/Layers/GitCore.ts+4−3
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
1818 } from "effect";
1919 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2020
21−import { GitCommandError } from "../Errors.ts";
21+import { GitCommandError } from "@t3tools/contracts";
2222 import {
2323 GitCore,
2424 type ExecuteGitProgress,
@@ -1941,8 +1941,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 1916 unmodified lines
19411941 });
19421942
19431943 // Refresh upstream refs in the background so checkout remains responsive.
1944− yield* Effect.forkScoped(
1945− refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })),
1944+ yield* refreshCheckedOutBranchUpstream(input.cwd).pipe(
1945+ Effect.ignoreCause({ log: true }),
1946+ Effect.forkDetach({ startImmediately: true }),
19461947 );
19471948 },
19481949 );
apps/server/src/git/Layers/GitHubCli.ts+1−1
@@ -2,7 +2,7 @@ import { Effect, Layer, Schema } from "effect"; · 1 unmodified line
22 import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
33
44 import { runProcess } from "../../processRunner";
5−import { GitHubCliError } from "../Errors.ts";
5+import { GitHubCliError } from "@t3tools/contracts";
66 import {
77 GitHubCli,
88 type GitHubRepositoryCloneUrls,
apps/server/src/git/Layers/GitManager.test.ts+1−1
@@ -8,7 +8,7 @@ import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; · 7 unmodified lines
88 import { expect } from "vitest";
99 import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts";
1010
11−import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";
11+import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts";
1212 import { type GitManagerShape } from "../Services/GitManager.ts";
1313 import {
1414 type GitHubCliShape,
apps/server/src/git/Layers/GitManager.ts+2−2
@@ -14,7 +14,7 @@ import { · 13 unmodified lines
1414 sanitizeFeatureBranchName,
1515 } from "@t3tools/shared/git";
1616
17−import { GitManagerError } from "../Errors.ts";
17+import { GitManagerError } from "@t3tools/contracts";
1818 import {
1919 GitManager,
2020 type GitActionProgressReporter,
@@ -25,7 +25,7 @@ import { GitCore } from "../Services/GitCore.ts"; · 4 unmodified lines
2525 import { GitHubCli } from "../Services/GitHubCli.ts";
2626 import { TextGeneration } from "../Services/TextGeneration.ts";
2727 import { ServerSettingsService } from "../../serverSettings.ts";
28−import type { GitManagerServiceError } from "../Errors.ts";
28+import type { GitManagerServiceError } from "@t3tools/contracts";
2929
3030 const COMMIT_TIMEOUT_MS = 10 * 60_000;
3131 const MAX_PROGRESS_TEXT_LENGTH = 500;
apps/server/src/git/Prompts.test.ts+1−1
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
77 buildThreadTitlePrompt,
88 } from "./Prompts.ts";
99 import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts";
10−import { TextGenerationError } from "./Errors.ts";
10+import { TextGenerationError } from "@t3tools/contracts";
1111
1212 describe("buildCommitMessagePrompt", () => {
1313 it("includes staged patch and summary in the prompt", () => {
apps/server/src/git/Services/GitCore.ts+1−1
@@ -22,7 +22,7 @@ import type { · 21 unmodified lines
2222 GitStatusResult,
2323 } from "@t3tools/contracts";
2424
25−import type { GitCommandError } from "../Errors.ts";
25+import type { GitCommandError } from "@t3tools/contracts";
2626
2727 export interface ExecuteGitInput {
2828 readonly operation: string;
apps/server/src/git/Services/GitHubCli.ts+1−1
@@ -9,7 +9,7 @@ import { ServiceMap } from "effect"; · 8 unmodified lines
99 import type { Effect } from "effect";
1010
1111 import type { ProcessRunResult } from "../../processRunner";
12−import type { GitHubCliError } from "../Errors.ts";
12+import type { GitHubCliError } from "@t3tools/contracts";
1313
1414 export interface GitHubPullRequestSummary {
1515 readonly number: number;
apps/server/src/git/Services/GitManager.ts+1−1
@@ -19,7 +19,7 @@ import { · 18 unmodified lines
1919 } from "@t3tools/contracts";
2020 import { ServiceMap } from "effect";
2121 import type { Effect } from "effect";
22−import type { GitManagerServiceError } from "../Errors.ts";
22+import type { GitManagerServiceError } from "@t3tools/contracts";
2323
2424 export interface GitActionProgressReporter {
2525 readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>;
apps/server/src/git/Services/TextGeneration.ts+1−1
@@ -10,7 +10,7 @@ import { ServiceMap } from "effect"; · 9 unmodified lines
1010 import type { Effect } from "effect";
1111 import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";
1212
13−import type { TextGenerationError } from "../Errors.ts";
13+import type { TextGenerationError } from "@t3tools/contracts";
1414
1515 /** Providers that support git text generation (commit messages, PR content, branch names). */
1616 export type TextGenerationProvider = "codex" | "claudeAgent";
apps/server/src/git/Utils.ts+1−1
@@ -5,7 +5,7 @@ · 4 unmodified lines
55 */
66 import { Schema } from "effect";
77
8−import { TextGenerationError } from "./Errors.ts";
8+import { TextGenerationError } from "@t3tools/contracts";
99
1010 import { existsSync } from "node:fs";
1111 import { join } from "node:path";
apps/server/src/http.ts+197−0
@@ -0,0 +1,197 @@
1+import Mime from "@effect/platform-node/Mime";
2+import { Effect, FileSystem, Option, Path } from "effect";
3+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
4+
5+import {
6+ ATTACHMENTS_ROUTE_PREFIX,
7+ normalizeAttachmentRelativePath,
8+ resolveAttachmentRelativePath,
9+} from "./attachmentPaths";
10+import { resolveAttachmentPathById } from "./attachmentStore";
11+import { ServerConfig } from "./config";
12+import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver";
13+
14+const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
15+const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
16+
17+export const attachmentsRouteLayer = HttpRouter.add(
18+ "GET",
19+ `${ATTACHMENTS_ROUTE_PREFIX}/*`,
20+ Effect.gen(function* () {
21+ const request = yield* HttpServerRequest.HttpServerRequest;
22+ const url = HttpServerRequest.toURL(request);
23+ if (Option.isNone(url)) {
24+ return HttpServerResponse.text("Bad Request", { status: 400 });
25+ }
26+
27+ const config = yield* ServerConfig;
28+ const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
29+ const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);
30+ if (!normalizedRelativePath) {
31+ return HttpServerResponse.text("Invalid attachment path", { status: 400 });
32+ }
33+
34+ const isIdLookup =
35+ !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");
36+ const filePath = isIdLookup
37+ ? resolveAttachmentPathById({
38+ attachmentsDir: config.attachmentsDir,
39+ attachmentId: normalizedRelativePath,
40+ })
41+ : resolveAttachmentRelativePath({
42+ attachmentsDir: config.attachmentsDir,
43+ relativePath: normalizedRelativePath,
44+ });
45+ if (!filePath) {
46+ return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", {
47+ status: isIdLookup ? 404 : 400,
48+ });
49+ }
50+
51+ const fileSystem = yield* FileSystem.FileSystem;
52+ const fileInfo = yield* fileSystem
53+ .stat(filePath)
54+ .pipe(Effect.catch(() => Effect.succeed(null)));
55+ if (!fileInfo || fileInfo.type !== "File") {
56+ return HttpServerResponse.text("Not Found", { status: 404 });
57+ }
58+
59+ return yield* HttpServerResponse.file(filePath, {
60+ status: 200,
61+ headers: {
62+ "Cache-Control": "public, max-age=31536000, immutable",
63+ },
64+ }).pipe(
65+ Effect.catch(() =>
66+ Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
67+ ),
68+ );
69+ }),
70+);
71+
72+export const projectFaviconRouteLayer = HttpRouter.add(
73+ "GET",
74+ "/api/project-favicon",
75+ Effect.gen(function* () {
76+ const request = yield* HttpServerRequest.HttpServerRequest;
77+ const url = HttpServerRequest.toURL(request);
78+ if (Option.isNone(url)) {
79+ return HttpServerResponse.text("Bad Request", { status: 400 });
80+ }
81+
82+ const projectCwd = url.value.searchParams.get("cwd");
83+ if (!projectCwd) {
84+ return HttpServerResponse.text("Missing cwd parameter", { status: 400 });
85+ }
86+
87+ const faviconResolver = yield* ProjectFaviconResolver;
88+ const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd);
89+ if (!faviconFilePath) {
90+ return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, {
91+ status: 200,
92+ contentType: "image/svg+xml",
93+ headers: {
94+ "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL,
95+ },
96+ });
97+ }
98+
99+ return yield* HttpServerResponse.file(faviconFilePath, {
100+ status: 200,
101+ headers: {
102+ "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL,
103+ },
104+ }).pipe(
105+ Effect.catch(() =>
106+ Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
107+ ),
108+ );
109+ }),
110+);
111+
112+export const staticAndDevRouteLayer = HttpRouter.add(
113+ "GET",
114+ "*",
115+ Effect.gen(function* () {
116+ const request = yield* HttpServerRequest.HttpServerRequest;
117+ const url = HttpServerRequest.toURL(request);
118+ if (Option.isNone(url)) {
119+ return HttpServerResponse.text("Bad Request", { status: 400 });
120+ }
121+
122+ const config = yield* ServerConfig;
123+ if (config.devUrl) {
124+ return HttpServerResponse.redirect(config.devUrl.href, { status: 302 });
125+ }
126+
127+ if (!config.staticDir) {
128+ return HttpServerResponse.text("No static directory configured and no dev URL set.", {
129+ status: 503,
130+ });
131+ }
132+
133+ const fileSystem = yield* FileSystem.FileSystem;
134+ const path = yield* Path.Path;
135+ const staticRoot = path.resolve(config.staticDir);
136+ const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;
137+ const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
138+ const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
139+ const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, "");
140+ const hasPathTraversalSegment = staticRelativePath.startsWith("..");
141+ if (
142+ staticRelativePath.length === 0 ||
143+ hasRawLeadingParentSegment ||
144+ hasPathTraversalSegment ||
145+ staticRelativePath.includes("\0")
146+ ) {
147+ return HttpServerResponse.text("Invalid static file path", { status: 400 });
148+ }
149+
150+ const isWithinStaticRoot = (candidate: string) =>
151+ candidate === staticRoot ||
152+ candidate.startsWith(staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`);
153+
154+ let filePath = path.resolve(staticRoot, staticRelativePath);
155+ if (!isWithinStaticRoot(filePath)) {
156+ return HttpServerResponse.text("Invalid static file path", { status: 400 });
157+ }
158+
159+ const ext = path.extname(filePath);
160+ if (!ext) {
161+ filePath = path.resolve(filePath, "index.html");
162+ if (!isWithinStaticRoot(filePath)) {
163+ return HttpServerResponse.text("Invalid static file path", { status: 400 });
164+ }
165+ }
166+
167+ const fileInfo = yield* fileSystem
168+ .stat(filePath)
169+ .pipe(Effect.catch(() => Effect.succeed(null)));
170+ if (!fileInfo || fileInfo.type !== "File") {
171+ const indexPath = path.resolve(staticRoot, "index.html");
172+ const indexData = yield* fileSystem
173+ .readFile(indexPath)
174+ .pipe(Effect.catch(() => Effect.succeed(null)));
175+ if (!indexData) {
176+ return HttpServerResponse.text("Not Found", { status: 404 });
177+ }
178+ return HttpServerResponse.uint8Array(indexData, {
179+ status: 200,
180+ contentType: "text/html; charset=utf-8",
181+ });
182+ }
183+
184+ const contentType = Mime.getType(filePath) ?? "application/octet-stream";
185+ const data = yield* fileSystem
186+ .readFile(filePath)
187+ .pipe(Effect.catch(() => Effect.succeed(null)));
188+ if (!data) {
189+ return HttpServerResponse.text("Internal Server Error", { status: 500 });
190+ }
191+
192+ return HttpServerResponse.uint8Array(data, {
193+ status: 200,
194+ contentType,
195+ });
196+ }),
197+);
apps/server/src/index.ts+0−23
@@ -1,23 +0,0 @@
1−import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2−import * as NodeServices from "@effect/platform-node/NodeServices";
3−import * as Effect from "effect/Effect";
4−import * as Layer from "effect/Layer";
5−
6−import { CliConfig, t3Cli } from "./main";
7−import { OpenLive } from "./open";
8−import { Command } from "effect/unstable/cli";
9−import { version } from "../package.json" with { type: "json" };
10−import { ServerLive } from "./wsServer";
11−import { NetService } from "@t3tools/shared/Net";
12−import { FetchHttpClient } from "effect/unstable/http";
13−
14−const RuntimeLayer = Layer.empty.pipe(
15− Layer.provideMerge(CliConfig.layer),
16− Layer.provideMerge(ServerLive),
17− Layer.provideMerge(OpenLive),
18− Layer.provideMerge(NetService.layer),
19− Layer.provideMerge(NodeServices.layer),
20− Layer.provideMerge(FetchHttpClient.layer),
21−);
22−
23−Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);
apps/server/src/keybindings.test.ts+1−1
@@ -8,13 +8,13 @@ import { ServerConfig } from "./config"; · 7 unmodified lines
88 import {
99 DEFAULT_KEYBINDINGS,
1010 Keybindings,
11− KeybindingsConfigError,
1211 KeybindingsLive,
1312 ResolvedKeybindingFromConfig,
1413 compileResolvedKeybindingRule,
1514 compileResolvedKeybindingsConfig,
1615 parseKeybindingShortcut,
1716 } from "./keybindings";
17+import { KeybindingsConfigError } from "@t3tools/contracts";
1818
1919 const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
2020 const makeKeybindingsLayer = () => {
apps/server/src/keybindings.ts+1−13
@@ -9,6 +9,7 @@ · 8 unmodified lines
99 import {
1010 KeybindingRule,
1111 KeybindingsConfig,
12+ KeybindingsConfigError,
1213 KeybindingShortcut,
1314 KeybindingWhenNode,
1415 MAX_KEYBINDINGS_COUNT,
@@ -46,19 +47,6 @@ import * as Semaphore from "effect/Semaphore"; · 31 unmodified lines
4647 import { ServerConfig } from "./config";
4748 import { fromLenientJson } from "@t3tools/shared/schemaJson";
4849
49−export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()(
50− "KeybindingsConfigParseError",
51− {
52− configPath: Schema.String,
53− detail: Schema.String,
54− cause: Schema.optional(Schema.Defect),
55− },
56−) {
57− override get message(): string {
58− return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`;
59− }
60−}
61−
6250 type WhenToken =
6351 | { type: "identifier"; value: string }
6452 | { type: "not" }
apps/server/src/logger.ts+0−103
@@ -1,103 +0,0 @@
1−import util from "node:util";
2−
3−type LogLevel = "info" | "warn" | "error" | "event";
4−
5−type LogContext = Record<string, unknown>;
6−
7−const ANSI = {
8− reset: "\u001b[0m",
9− dim: "\u001b[2m",
10− cyan: "\u001b[36m",
11− yellow: "\u001b[33m",
12− red: "\u001b[31m",
13− magenta: "\u001b[35m",
14−} as const;
15−
16−const LEVEL_LABEL: Record<LogLevel, string> = {
17− info: "INFO",
18− warn: "WARN",
19− error: "ERROR",
20− event: "EVENT",
21−};
22−
23−const LEVEL_COLOR: Record<LogLevel, string> = {
24− info: ANSI.cyan,
25− warn: ANSI.yellow,
26− error: ANSI.red,
27− event: ANSI.magenta,
28−};
29−
30−function useColors() {
31− return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined;
32−}
33−
34−function colorize(value: string, color: string, enabled: boolean) {
35− return enabled ? `${color}${value}${ANSI.reset}` : value;
36−}
37−
38−function timeStamp() {
39− return new Date().toISOString().slice(11, 23);
40−}
41−
42−function formatValue(value: unknown) {
43− if (typeof value === "string") {
44− return JSON.stringify(value);
45− }
46− if (
47− typeof value === "number" ||
48− typeof value === "boolean" ||
49− value === null ||
50− value === undefined
51− ) {
52− return String(value);
53− }
54− return util.inspect(value, {
55− depth: 4,
56− breakLength: Infinity,
57− compact: true,
58− maxArrayLength: 25,
59− maxStringLength: 320,
60− });
61−}
62−
63−function formatContext(context: LogContext | undefined) {
64− if (!context) return "";
65− const entries = Object.entries(context).filter(([, value]) => value !== undefined);
66− if (entries.length === 0) return "";
67− return entries.map(([key, value]) => `${key}=${formatValue(value)}`).join(" ");
68−}
69−
70−function write(level: LogLevel, scope: string, message: string, context?: LogContext) {
71− const colorEnabled = useColors();
72− const ts = colorize(timeStamp(), ANSI.dim, colorEnabled);
73− const levelLabel = colorize(LEVEL_LABEL[level], LEVEL_COLOR[level], colorEnabled);
74− const contextText = formatContext(context);
75− const line = `${ts} ${levelLabel} [${scope}] ${message}${contextText ? ` ${contextText}` : ""}`;
76−
77− if (level === "warn") {
78− console.warn(line);
79− return;
80− }
81− if (level === "error") {
82− console.error(line);
83− return;
84− }
85− console.log(line);
86−}
87−
88−export function createLogger(scope: string) {
89− return {
90− info(message: string, context?: LogContext) {
91− write("info", scope, message, context);
92− },
93− warn(message: string, context?: LogContext) {
94− write("warn", scope, message, context);
95− },
96− error(message: string, context?: LogContext) {
97− write("error", scope, message, context);
98− },
99− event(message: string, context?: LogContext) {
100− write("event", scope, message, context);
101− },
102− };
103−}
apps/server/src/main.test.ts+0−413
@@ -1,413 +0,0 @@
1−import * as Http from "node:http";
2−import * as NodeServices from "@effect/platform-node/NodeServices";
3−import { assert, it, vi } from "@effect/vitest";
4−import type { OrchestrationReadModel } from "@t3tools/contracts";
5−import * as ConfigProvider from "effect/ConfigProvider";
6−import * as Effect from "effect/Effect";
7−import * as FileSystem from "effect/FileSystem";
8−import * as Layer from "effect/Layer";
9−import * as Command from "effect/unstable/cli/Command";
10−import { FetchHttpClient } from "effect/unstable/http";
11−import { beforeEach } from "vitest";
12−import { NetService } from "@t3tools/shared/Net";
13−
14−import { CliConfig, recordStartupHeartbeat, t3Cli, type CliConfigShape } from "./main";
15−import { ServerConfig, type ServerConfigShape } from "./config";
16−import { Open, type OpenShape } from "./open";
17−import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
18−import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
19−import { Server, type ServerShape } from "./wsServer";
20−import { ServerSettingsService } from "./serverSettings";
21−
22−const start = vi.fn(() => undefined);
23−const stop = vi.fn(() => undefined);
24−const fixPath = vi.fn(() => undefined);
25−let resolvedConfig: ServerConfigShape | null = null;
26−const serverStart = Effect.acquireRelease(
27− Effect.gen(function* () {
28− resolvedConfig = yield* ServerConfig;
29− start();
30− return {} as unknown as Http.Server;
31− }),
32− () => Effect.sync(() => stop()),
33−);
34−const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred));
35−
36−// Shared service layer used by this CLI test suite.
37−const testLayer = Layer.mergeAll(
38− Layer.succeed(CliConfig, {
39− cwd: "/tmp/t3-test-workspace",
40− fixPath: Effect.sync(fixPath),
41− resolveStaticDir: Effect.undefined,
42− } satisfies CliConfigShape),
43− Layer.succeed(NetService, {
44− canListenOnHost: () => Effect.succeed(true),
45− isPortAvailableOnLoopback: () => Effect.succeed(true),
46− reserveLoopbackPort: () => Effect.succeed(0),
47− findAvailablePort,
48− }),
49− Layer.succeed(Server, {
50− start: serverStart,
51− stopSignal: Effect.void,
52− } satisfies ServerShape),
53− Layer.succeed(Open, {
54− openBrowser: (_target: string) => Effect.void,
55− openInEditor: () => Effect.void,
56− } satisfies OpenShape),
57− ServerSettingsService.layerTest(),
58− AnalyticsService.layerTest,
59− FetchHttpClient.layer,
60− NodeServices.layer,
61−);
62−
63−const runCli = (
64− args: ReadonlyArray<string>,
65− env: Record<string, string> = { T3CODE_NO_BROWSER: "true" },
66−) => {
67− return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe(
68− Effect.provide(
69− ConfigProvider.layer(
70− ConfigProvider.fromEnv({
71− env: {
72− ...env,
73− },
74− }),
75− ),
76− ),
77− );
78−};
79−
80−beforeEach(() => {
81− vi.clearAllMocks();
82− resolvedConfig = null;
83− start.mockImplementation(() => undefined);
84− stop.mockImplementation(() => undefined);
85− fixPath.mockImplementation(() => undefined);
86− findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
87−});
88−
89−it.layer(testLayer)("server CLI command", (it) => {
90− it.effect("parses all CLI flags and wires scoped start/stop", () =>
91− Effect.gen(function* () {
92− yield* runCli([
93− "--mode",
94− "desktop",
95− "--port",
96− "4010",
97− "--host",
98− "0.0.0.0",
99− "--home-dir",
100− "/tmp/t3-cli-home",
101− "--dev-url",
102− "http://127.0.0.1:5173",
103− "--no-browser",
104− "--auth-token",
105− "auth-secret",
106− ]);
107−
108− assert.equal(start.mock.calls.length, 1);
109− assert.equal(resolvedConfig?.mode, "desktop");
110− assert.equal(resolvedConfig?.port, 4010);
111− assert.equal(resolvedConfig?.host, "0.0.0.0");
112− assert.equal(resolvedConfig?.baseDir, "/tmp/t3-cli-home");
113− assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-home/dev");
114− assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
115− assert.equal(resolvedConfig?.noBrowser, true);
116− assert.equal(resolvedConfig?.authToken, "auth-secret");
117− assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
118− assert.equal(resolvedConfig?.logWebSocketEvents, true);
119− assert.equal(stop.mock.calls.length, 1);
120− }),
121− );
122−
123− it.effect("supports --token as an alias for --auth-token", () =>
124− Effect.gen(function* () {
125− yield* runCli(["--token", "token-secret"]);
126−
127− assert.equal(start.mock.calls.length, 1);
128− assert.equal(resolvedConfig?.authToken, "token-secret");
129− }),
130− );
131−
132− it.effect("uses env fallbacks when flags are not provided", () =>
133− Effect.gen(function* () {
134− yield* runCli([], {
135− T3CODE_MODE: "desktop",
136− T3CODE_PORT: "4999",
137− T3CODE_HOST: "100.88.10.4",
138− T3CODE_HOME: "/tmp/t3-env-home",
139− VITE_DEV_SERVER_URL: "http://localhost:5173",
140− T3CODE_NO_BROWSER: "true",
141− T3CODE_AUTH_TOKEN: "env-token",
142− });
143−
144− assert.equal(start.mock.calls.length, 1);
145− assert.equal(resolvedConfig?.mode, "desktop");
146− assert.equal(resolvedConfig?.port, 4999);
147− assert.equal(resolvedConfig?.host, "100.88.10.4");
148− assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home");
149− assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-home/dev");
150− assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/");
151− assert.equal(resolvedConfig?.noBrowser, true);
152− assert.equal(resolvedConfig?.authToken, "env-token");
153− assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
154− assert.equal(resolvedConfig?.logWebSocketEvents, true);
155− assert.equal(findAvailablePort.mock.calls.length, 0);
156− }),
157− );
158−
159− const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) {
160− const fs = yield* FileSystem.FileSystem;
161− const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
162− yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`);
163− const { fd } = yield* fs.open(filePath, { flag: "r" });
164− return fd;
165− });
166−
167− it.effect("recognizes bootstrap fd from environment config", () =>
168− Effect.gen(function* () {
169− const fd = yield* openBootstrapFd({ authToken: "bootstrap-token" });
170−
171− yield* runCli([], {
172− T3CODE_MODE: "web",
173− T3CODE_BOOTSTRAP_FD: String(fd),
174− T3CODE_AUTH_TOKEN: "env-token",
175− T3CODE_NO_BROWSER: "true",
176− });
177−
178− assert.equal(start.mock.calls.length, 1);
179− assert.equal(resolvedConfig?.mode, "web");
180− assert.equal(resolvedConfig?.authToken, "env-token");
181− }),
182− );
183−
184− it.effect("uses bootstrap envelope values as fallbacks when CLI and env are absent", () =>
185− Effect.gen(function* () {
186− const fd = yield* openBootstrapFd({
187− mode: "desktop",
188− port: 4888,
189− host: "127.0.0.2",
190− t3Home: "/tmp/t3-bootstrap-home",
191− devUrl: "http://127.0.0.1:5173",
192− noBrowser: true,
193− authToken: "bootstrap-token",
194− autoBootstrapProjectFromCwd: false,
195− logWebSocketEvents: true,
196− });
197−
198− yield* runCli([], {
199− T3CODE_BOOTSTRAP_FD: String(fd),
200− });
201−
202− assert.equal(start.mock.calls.length, 1);
203− assert.equal(resolvedConfig?.mode, "desktop");
204− assert.equal(resolvedConfig?.port, 4888);
205− assert.equal(resolvedConfig?.host, "127.0.0.2");
206− assert.equal(resolvedConfig?.baseDir, "/tmp/t3-bootstrap-home");
207− assert.equal(resolvedConfig?.stateDir, "/tmp/t3-bootstrap-home/dev");
208− assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
209− assert.equal(resolvedConfig?.noBrowser, true);
210− assert.equal(resolvedConfig?.authToken, "bootstrap-token");
211− assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
212− assert.equal(resolvedConfig?.logWebSocketEvents, true);
213− }),
214− );
215−
216− it.effect("applies CLI then env precedence over bootstrap envelope values", () =>
217− Effect.gen(function* () {
218− const fd = yield* openBootstrapFd({
219− mode: "desktop",
220− port: 4888,
221− host: "127.0.0.2",
222− t3Home: "/tmp/t3-bootstrap-home",
223− devUrl: "http://127.0.0.1:5173",
224− noBrowser: false,
225− authToken: "bootstrap-token",
226− autoBootstrapProjectFromCwd: false,
227− logWebSocketEvents: false,
228− });
229−
230− yield* runCli(["--port", "4999", "--host", "0.0.0.0", "--auth-token", "cli-token"], {
231− T3CODE_MODE: "web",
232− T3CODE_BOOTSTRAP_FD: String(fd),
233− T3CODE_HOME: "/tmp/t3-env-home",
234− T3CODE_NO_BROWSER: "true",
235− T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
236− T3CODE_LOG_WS_EVENTS: "true",
237− });
238−
239− assert.equal(start.mock.calls.length, 1);
240− assert.equal(resolvedConfig?.mode, "web");
241− assert.equal(resolvedConfig?.port, 4999);
242− assert.equal(resolvedConfig?.host, "0.0.0.0");
243− assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home");
244− assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
245− assert.equal(resolvedConfig?.noBrowser, true);
246− assert.equal(resolvedConfig?.authToken, "cli-token");
247− assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true);
248− assert.equal(resolvedConfig?.logWebSocketEvents, true);
249− }),
250− );
251−
252− it.effect("prefers --mode over T3CODE_MODE", () =>
253− Effect.gen(function* () {
254− findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666));
255− yield* runCli(["--mode", "web"], {
256− T3CODE_MODE: "desktop",
257− T3CODE_NO_BROWSER: "true",
258− });
259−
260− assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
261− assert.equal(start.mock.calls.length, 1);
262− assert.equal(resolvedConfig?.mode, "web");
263− assert.equal(resolvedConfig?.port, 4666);
264− assert.equal(resolvedConfig?.host, undefined);
265− }),
266− );
267−
268− it.effect("prefers --no-browser over T3CODE_NO_BROWSER", () =>
269− Effect.gen(function* () {
270− yield* runCli(["--no-browser"], {
271− T3CODE_NO_BROWSER: "false",
272− });
273−
274− assert.equal(start.mock.calls.length, 1);
275− assert.equal(resolvedConfig?.noBrowser, true);
276− }),
277− );
278−
279− it.effect("uses dynamic port discovery in web mode when port is omitted", () =>
280− Effect.gen(function* () {
281− findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444));
282− yield* runCli([]);
283−
284− assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
285− assert.equal(start.mock.calls.length, 1);
286− assert.equal(resolvedConfig?.port, 5444);
287− assert.equal(resolvedConfig?.mode, "web");
288− }),
289− );
290−
291− it.effect("uses fixed localhost defaults in desktop mode", () =>
292− Effect.gen(function* () {
293− yield* runCli([], {
294− T3CODE_MODE: "desktop",
295− T3CODE_NO_BROWSER: "true",
296− });
297−
298− assert.equal(findAvailablePort.mock.calls.length, 0);
299− assert.equal(start.mock.calls.length, 1);
300− assert.equal(resolvedConfig?.port, 3773);
301− assert.equal(resolvedConfig?.host, "127.0.0.1");
302− assert.equal(resolvedConfig?.mode, "desktop");
303− }),
304− );
305−
306− it.effect("allows overriding desktop host with --host", () =>
307− Effect.gen(function* () {
308− yield* runCli(["--host", "0.0.0.0"], {
309− T3CODE_MODE: "desktop",
310− T3CODE_NO_BROWSER: "true",
311− });
312−
313− assert.equal(start.mock.calls.length, 1);
314− assert.equal(resolvedConfig?.mode, "desktop");
315− assert.equal(resolvedConfig?.host, "0.0.0.0");
316− }),
317− );
318−
319− it.effect("supports CLI and env for bootstrap/log websocket toggles", () =>
320− Effect.gen(function* () {
321− yield* runCli(["--auto-bootstrap-project-from-cwd"], {
322− T3CODE_MODE: "desktop",
323− T3CODE_LOG_WS_EVENTS: "false",
324− T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
325− T3CODE_NO_BROWSER: "true",
326− });
327−
328− assert.equal(start.mock.calls.length, 1);
329− assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true);
330− assert.equal(resolvedConfig?.logWebSocketEvents, false);
331− }),
332− );
333−
334− it.effect("hydrates PATH before server startup", () =>
335− Effect.gen(function* () {
336− yield* runCli([]);
337−
338− assert.equal(fixPath.mock.calls.length, 1);
339− assert.equal(start.mock.calls.length, 1);
340− const fixPathOrder = fixPath.mock.invocationCallOrder[0];
341− const startOrder = start.mock.invocationCallOrder[0];
342− if (typeof fixPathOrder !== "number" || typeof startOrder !== "number") {
343− assert.fail("Expected fixPath and start to be called");
344− }
345− assert.isTrue(fixPathOrder < startOrder);
346− }),
347− );
348−
349− it.effect("records a startup heartbeat with thread/project counts", () =>
350− Effect.gen(function* () {
351− const recordTelemetry = vi.fn(
352− (_event: string, _properties?: Readonly<Record<string, unknown>>) => Effect.void,
353− );
354− const getSnapshot = vi.fn(() =>
355− Effect.succeed({
356− snapshotSequence: 2,
357− projects: [{} as OrchestrationReadModel["projects"][number]],
358− threads: [
359− {} as OrchestrationReadModel["threads"][number],
360− {} as OrchestrationReadModel["threads"][number],
361− ],
362− updatedAt: new Date(1).toISOString(),
363− } satisfies OrchestrationReadModel),
364− );
365−
366− yield* recordStartupHeartbeat.pipe(
367− Effect.provideService(ProjectionSnapshotQuery, {
368− getSnapshot,
369− }),
370− Effect.provideService(AnalyticsService, {
371− record: recordTelemetry,
372− flush: Effect.void,
373− }),
374− );
375−
376− assert.deepEqual(recordTelemetry.mock.calls[0], [
377− "server.boot.heartbeat",
378− {
379− threadCount: 2,
380− projectCount: 1,
381− },
382− ]);
383− }),
384− );
385−
386− it.effect("does not start server for invalid --mode values", () =>
387− Effect.gen(function* () {
388− yield* runCli(["--mode", "invalid"]).pipe(Effect.catch(() => Effect.void));
389−
390− assert.equal(start.mock.calls.length, 0);
391− assert.equal(stop.mock.calls.length, 0);
392− }),
393− );
394−
395− it.effect("does not start server for invalid --dev-url values", () =>
396− Effect.gen(function* () {
397− yield* runCli(["--dev-url", "not-a-url"]).pipe(Effect.catch(() => Effect.void));
398−
399− assert.equal(start.mock.calls.length, 0);
400− assert.equal(stop.mock.calls.length, 0);
401− }),
402− );
403−
404− it.effect("does not start server for out-of-range --port values", () =>
405− Effect.gen(function* () {
406− yield* runCli(["--port", "70000"]).pipe(Effect.catch(() => Effect.void));
407−
408− // effect/unstable/cli renders help/errors for parse failures and returns success.
409− assert.equal(start.mock.calls.length, 0);
410− assert.equal(stop.mock.calls.length, 0);
411− }),
412− );
413−});
apps/server/src/main.ts+0−456
@@ -1,456 +0,0 @@
1−/**
2− * CliConfig - CLI/runtime bootstrap service definitions.
3− *
4− * Defines startup-only service contracts used while resolving process config
5− * and constructing server runtime layers.
6− *
7− * @module CliConfig
8− */
9−import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect";
10−import { Command, Flag } from "effect/unstable/cli";
11−import { NetService } from "@t3tools/shared/Net";
12−import {
13− DEFAULT_PORT,
14− deriveServerPaths,
15− resolveStaticDir,
16− ServerConfig,
17− type RuntimeMode,
18− type ServerConfigShape,
19−} from "./config";
20−import { fixPath, resolveBaseDir } from "./os-jank";
21−import { Open } from "./open";
22−import * as SqlitePersistence from "./persistence/Layers/Sqlite";
23−import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
24−import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
25−import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";
26−import { Server } from "./wsServer";
27−import { ServerLoggerLive } from "./serverLogger";
28−import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
29−import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
30−import { readBootstrapEnvelope } from "./bootstrap";
31−import { ServerSettingsLive } from "./serverSettings";
32−
33−export class StartupError extends Data.TaggedError("StartupError")<{
34− readonly message: string;
35− readonly cause?: unknown;
36−}> {}
37−
38−const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
39−
40−const BootstrapEnvelopeSchema = Schema.Struct({
41− mode: Schema.optional(Schema.String),
42− port: Schema.optional(PortSchema),
43− host: Schema.optional(Schema.String),
44− t3Home: Schema.optional(Schema.String),
45− devUrl: Schema.optional(Schema.URLFromString),
46− noBrowser: Schema.optional(Schema.Boolean),
47− authToken: Schema.optional(Schema.String),
48− autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
49− logWebSocketEvents: Schema.optional(Schema.Boolean),
50−});
51−
52−interface CliInput {
53− readonly mode: Option.Option<RuntimeMode>;
54− readonly port: Option.Option<number>;
55− readonly host: Option.Option<string>;
56− readonly t3Home: Option.Option<string>;
57− readonly devUrl: Option.Option<URL>;
58− readonly noBrowser: Option.Option<boolean>;
59− readonly authToken: Option.Option<string>;
60− readonly bootstrapFd: Option.Option<number>;
61− readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
62− readonly logWebSocketEvents: Option.Option<boolean>;
63−}
64−
65−/**
66− * CliConfigShape - Startup helpers required while building server layers.
67− */
68−export interface CliConfigShape {
69− /**
70− * Current process working directory.
71− */
72− readonly cwd: string;
73−
74− /**
75− * Apply OS-specific PATH normalization.
76− */
77− readonly fixPath: Effect.Effect<void>;
78−
79− /**
80− * Resolve static web asset directory for server mode.
81− */
82− readonly resolveStaticDir: Effect.Effect<string | undefined>;
83−}
84−
85−/**
86− * CliConfig - Service tag for startup CLI/runtime helpers.
87− */
88−export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()(
89− "t3/main/CliConfig",
90−) {
91− static readonly layer = Layer.effect(
92− CliConfig,
93− Effect.gen(function* () {
94− const fileSystem = yield* FileSystem.FileSystem;
95− const path = yield* Path.Path;
96− return {
97− cwd: process.cwd(),
98− fixPath: Effect.sync(fixPath),
99− resolveStaticDir: resolveStaticDir().pipe(
100− Effect.provideService(FileSystem.FileSystem, fileSystem),
101− Effect.provideService(Path.Path, path),
102− ),
103− } satisfies CliConfigShape;
104− }),
105− );
106−}
107−
108−const CliEnvConfig = Config.all({
109− mode: Config.string("T3CODE_MODE").pipe(
110− Config.option,
111− Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))),
112− Config.map(Option.getOrUndefined),
113− ),
114− port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
115− host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
116− t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
117− devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
118− noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
119− Config.option,
120− Config.map(Option.getOrUndefined),
121− ),
122− authToken: Config.string("T3CODE_AUTH_TOKEN").pipe(
123− Config.option,
124− Config.map(Option.getOrUndefined),
125− ),
126− bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(
127− Config.option,
128− Config.map(Option.getOrUndefined),
129− ),
130− autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
131− Config.option,
132− Config.map(Option.getOrUndefined),
133− ),
134− logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe(
135− Config.option,
136− Config.map(Option.getOrUndefined),
137− ),
138−});
139−
140−const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
141− Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
142−
143−const resolveOptionPrecedence = <Value>(
144− ...values: ReadonlyArray<Option.Option<Value>>
145−): Option.Option<Value> => Option.firstSomeOf(values);
146−
147−const isValidPort = (value: number): boolean => value >= 1 && value <= 65_535;
148−const isRuntimeMode = (value: string): value is RuntimeMode =>
149− value === "web" || value === "desktop";
150−
151−const ServerConfigLive = (input: CliInput) =>
152− Layer.effect(
153− ServerConfig,
154− Effect.gen(function* () {
155− const cliConfig = yield* CliConfig;
156− const { findAvailablePort } = yield* NetService;
157− const env = yield* CliEnvConfig.asEffect().pipe(
158− Effect.mapError(
159− (cause) =>
160− new StartupError({ message: "Failed to read environment configuration", cause }),
161− ),
162− );
163−
164− const bootstrapFd = Option.getOrUndefined(input.bootstrapFd) ?? env.bootstrapFd;
165− const bootstrapEnvelope =
166− bootstrapFd !== undefined
167− ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
168− : Option.none();
169−
170− const mode: RuntimeMode = Option.getOrElse(
171− resolveOptionPrecedence(
172− input.mode,
173− Option.fromUndefinedOr(env.mode),
174− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
175− Option.filter(Option.fromUndefinedOr(bootstrap.mode), isRuntimeMode),
176− ),
177− ),
178− () => "web",
179− );
180− const port = yield* Option.match(
181− resolveOptionPrecedence(
182− input.port,
183− Option.fromUndefinedOr(env.port),
184− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
185− Option.filter(Option.fromUndefinedOr(bootstrap.port), isValidPort),
186− ),
187− ),
188− {
189− onSome: (value) => Effect.succeed(value),
190− onNone: () => {
191− if (mode === "desktop") {
192− return Effect.succeed(DEFAULT_PORT);
193− }
194− return findAvailablePort(DEFAULT_PORT);
195− },
196− },
197− );
198−
199− const devUrl = Option.getOrElse(
200− resolveOptionPrecedence(
201− input.devUrl,
202− Option.fromUndefinedOr(env.devUrl),
203− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
204− Option.fromUndefinedOr(bootstrap.devUrl),
205− ),
206− ),
207− () => undefined,
208− );
209− const baseDir = yield* resolveBaseDir(
210− Option.getOrUndefined(
211− resolveOptionPrecedence(
212− input.t3Home,
213− Option.fromUndefinedOr(env.t3Home),
214− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
215− Option.fromUndefinedOr(bootstrap.t3Home),
216− ),
217− ),
218− ),
219− );
220− const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
221− const noBrowser = resolveBooleanFlag(
222− input.noBrowser,
223− Option.getOrElse(
224− resolveOptionPrecedence(
225− Option.fromUndefinedOr(env.noBrowser),
226− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
227− Option.fromUndefinedOr(bootstrap.noBrowser),
228− ),
229− ),
230− () => mode === "desktop",
231− ),
232− );
233− const authToken = resolveOptionPrecedence(
234− input.authToken,
235− Option.fromUndefinedOr(env.authToken),
236− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
237− Option.fromUndefinedOr(bootstrap.authToken),
238− ),
239− );
240− const autoBootstrapProjectFromCwd = resolveBooleanFlag(
241− input.autoBootstrapProjectFromCwd,
242− Option.getOrElse(
243− resolveOptionPrecedence(
244− Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
245− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
246− Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd),
247− ),
248− ),
249− () => mode === "web",
250− ),
251− );
252− const logWebSocketEvents = resolveBooleanFlag(
253− input.logWebSocketEvents,
254− Option.getOrElse(
255− resolveOptionPrecedence(
256− Option.fromUndefinedOr(env.logWebSocketEvents),
257− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
258− Option.fromUndefinedOr(bootstrap.logWebSocketEvents),
259− ),
260− ),
261− () => Boolean(devUrl),
262− ),
263− );
264− const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
265− const host = Option.getOrElse(
266− resolveOptionPrecedence(
267− input.host,
268− Option.fromUndefinedOr(env.host),
269− Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)),
270− ),
271− () => (mode === "desktop" ? "127.0.0.1" : undefined),
272− );
273−
274− const config: ServerConfigShape = {
275− mode,
276− port,
277− cwd: cliConfig.cwd,
278− host,
279− baseDir,
280− ...derivedPaths,
281− staticDir,
282− devUrl,
283− noBrowser,
284− authToken: Option.getOrUndefined(authToken),
285− autoBootstrapProjectFromCwd,
286− logWebSocketEvents,
287− } satisfies ServerConfigShape;
288−
289− return config;
290− }),
291− );
292−
293−const LayerLive = (input: CliInput) =>
294− Layer.empty.pipe(
295− Layer.provideMerge(makeServerRuntimeServicesLayer()),
296− Layer.provideMerge(makeServerProviderLayer()),
297− Layer.provideMerge(ProviderRegistryLive),
298− Layer.provideMerge(SqlitePersistence.layerConfig),
299− Layer.provideMerge(ServerLoggerLive),
300− Layer.provideMerge(AnalyticsServiceLayerLive),
301− Layer.provideMerge(ServerSettingsLive),
302− Layer.provideMerge(ServerConfigLive(input)),
303− );
304−
305−const isWildcardHost = (host: string | undefined): boolean =>
306− host === "0.0.0.0" || host === "::" || host === "[::]";
307−
308−const formatHostForUrl = (host: string): string =>
309− host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
310−
311−export const recordStartupHeartbeat = Effect.gen(function* () {
312− const analytics = yield* AnalyticsService;
313− const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
314−
315− const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe(
316− Effect.map((snapshot) => ({
317− threadCount: snapshot.threads.length,
318− projectCount: snapshot.projects.length,
319− })),
320− Effect.catch((cause) =>
321− Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe(
322− Effect.as({
323− threadCount: 0,
324− projectCount: 0,
325− }),
326− ),
327− ),
328− );
329−
330− yield* analytics.record("server.boot.heartbeat", {
331− threadCount,
332− projectCount,
333− });
334−});
335−
336−const makeServerRuntimeProgram = (input: CliInput) =>
337− Effect.gen(function* () {
338− const { start, stopSignal } = yield* Server;
339− const openDeps = yield* Open;
340−
341− const config = yield* ServerConfig;
342−
343− if (!config.devUrl && !config.staticDir) {
344− yield* Effect.logWarning(
345− "web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable",
346− {
347− hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.",
348− },
349− );
350− }
351−
352− yield* start;
353− yield* Effect.forkChild(recordStartupHeartbeat);
354−
355− const localUrl = `http://localhost:${config.port}`;
356− const bindUrl =
357− config.host && !isWildcardHost(config.host)
358− ? `http://${formatHostForUrl(config.host)}:${config.port}`
359− : localUrl;
360− const { authToken, devUrl, ...safeConfig } = config;
361− yield* Effect.logInfo("T3 Code running", {
362− ...safeConfig,
363− devUrl: devUrl?.toString(),
364− authEnabled: Boolean(authToken),
365− });
366−
367− if (!config.noBrowser) {
368− const target = config.devUrl?.toString() ?? bindUrl;
369− yield* openDeps.openBrowser(target).pipe(
370− Effect.catch(() =>
371− Effect.logInfo("browser auto-open unavailable", {
372− hint: `Open ${target} in your browser.`,
373− }),
374− ),
375− );
376− }
377−
378− return yield* stopSignal;
379− }).pipe(Effect.provide(LayerLive(input)));
380−
381−const makeServerProgram = (input: CliInput) =>
382− Effect.gen(function* () {
383− const cliConfig = yield* CliConfig;
384− yield* cliConfig.fixPath;
385− return yield* makeServerRuntimeProgram(input);
386− });
387−
388−/**
389− * These flags mirrors the environment variables and the config shape.
390− */
391−
392−const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe(
393− Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
394− Flag.optional,
395−);
396−const portFlag = Flag.integer("port").pipe(
397− Flag.withSchema(PortSchema),
398− Flag.withDescription("Port for the HTTP/WebSocket server."),
399− Flag.optional,
400−);
401−const hostFlag = Flag.string("host").pipe(
402− Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),
403− Flag.optional,
404−);
405−const t3HomeFlag = Flag.string("home-dir").pipe(
406− Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."),
407− Flag.optional,
408−);
409−const devUrlFlag = Flag.string("dev-url").pipe(
410− Flag.withSchema(Schema.URLFromString),
411− Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."),
412− Flag.optional,
413−);
414−const noBrowserFlag = Flag.boolean("no-browser").pipe(
415− Flag.withDescription("Disable automatic browser opening."),
416− Flag.optional,
417−);
418−const authTokenFlag = Flag.string("auth-token").pipe(
419− Flag.withDescription("Auth token required for WebSocket connections."),
420− Flag.withAlias("token"),
421− Flag.optional,
422−);
423−const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(
424− Flag.withSchema(Schema.Int),
425− Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),
426− Flag.optional,
427−);
428−const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
429− Flag.withDescription(
430− "Create a project for the current working directory on startup when missing.",
431− ),
432− Flag.optional,
433−);
434−const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(
435− Flag.withDescription(
436− "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).",
437− ),
438− Flag.withAlias("log-ws-events"),
439− Flag.optional,
440−);
441−
442−export const t3Cli = Command.make("t3", {
443− mode: modeFlag,
444− port: portFlag,
445− host: hostFlag,
446− t3Home: t3HomeFlag,
447− devUrl: devUrlFlag,
448− noBrowser: noBrowserFlag,
449− authToken: authTokenFlag,
450− bootstrapFd: bootstrapFdFlag,
451− autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
452− logWebSocketEvents: logWebSocketEventsFlag,
453−}).pipe(
454− Command.withDescription("Run the T3 Code server."),
455− Command.withHandler((input) => Effect.scoped(makeServerProgram(input))),
456−);
apps/server/src/open.ts+3−6
@@ -10,17 +10,14 @@ import { spawn } from "node:child_process"; · 9 unmodified lines
1010 import { accessSync, constants, statSync } from "node:fs";
1111 import { extname, join } from "node:path";
1212
13−import { EDITORS, type EditorId } from "@t3tools/contracts";
14−import { ServiceMap, Schema, Effect, Layer } from "effect";
13+import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";
14+import { ServiceMap, Effect, Layer } from "effect";
1515
1616 // ==============================
1717 // Definitions
1818 // ==============================
1919
20−export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
21− message: Schema.String,
22− cause: Schema.optional(Schema.Defect),
23−}) {}
20+export { OpenError };
2421
2522 export interface OpenInEditorInput {
2623 readonly cwd: string;
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+7−16
@@ -39,6 +39,7 @@ import { · 38 unmodified lines
3939 import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
4040 import { ServerConfig } from "../../config.ts";
4141 import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts";
42+import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";
4243
4344 const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
4445 const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
@@ -115,7 +116,7 @@ async function waitForThread( · 70 unmodified lines
115116 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
116117 activities: ReadonlyArray<{ kind: string }>;
117118 }) => boolean,
118− timeoutMs = 5000,
119+ timeoutMs = 15_000,
119120 ) {
120121 const deadline = Date.now() + timeoutMs;
121122 const poll = async (): Promise<{
@@ -140,7 +141,7 @@ async function waitForThread( · 18 unmodified lines
140141 async function waitForEvent(
141142 engine: OrchestrationEngineShape,
142143 predicate: (event: { type: string }) => boolean,
143− timeoutMs = 5000,
144+ timeoutMs = 15_000,
144145 ) {
145146 const deadline = Date.now() + timeoutMs;
146147 const poll = async () => {
@@ -191,7 +192,7 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { · 44 unmodified lines
191192 return runGit(cwd, ["show", `${ref}:${filePath}`]);
192193 }
193194
194−async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 5000) {
195+async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) {
195196 const deadline = Date.now() + timeoutMs;
196197 const poll = async (): Promise<void> => {
197198 if (gitRefExists(cwd, ref)) {
@@ -263,7 +264,8 @@ describe("CheckpointReactor", () => { · 65 unmodified lines
263264 Layer.provideMerge(RuntimeReceiptBusLive),
264265 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
265266 Layer.provideMerge(CheckpointStoreLive),
266− Layer.provideMerge(WorkspaceEntriesLive),
267+ Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
268+ Layer.provideMerge(WorkspacePathsLive),
267269 Layer.provideMerge(GitCoreLive),
268270 Layer.provideMerge(ServerConfigLayer),
269271 Layer.provideMerge(NodeServices.layer),
@@ -1007,18 +1009,7 @@ describe("CheckpointReactor", () => { · 737 unmodified lines
10071009 }),
10081010 );
10091011
1010− const deadline = Date.now() + 2000;
1011− const waitForRollbackCalls = async (): Promise<void> => {
1012− if (harness.provider.rollbackConversation.mock.calls.length >= 2) {
1013− return;
1014− }
1015− if (Date.now() >= deadline) {
1016− throw new Error("Timed out waiting for rollbackConversation calls.");
1017− }
1018− await new Promise((resolve) => setTimeout(resolve, 10));
1019− return waitForRollbackCalls();
1020− };
1021− await waitForRollbackCalls();
1012+ await harness.drain();
10221013
10231014 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2);
10241015 expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+1−1
@@ -205,7 +205,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 204 unmodified lines
205205
206206 const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));
207207 yield* Effect.forkScoped(worker);
208− yield* Effect.log("orchestration engine started").pipe(
208+ yield* Effect.logDebug("orchestration engine started").pipe(
209209 Effect.annotateLogs({ sequence: readModel.snapshotSequence }),
210210 );
211211
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+1−3
@@ -3,7 +3,6 @@ import { · 2 unmodified lines
33 type ChatAttachment,
44 type OrchestrationEvent,
55 } from "@t3tools/contracts";
6−import * as NodeServices from "@effect/platform-node/NodeServices";
76 import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";
87 import * as SqlClient from "effect/unstable/sql/SqlClient";
98
@@ -1271,7 +1270,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 1261 unmodified lines
12711270 Effect.provideService(ServerConfig, serverConfig),
12721271 Effect.asVoid,
12731272 Effect.tap(() =>
1274− Effect.log("orchestration projection pipeline bootstrapped").pipe(
1273+ Effect.logDebug("orchestration projection pipeline bootstrapped").pipe(
12751274 Effect.annotateLogs({ projectors: projectors.length }),
12761275 ),
12771276 ),
@@ -1291,7 +1290,6 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( · 13 unmodified lines
12911290 OrchestrationProjectionPipeline,
12921291 makeOrchestrationProjectionPipeline(),
12931292 ).pipe(
1294− Layer.provideMerge(NodeServices.layer),
12951293 Layer.provideMerge(ProjectionProjectRepositoryLive),
12961294 Layer.provideMerge(ProjectionThreadRepositoryLive),
12971295 Layer.provideMerge(ProjectionThreadMessageRepositoryLive),
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1−1
@@ -17,7 +17,7 @@ import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effe · 16 unmodified lines
1717 import { afterEach, describe, expect, it, vi } from "vitest";
1818
1919 import { deriveServerPaths, ServerConfig } from "../../config.ts";
20−import { TextGenerationError } from "../../git/Errors.ts";
20+import { TextGenerationError } from "@t3tools/contracts";
2121 import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
2222 import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
2323 import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
apps/server/src/orchestration/Normalizer.ts+121−0
@@ -0,0 +1,121 @@
1+import { Effect, FileSystem, Path } from "effect";
2+import {
3+ type ClientOrchestrationCommand,
4+ type OrchestrationCommand,
5+ OrchestrationDispatchCommandError,
6+ PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
7+} from "@t3tools/contracts";
8+
9+import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore";
10+import { ServerConfig } from "../config";
11+import { parseBase64DataUrl } from "../imageMime";
12+import { WorkspacePaths } from "../workspace/Services/WorkspacePaths";
13+
14+export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
15+ Effect.gen(function* () {
16+ const fileSystem = yield* FileSystem.FileSystem;
17+ const path = yield* Path.Path;
18+ const serverConfig = yield* ServerConfig;
19+ const workspacePaths = yield* WorkspacePaths;
20+
21+ const normalizeProjectWorkspaceRoot = (workspaceRoot: string) =>
22+ workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(
23+ Effect.mapError(
24+ (cause) =>
25+ new OrchestrationDispatchCommandError({
26+ message: cause.message,
27+ }),
28+ ),
29+ );
30+
31+ if (command.type === "project.create") {
32+ return {
33+ ...command,
34+ workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot),
35+ } satisfies OrchestrationCommand;
36+ }
37+
38+ if (command.type === "project.meta.update" && command.workspaceRoot !== undefined) {
39+ return {
40+ ...command,
41+ workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot),
42+ } satisfies OrchestrationCommand;
43+ }
44+
45+ if (command.type !== "thread.turn.start") {
46+ return command as OrchestrationCommand;
47+ }
48+
49+ const normalizedAttachments = yield* Effect.forEach(
50+ command.message.attachments,
51+ (attachment) =>
52+ Effect.gen(function* () {
53+ const parsed = parseBase64DataUrl(attachment.dataUrl);
54+ if (!parsed || !parsed.mimeType.startsWith("image/")) {
55+ return yield* new OrchestrationDispatchCommandError({
56+ message: `Invalid image attachment payload for '${attachment.name}'.`,
57+ });
58+ }
59+
60+ const bytes = Buffer.from(parsed.base64, "base64");
61+ if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
62+ return yield* new OrchestrationDispatchCommandError({
63+ message: `Image attachment '${attachment.name}' is empty or too large.`,
64+ });
65+ }
66+
67+ const attachmentId = createAttachmentId(command.threadId);
68+ if (!attachmentId) {
69+ return yield* new OrchestrationDispatchCommandError({
70+ message: "Failed to create a safe attachment id.",
71+ });
72+ }
73+
74+ const persistedAttachment = {
75+ type: "image" as const,
76+ id: attachmentId,
77+ name: attachment.name,
78+ mimeType: parsed.mimeType.toLowerCase(),
79+ sizeBytes: bytes.byteLength,
80+ };
81+
82+ const attachmentPath = resolveAttachmentPath({
83+ attachmentsDir: serverConfig.attachmentsDir,
84+ attachment: persistedAttachment,
85+ });
86+ if (!attachmentPath) {
87+ return yield* new OrchestrationDispatchCommandError({
88+ message: `Failed to resolve persisted path for '${attachment.name}'.`,
89+ });
90+ }
91+
92+ yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe(
93+ Effect.mapError(
94+ () =>
95+ new OrchestrationDispatchCommandError({
96+ message: `Failed to create attachment directory for '${attachment.name}'.`,
97+ }),
98+ ),
99+ );
100+ yield* fileSystem.writeFile(attachmentPath, bytes).pipe(
101+ Effect.mapError(
102+ () =>
103+ new OrchestrationDispatchCommandError({
104+ message: `Failed to persist attachment '${attachment.name}'.`,
105+ }),
106+ ),
107+ );
108+
109+ return persistedAttachment;
110+ }),
111+ { concurrency: 1 },
112+ );
113+
114+ return {
115+ ...command,
116+ message: {
117+ ...command.message,
118+ attachments: normalizedAttachments,
119+ },
120+ } satisfies OrchestrationCommand;
121+ });
apps/server/src/projectFaviconRoute.test.ts+0−189
@@ -1,189 +0,0 @@
1−import fs from "node:fs";
2−import http from "node:http";
3−import os from "node:os";
4−import path from "node:path";
5−
6−import * as NodeServices from "@effect/platform-node/NodeServices";
7−import { Effect } from "effect";
8−import { afterEach, describe, expect, it } from "vitest";
9−
10−import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
11−import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
12−
13−interface HttpResponse {
14− statusCode: number;
15− contentType: string | null;
16− body: string;
17−}
18−
19−const tempDirs: string[] = [];
20−
21−function makeTempDir(prefix: string): string {
22− const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
23− tempDirs.push(dir);
24− return dir;
25−}
26−
27−async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise<void> {
28− const server = http.createServer((req, res) => {
29− const url = new URL(req.url ?? "/", "http://127.0.0.1");
30− void Effect.runPromise(
31− tryHandleProjectFaviconRequest(url, res).pipe(
32− Effect.provide(ProjectFaviconResolverLive),
33− Effect.provide(NodeServices.layer),
34− Effect.flatMap((handled) =>
35− handled
36− ? Effect.void
37− : Effect.sync(() => {
38− res.writeHead(404, { "Content-Type": "text/plain" });
39− res.end("Not Found");
40− }),
41− ),
42− ),
43− ).catch(() => {
44− if (!res.headersSent) {
45− res.writeHead(500, { "Content-Type": "text/plain" });
46− res.end("Internal Server Error");
47− }
48− });
49− });
50−
51− await new Promise<void>((resolve, reject) => {
52− server.listen(0, "127.0.0.1", (error?: Error) => {
53− if (error) {
54− reject(error);
55− return;
56− }
57− resolve();
58− });
59− });
60−
61− const address = server.address();
62− if (typeof address !== "object" || address === null) {
63− throw new Error("Expected server address to be an object");
64− }
65− const baseUrl = `http://127.0.0.1:${address.port}`;
66−
67− try {
68− await run(baseUrl);
69− } finally {
70− await new Promise<void>((resolve, reject) => {
71− server.close((error?: Error) => {
72− if (error) {
73− reject(error);
74− return;
75− }
76− resolve();
77− });
78− });
79− }
80−}
81−
82−async function request(baseUrl: string, pathname: string): Promise<HttpResponse> {
83− const response = await fetch(`${baseUrl}${pathname}`);
84− return {
85− statusCode: response.status,
86− contentType: response.headers.get("content-type"),
87− body: await response.text(),
88− };
89−}
90−
91−describe("tryHandleProjectFaviconRequest", () => {
92− afterEach(() => {
93− for (const dir of tempDirs.splice(0, tempDirs.length)) {
94− fs.rmSync(dir, { recursive: true, force: true });
95− }
96− });
97−
98− it("returns 400 when cwd is missing", async () => {
99− await withRouteServer(async (baseUrl) => {
100− const response = await request(baseUrl, "/api/project-favicon");
101− expect(response.statusCode).toBe(400);
102− expect(response.body).toBe("Missing cwd parameter");
103− });
104− });
105−
106− it("serves a well-known favicon file from the project root", async () => {
107− const projectDir = makeTempDir("t3code-favicon-route-root-");
108− fs.writeFileSync(path.join(projectDir, "favicon.svg"), "<svg>favicon</svg>", "utf8");
109−
110− await withRouteServer(async (baseUrl) => {
111− const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
112− const response = await request(baseUrl, pathname);
113− expect(response.statusCode).toBe(200);
114− expect(response.contentType).toContain("image/svg+xml");
115− expect(response.body).toBe("<svg>favicon</svg>");
116− });
117− });
118−
119− it("resolves icon href from source files when no well-known favicon exists", async () => {
120− const projectDir = makeTempDir("t3code-favicon-route-source-");
121− const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
122− fs.mkdirSync(path.dirname(iconPath), { recursive: true });
123− fs.writeFileSync(
124− path.join(projectDir, "index.html"),
125− '<link rel="icon" href="/brand/logo.svg">',
126− );
127− fs.writeFileSync(iconPath, "<svg>brand</svg>", "utf8");
128−
129− await withRouteServer(async (baseUrl) => {
130− const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
131− const response = await request(baseUrl, pathname);
132− expect(response.statusCode).toBe(200);
133− expect(response.contentType).toContain("image/svg+xml");
134− expect(response.body).toBe("<svg>brand</svg>");
135− });
136− });
137−
138− it("resolves icon link when href appears before rel in HTML", async () => {
139− const projectDir = makeTempDir("t3code-favicon-route-html-order-");
140− const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
141− fs.mkdirSync(path.dirname(iconPath), { recursive: true });
142− fs.writeFileSync(
143− path.join(projectDir, "index.html"),
144− '<link href="/brand/logo.svg" rel="icon">',
145− );
146− fs.writeFileSync(iconPath, "<svg>brand-html-order</svg>", "utf8");
147−
148− await withRouteServer(async (baseUrl) => {
149− const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
150− const response = await request(baseUrl, pathname);
151− expect(response.statusCode).toBe(200);
152− expect(response.contentType).toContain("image/svg+xml");
153− expect(response.body).toBe("<svg>brand-html-order</svg>");
154− });
155− });
156−
157− it("resolves object-style icon metadata when href appears before rel", async () => {
158− const projectDir = makeTempDir("t3code-favicon-route-obj-order-");
159− const iconPath = path.join(projectDir, "public", "brand", "obj.svg");
160− fs.mkdirSync(path.dirname(iconPath), { recursive: true });
161− fs.mkdirSync(path.join(projectDir, "src"), { recursive: true });
162− fs.writeFileSync(
163− path.join(projectDir, "src", "root.tsx"),
164− 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];',
165− "utf8",
166− );
167− fs.writeFileSync(iconPath, "<svg>brand-obj-order</svg>", "utf8");
168−
169− await withRouteServer(async (baseUrl) => {
170− const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
171− const response = await request(baseUrl, pathname);
172− expect(response.statusCode).toBe(200);
173− expect(response.contentType).toContain("image/svg+xml");
174− expect(response.body).toBe("<svg>brand-obj-order</svg>");
175− });
176− });
177−
178− it("serves a fallback favicon when no icon exists", async () => {
179− const projectDir = makeTempDir("t3code-favicon-route-fallback-");
180−
181− await withRouteServer(async (baseUrl) => {
182− const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`;
183− const response = await request(baseUrl, pathname);
184− expect(response.statusCode).toBe(200);
185− expect(response.contentType).toContain("image/svg+xml");
186− expect(response.body).toContain('data-fallback="project-favicon"');
187− });
188− });
189−});
apps/server/src/projectFaviconRoute.ts+0−76
@@ -1,76 +0,0 @@
1−import http from "node:http";
2−import path from "node:path";
3−import { Effect, FileSystem } from "effect";
4−
5−import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
6−
7−const FAVICON_MIME_TYPES: Record<string, string> = {
8− ".png": "image/png",
9− ".jpg": "image/jpeg",
10− ".svg": "image/svg+xml",
11− ".ico": "image/x-icon",
12−};
13−
14−const FALLBACK_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
15−
16−export const tryHandleProjectFaviconRequest = Effect.fn("tryHandleProjectFaviconRequest")(
17− function* (
18− url: URL,
19− res: http.ServerResponse,
20− ): Effect.fn.Return<boolean, never, ProjectFaviconResolver | FileSystem.FileSystem> {
21− const respond = (
22− statusCode: number,
23− headers: Record<string, string>,
24− body: string | Uint8Array,
25− ) => {
26− res.writeHead(statusCode, headers);
27− res.end(body);
28− };
29−
30− if (url.pathname !== "/api/project-favicon") {
31− return false;
32− }
33−
34− const projectCwd = url.searchParams.get("cwd");
35− if (!projectCwd) {
36− respond(400, { "Content-Type": "text/plain" }, "Missing cwd parameter");
37− return true;
38− }
39−
40− const fileSystem = yield* FileSystem.FileSystem;
41− const faviconResolver = yield* ProjectFaviconResolver;
42− const resolvedPath = yield* faviconResolver.resolvePath(projectCwd);
43−
44− if (!resolvedPath) {
45− respond(
46− 200,
47− {
48− "Content-Type": "image/svg+xml",
49− "Cache-Control": "public, max-age=3600",
50− },
51− FALLBACK_FAVICON_SVG,
52− );
53− return true;
54− }
55−
56− const data = yield* fileSystem
57− .readFile(resolvedPath)
58− .pipe(Effect.catch(() => Effect.succeed(null)));
59− if (!data) {
60− respond(500, { "Content-Type": "text/plain" }, "Read error");
61− return true;
62− }
63−
64− const ext = path.extname(resolvedPath).toLowerCase();
65− const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream";
66− respond(
67− 200,
68− {
69− "Content-Type": contentType,
70− "Cache-Control": "public, max-age=3600",
71− },
72− data,
73− );
74− return true;
75− },
76−);
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+3−0
@@ -208,6 +208,9 @@ async function readFirstPromptText( · 207 unmodified lines
208208 if (next.done) {
209209 return undefined;
210210 }
211+ if (typeof next.value.message.content === "string") {
212+ return next.value.message.content;
213+ }
211214 const content = next.value.message.content[0];
212215 if (!content || content.type !== "text") {
213216 return undefined;
apps/server/src/provider/Layers/ClaudeAdapter.ts+1−1
@@ -533,7 +533,7 @@ function buildUserMessage(input: { · 532 unmodified lines
533533 parent_tool_use_id: null,
534534 message: {
535535 role: "user",
536− content: input.sdkContent,
536+ content: input.sdkContent as unknown as SDKUserMessage["message"]["content"],
537537 },
538538 } as SDKUserMessage;
539539 }
apps/server/src/provider/Layers/ClaudeProvider.ts+2−1
@@ -24,7 +24,8 @@ import { · 23 unmodified lines
2424 } from "../providerSnapshot";
2525 import { makeManagedServerProvider } from "../makeManagedServerProvider";
2626 import { ClaudeProvider } from "../Services/ClaudeProvider";
27−import { ServerSettingsError, ServerSettingsService } from "../../serverSettings";
27+import { ServerSettingsService } from "../../serverSettings";
28+import { ServerSettingsError } from "@t3tools/contracts";
2829
2930 const PROVIDER = "claudeAgent" as const;
3031 const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
apps/server/src/provider/Layers/CodexProvider.ts+2−1
@@ -46,7 +46,8 @@ import { · 45 unmodified lines
4646 } from "../codexAccount";
4747 import { probeCodexAccount } from "../codexAppServer";
4848 import { CodexProvider } from "../Services/CodexProvider";
49−import { ServerSettingsError, ServerSettingsService } from "../../serverSettings";
49+import { ServerSettingsService } from "../../serverSettings";
50+import { ServerSettingsError } from "@t3tools/contracts";
5051
5152 const PROVIDER = "codex" as const;
5253 const OPENAI_AUTH_PROVIDERS = new Set(["openai"]);
apps/server/src/provider/makeManagedServerProvider.ts+1−1
@@ -3,7 +3,7 @@ import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; · 2 unmodified lines
33 import * as Semaphore from "effect/Semaphore";
44
55 import type { ServerProviderShape } from "./Services/ServerProvider";
6−import { ServerSettingsError } from "../serverSettings";
6+import { ServerSettingsError } from "@t3tools/contracts";
77
88 export function makeManagedServerProvider<Settings>(input: {
99 readonly getSettings: Effect.Effect<Settings>;
apps/server/src/server.test.ts+1391−0
@@ -0,0 +1,1391 @@
1+import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2+import * as NodeSocket from "@effect/platform-node/NodeSocket";
3+import * as NodeServices from "@effect/platform-node/NodeServices";
4+import {
5+ CommandId,
6+ DEFAULT_SERVER_SETTINGS,
7+ GitCommandError,
8+ KeybindingRule,
9+ OpenError,
10+ TerminalNotRunningError,
11+ type OrchestrationEvent,
12+ ORCHESTRATION_WS_METHODS,
13+ ProjectId,
14+ ResolvedKeybindingRule,
15+ ThreadId,
16+ WS_METHODS,
17+ WsRpcGroup,
18+ EditorId,
19+} from "@t3tools/contracts";
20+import { assert, it } from "@effect/vitest";
21+import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";
22+import { Effect, FileSystem, Layer, Path, Stream } from "effect";
23+import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
24+import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
25+
26+import type { ServerConfigShape } from "./config.ts";
27+import { deriveServerPaths, ServerConfig } from "./config.ts";
28+import { makeRoutesLayer } from "./server.ts";
29+import { resolveAttachmentRelativePath } from "./attachmentPaths.ts";
30+import {
31+ CheckpointDiffQuery,
32+ type CheckpointDiffQueryShape,
33+} from "./checkpointing/Services/CheckpointDiffQuery.ts";
34+import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts";
35+import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
36+import { Keybindings, type KeybindingsShape } from "./keybindings.ts";
37+import { Open, type OpenShape } from "./open.ts";
38+import {
39+ OrchestrationEngineService,
40+ type OrchestrationEngineShape,
41+} from "./orchestration/Services/OrchestrationEngine.ts";
42+import {
43+ ProjectionSnapshotQuery,
44+ type ProjectionSnapshotQueryShape,
45+} from "./orchestration/Services/ProjectionSnapshotQuery.ts";
46+import { PersistenceSqlError } from "./persistence/Errors.ts";
47+import {
48+ ProviderRegistry,
49+ type ProviderRegistryShape,
50+} from "./provider/Services/ProviderRegistry.ts";
51+import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts";
52+import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";
53+import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";
54+import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";
55+import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
56+import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
57+import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
58+import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
59+
60+const defaultProjectId = ProjectId.makeUnsafe("project-default");
61+const defaultThreadId = ThreadId.makeUnsafe("thread-default");
62+const defaultModelSelection = {
63+ provider: "codex",
64+ model: "gpt-5-codex",
65+} as const;
66+
67+const makeDefaultOrchestrationReadModel = () => {
68+ const now = new Date().toISOString();
69+ return {
70+ snapshotSequence: 0,
71+ updatedAt: now,
72+ projects: [
73+ {
74+ id: defaultProjectId,
75+ title: "Default Project",
76+ workspaceRoot: "/tmp/default-project",
77+ defaultModelSelection,
78+ scripts: [],
79+ createdAt: now,
80+ updatedAt: now,
81+ deletedAt: null,
82+ },
83+ ],
84+ threads: [
85+ {
86+ id: defaultThreadId,
87+ projectId: defaultProjectId,
88+ title: "Default Thread",
89+ modelSelection: defaultModelSelection,
90+ interactionMode: "default" as const,
91+ runtimeMode: "full-access" as const,
92+ branch: null,
93+ worktreePath: null,
94+ createdAt: now,
95+ updatedAt: now,
96+ archivedAt: null,
97+ latestTurn: null,
98+ messages: [],
99+ session: null,
100+ activities: [],
101+ proposedPlans: [],
102+ checkpoints: [],
103+ deletedAt: null,
104+ },
105+ ],
106+ };
107+};
108+
109+const workspaceAndProjectServicesLayer = Layer.mergeAll(
110+ WorkspacePathsLive,
111+ WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)),
112+ WorkspaceFileSystemLive.pipe(
113+ Layer.provide(WorkspacePathsLive),
114+ Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
115+ ),
116+ ProjectFaviconResolverLive,
117+);
118+
119+const buildAppUnderTest = (options?: {
120+ config?: Partial<ServerConfigShape>;
121+ layers?: {
122+ keybindings?: Partial<KeybindingsShape>;
123+ providerRegistry?: Partial<ProviderRegistryShape>;
124+ serverSettings?: Partial<ServerSettingsShape>;
125+ open?: Partial<OpenShape>;
126+ gitCore?: Partial<GitCoreShape>;
127+ gitManager?: Partial<GitManagerShape>;
128+ terminalManager?: Partial<TerminalManagerShape>;
129+ orchestrationEngine?: Partial<OrchestrationEngineShape>;
130+ projectionSnapshotQuery?: Partial<ProjectionSnapshotQueryShape>;
131+ checkpointDiffQuery?: Partial<CheckpointDiffQueryShape>;
132+ serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>;
133+ serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>;
134+ };
135+}) =>
136+ Effect.gen(function* () {
137+ const fileSystem = yield* FileSystem.FileSystem;
138+ const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" });
139+ const baseDir = options?.config?.baseDir ?? tempBaseDir;
140+ const devUrl = options?.config?.devUrl;
141+ const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
142+ const config = {
143+ logLevel: "Info",
144+ mode: "web",
145+ port: 0,
146+ host: "127.0.0.1",
147+ cwd: process.cwd(),
148+ baseDir,
149+ ...derivedPaths,
150+ staticDir: undefined,
151+ devUrl,
152+ noBrowser: true,
153+ authToken: undefined,
154+ autoBootstrapProjectFromCwd: false,
155+ logWebSocketEvents: false,
156+ ...options?.config,
157+ } satisfies ServerConfigShape;
158+ const layerConfig = Layer.succeed(ServerConfig, config);
159+
160+ const appLayer = HttpRouter.serve(makeRoutesLayer, {
161+ disableListenLog: true,
162+ disableLogger: true,
163+ }).pipe(
164+ Layer.provide(
165+ Layer.mock(Keybindings)({
166+ streamChanges: Stream.empty,
167+ ...options?.layers?.keybindings,
168+ }),
169+ ),
170+ Layer.provide(
171+ Layer.mock(ProviderRegistry)({
172+ getProviders: Effect.succeed([]),
173+ refresh: () => Effect.succeed([]),
174+ streamChanges: Stream.empty,
175+ ...options?.layers?.providerRegistry,
176+ }),
177+ ),
178+ Layer.provide(
179+ Layer.mock(ServerSettingsService)({
180+ start: Effect.void,
181+ ready: Effect.void,
182+ getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS),
183+ updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS),
184+ streamChanges: Stream.empty,
185+ ...options?.layers?.serverSettings,
186+ }),
187+ ),
188+ Layer.provide(
189+ Layer.mock(Open)({
190+ ...options?.layers?.open,
191+ }),
192+ ),
193+ Layer.provide(
194+ Layer.mock(GitCore)({
195+ ...options?.layers?.gitCore,
196+ }),
197+ ),
198+ Layer.provide(
199+ Layer.mock(GitManager)({
200+ ...options?.layers?.gitManager,
201+ }),
202+ ),
203+ Layer.provide(
204+ Layer.mock(TerminalManager)({
205+ ...options?.layers?.terminalManager,
206+ }),
207+ ),
208+ Layer.provide(
209+ Layer.mock(OrchestrationEngineService)({
210+ getReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
211+ readEvents: () => Stream.empty,
212+ dispatch: () => Effect.succeed({ sequence: 0 }),
213+ streamDomainEvents: Stream.empty,
214+ ...options?.layers?.orchestrationEngine,
215+ }),
216+ ),
217+ Layer.provide(
218+ Layer.mock(ProjectionSnapshotQuery)({
219+ getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
220+ ...options?.layers?.projectionSnapshotQuery,
221+ }),
222+ ),
223+ Layer.provide(
224+ Layer.mock(CheckpointDiffQuery)({
225+ getTurnDiff: () =>
226+ Effect.succeed({
227+ threadId: defaultThreadId,
228+ fromTurnCount: 0,
229+ toTurnCount: 0,
230+ diff: "",
231+ }),
232+ getFullThreadDiff: () =>
233+ Effect.succeed({
234+ threadId: defaultThreadId,
235+ fromTurnCount: 0,
236+ toTurnCount: 0,
237+ diff: "",
238+ }),
239+ ...options?.layers?.checkpointDiffQuery,
240+ }),
241+ ),
242+ Layer.provide(
243+ Layer.mock(ServerLifecycleEvents)({
244+ publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }),
245+ snapshot: Effect.succeed({ sequence: 0, events: [] }),
246+ stream: Stream.empty,
247+ ...options?.layers?.serverLifecycleEvents,
248+ }),
249+ ),
250+ Layer.provide(
251+ Layer.mock(ServerRuntimeStartup)({
252+ awaitCommandReady: Effect.void,
253+ markHttpListening: Effect.void,
254+ enqueueCommand: (effect) => effect,
255+ ...options?.layers?.serverRuntimeStartup,
256+ }),
257+ ),
258+ Layer.provide(workspaceAndProjectServicesLayer),
259+ Layer.provide(layerConfig),
260+ );
261+
262+ yield* Layer.build(appLayer);
263+ return config;
264+ });
265+
266+const wsRpcProtocolLayer = (wsUrl: string) =>
267+ RpcClient.layerProtocolSocket().pipe(
268+ Layer.provide(NodeSocket.layerWebSocket(wsUrl)),
269+ Layer.provide(RpcSerialization.layerJson),
270+ );
271+
272+const makeWsRpcClient = RpcClient.make(WsRpcGroup);
273+type WsRpcClient =
274+ typeof makeWsRpcClient extends Effect.Effect<infer Client, any, any> ? Client : never;
275+
276+const withWsRpcClient = <A, E, R>(
277+ wsUrl: string,
278+ f: (client: WsRpcClient) => Effect.Effect<A, E, R>,
279+) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl)));
280+
281+const getHttpServerUrl = (pathname = "") =>
282+ Effect.gen(function* () {
283+ const server = yield* HttpServer.HttpServer;
284+ const address = server.address as HttpServer.TcpAddress;
285+ return `http://127.0.0.1:${address.port}${pathname}`;
286+ });
287+
288+const getWsServerUrl = (pathname = "") =>
289+ Effect.gen(function* () {
290+ const server = yield* HttpServer.HttpServer;
291+ const address = server.address as HttpServer.TcpAddress;
292+ return `ws://127.0.0.1:${address.port}${pathname}`;
293+ });
294+
295+it.layer(NodeServices.layer)("server router seam", (it) => {
296+ it.effect("serves static index content for GET / when staticDir is configured", () =>
297+ Effect.gen(function* () {
298+ const fileSystem = yield* FileSystem.FileSystem;
299+ const path = yield* Path.Path;
300+ const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-static-" });
301+ const indexPath = path.join(staticDir, "index.html");
302+ yield* fileSystem.writeFileString(indexPath, "<html>router-static-ok</html>");
303+
304+ yield* buildAppUnderTest({ config: { staticDir } });
305+
306+ const response = yield* HttpClient.get("/");
307+ assert.equal(response.status, 200);
308+ assert.include(yield* response.text, "router-static-ok");
309+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
310+ );
311+
312+ it.effect("redirects to dev URL when configured", () =>
313+ Effect.gen(function* () {
314+ yield* buildAppUnderTest({
315+ config: { devUrl: new URL("http://127.0.0.1:5173") },
316+ });
317+
318+ const url = yield* getHttpServerUrl("/foo/bar");
319+ const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" }));
320+
321+ assert.equal(response.status, 302);
322+ assert.equal(response.headers.get("location"), "http://127.0.0.1:5173/");
323+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
324+ );
325+
326+ it.effect("serves project favicon requests before the dev URL redirect", () =>
327+ Effect.gen(function* () {
328+ const fileSystem = yield* FileSystem.FileSystem;
329+ const path = yield* Path.Path;
330+ const projectDir = yield* fileSystem.makeTempDirectoryScoped({
331+ prefix: "t3-router-project-favicon-",
332+ });
333+ yield* fileSystem.writeFileString(
334+ path.join(projectDir, "favicon.svg"),
335+ "<svg>router-project-favicon</svg>",
336+ );
337+
338+ yield* buildAppUnderTest({
339+ config: { devUrl: new URL("http://127.0.0.1:5173") },
340+ });
341+
342+ const response = yield* HttpClient.get(
343+ `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
344+ );
345+
346+ assert.equal(response.status, 200);
347+ assert.equal(yield* response.text, "<svg>router-project-favicon</svg>");
348+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
349+ );
350+
351+ it.effect("serves the fallback project favicon when no icon exists", () =>
352+ Effect.gen(function* () {
353+ const fileSystem = yield* FileSystem.FileSystem;
354+ const projectDir = yield* fileSystem.makeTempDirectoryScoped({
355+ prefix: "t3-router-project-favicon-fallback-",
356+ });
357+
358+ yield* buildAppUnderTest({
359+ config: { devUrl: new URL("http://127.0.0.1:5173") },
360+ });
361+
362+ const response = yield* HttpClient.get(
363+ `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
364+ );
365+
366+ assert.equal(response.status, 200);
367+ assert.include(yield* response.text, 'data-fallback="project-favicon"');
368+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
369+ );
370+
371+ it.effect("serves attachment files from state dir", () =>
372+ Effect.gen(function* () {
373+ const fileSystem = yield* FileSystem.FileSystem;
374+ const path = yield* Path.Path;
375+ const attachmentId = "thread-11111111-1111-4111-8111-111111111111";
376+
377+ const config = yield* buildAppUnderTest();
378+ const attachmentPath = resolveAttachmentRelativePath({
379+ attachmentsDir: config.attachmentsDir,
380+ relativePath: `${attachmentId}.bin`,
381+ });
382+ assert.isNotNull(attachmentPath, "Attachment path should be resolvable");
383+
384+ yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
385+ yield* fileSystem.writeFileString(attachmentPath, "attachment-ok");
386+
387+ const response = yield* HttpClient.get(`/attachments/${attachmentId}`);
388+ assert.equal(response.status, 200);
389+ assert.equal(yield* response.text, "attachment-ok");
390+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
391+ );
392+
393+ it.effect("serves attachment files for URL-encoded paths", () =>
394+ Effect.gen(function* () {
395+ const fileSystem = yield* FileSystem.FileSystem;
396+ const path = yield* Path.Path;
397+
398+ const config = yield* buildAppUnderTest();
399+ const attachmentPath = resolveAttachmentRelativePath({
400+ attachmentsDir: config.attachmentsDir,
401+ relativePath: "thread%20folder/message%20folder/file%20name.png",
402+ });
403+ assert.isNotNull(attachmentPath, "Attachment path should be resolvable");
404+
405+ yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
406+ yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok");
407+
408+ const response = yield* HttpClient.get(
409+ "/attachments/thread%20folder/message%20folder/file%20name.png",
410+ );
411+ assert.equal(response.status, 200);
412+ assert.equal(yield* response.text, "attachment-encoded-ok");
413+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
414+ );
415+
416+ it.effect("returns 404 for missing attachment id lookups", () =>
417+ Effect.gen(function* () {
418+ yield* buildAppUnderTest();
419+
420+ const response = yield* HttpClient.get(
421+ "/attachments/missing-11111111-1111-4111-8111-111111111111",
422+ );
423+ assert.equal(response.status, 404);
424+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
425+ );
426+
427+ it.effect("routes websocket rpc server.upsertKeybinding", () =>
428+ Effect.gen(function* () {
429+ const rule: KeybindingRule = {
430+ command: "terminal.toggle",
431+ key: "ctrl+k",
432+ };
433+ const resolved: ResolvedKeybindingRule = {
434+ command: "terminal.toggle",
435+ shortcut: {
436+ key: "k",
437+ metaKey: false,
438+ ctrlKey: true,
439+ shiftKey: false,
440+ altKey: false,
441+ modKey: true,
442+ },
443+ };
444+
445+ yield* buildAppUnderTest({
446+ layers: {
447+ keybindings: {
448+ upsertKeybindingRule: () => Effect.succeed([resolved]),
449+ },
450+ },
451+ });
452+
453+ const wsUrl = yield* getWsServerUrl("/ws");
454+ const response = yield* Effect.scoped(
455+ withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverUpsertKeybinding](rule)),
456+ );
457+
458+ assert.deepEqual(response.issues, []);
459+ assert.deepEqual(response.keybindings, [resolved]);
460+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
461+ );
462+
463+ it.effect("rejects websocket rpc handshake when auth token is missing", () =>
464+ Effect.gen(function* () {
465+ const fs = yield* FileSystem.FileSystem;
466+ const path = yield* Path.Path;
467+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-required-" });
468+ yield* fs.writeFileString(
469+ path.join(workspaceDir, "needle-file.ts"),
470+ "export const needle = 1;",
471+ );
472+
473+ yield* buildAppUnderTest({
474+ config: {
475+ authToken: "secret-token",
476+ },
477+ });
478+
479+ const wsUrl = yield* getWsServerUrl("/ws");
480+ const result = yield* Effect.scoped(
481+ withWsRpcClient(wsUrl, (client) =>
482+ client[WS_METHODS.projectsSearchEntries]({
483+ cwd: workspaceDir,
484+ query: "needle",
485+ limit: 10,
486+ }),
487+ ).pipe(Effect.result),
488+ );
489+
490+ assertTrue(result._tag === "Failure");
491+ assertInclude(String(result.failure), "SocketOpenError");
492+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
493+ );
494+
495+ it.effect("accepts websocket rpc handshake when auth token is provided", () =>
496+ Effect.gen(function* () {
497+ const fs = yield* FileSystem.FileSystem;
498+ const path = yield* Path.Path;
499+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-ok-" });
500+ yield* fs.writeFileString(
501+ path.join(workspaceDir, "needle-file.ts"),
502+ "export const needle = 1;",
503+ );
504+
505+ yield* buildAppUnderTest({
506+ config: {
507+ authToken: "secret-token",
508+ },
509+ });
510+
511+ const wsUrl = yield* getWsServerUrl("/ws?token=secret-token");
512+ const response = yield* Effect.scoped(
513+ withWsRpcClient(wsUrl, (client) =>
514+ client[WS_METHODS.projectsSearchEntries]({
515+ cwd: workspaceDir,
516+ query: "needle",
517+ limit: 10,
518+ }),
519+ ),
520+ );
521+
522+ assert.isAtLeast(response.entries.length, 1);
523+ assert.equal(response.truncated, false);
524+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
525+ );
526+
527+ it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>
528+ Effect.gen(function* () {
529+ const providers = [] as const;
530+ const changeEvent = {
531+ keybindings: [],
532+ issues: [],
533+ } as const;
534+
535+ yield* buildAppUnderTest({
536+ layers: {
537+ keybindings: {
538+ loadConfigState: Effect.succeed({
539+ keybindings: [],
540+ issues: [],
541+ }),
542+ streamChanges: Stream.succeed(changeEvent),
543+ },
544+ providerRegistry: {
545+ getProviders: Effect.succeed(providers),
546+ },
547+ },
548+ });
549+
550+ const wsUrl = yield* getWsServerUrl("/ws");
551+ const events = yield* Effect.scoped(
552+ withWsRpcClient(wsUrl, (client) =>
553+ client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),
554+ ),
555+ );
556+
557+ const [first, second] = Array.from(events);
558+ assert.equal(first?.type, "snapshot");
559+ if (first?.type === "snapshot") {
560+ assert.equal(first.version, 1);
561+ assert.deepEqual(first.config.keybindings, []);
562+ assert.deepEqual(first.config.issues, []);
563+ assert.deepEqual(first.config.providers, providers);
564+ assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS);
565+ }
566+ assert.deepEqual(second, {
567+ version: 1,
568+ type: "keybindingsUpdated",
569+ payload: { issues: [] },
570+ });
571+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
572+ );
573+
574+ it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>
575+ Effect.gen(function* () {
576+ const providers = [] as const;
577+
578+ yield* buildAppUnderTest({
579+ layers: {
580+ keybindings: {
581+ loadConfigState: Effect.succeed({
582+ keybindings: [],
583+ issues: [],
584+ }),
585+ streamChanges: Stream.empty,
586+ },
587+ providerRegistry: {
588+ getProviders: Effect.succeed([]),
589+ streamChanges: Stream.succeed(providers),
590+ },
591+ },
592+ });
593+
594+ const wsUrl = yield* getWsServerUrl("/ws");
595+ const events = yield* Effect.scoped(
596+ withWsRpcClient(wsUrl, (client) =>
597+ client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),
598+ ),
599+ );
600+
601+ const [first, second] = Array.from(events);
602+ assert.equal(first?.type, "snapshot");
603+ assert.deepEqual(second, {
604+ version: 1,
605+ type: "providerStatuses",
606+ payload: { providers },
607+ });
608+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
609+ );
610+
611+ it.effect(
612+ "routes websocket rpc subscribeServerLifecycle replays snapshot and streams updates",
613+ () =>
614+ Effect.gen(function* () {
615+ const lifecycleEvents = [
616+ {
617+ version: 1 as const,
618+ sequence: 1,
619+ type: "welcome" as const,
620+ payload: {
621+ cwd: "/tmp/project",
622+ projectName: "project",
623+ },
624+ },
625+ ] as const;
626+ const liveEvents = Stream.make({
627+ version: 1 as const,
628+ sequence: 2,
629+ type: "ready" as const,
630+ payload: { at: new Date().toISOString() },
631+ });
632+
633+ yield* buildAppUnderTest({
634+ layers: {
635+ serverLifecycleEvents: {
636+ snapshot: Effect.succeed({
637+ sequence: 1,
638+ events: lifecycleEvents,
639+ }),
640+ stream: liveEvents,
641+ },
642+ },
643+ });
644+
645+ const wsUrl = yield* getWsServerUrl("/ws");
646+ const events = yield* Effect.scoped(
647+ withWsRpcClient(wsUrl, (client) =>
648+ client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect),
649+ ),
650+ );
651+
652+ const [first, second] = Array.from(events);
653+ assert.equal(first?.type, "welcome");
654+ assert.equal(first?.sequence, 1);
655+ assert.equal(second?.type, "ready");
656+ assert.equal(second?.sequence, 2);
657+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
658+ );
659+
660+ it.effect("routes websocket rpc projects.searchEntries", () =>
661+ Effect.gen(function* () {
662+ const fs = yield* FileSystem.FileSystem;
663+ const path = yield* Path.Path;
664+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-search-" });
665+ yield* fs.writeFileString(
666+ path.join(workspaceDir, "needle-file.ts"),
667+ "export const needle = 1;",
668+ );
669+
670+ yield* buildAppUnderTest();
671+
672+ const wsUrl = yield* getWsServerUrl("/ws");
673+ const response = yield* Effect.scoped(
674+ withWsRpcClient(wsUrl, (client) =>
675+ client[WS_METHODS.projectsSearchEntries]({
676+ cwd: workspaceDir,
677+ query: "needle",
678+ limit: 10,
679+ }),
680+ ),
681+ );
682+
683+ assert.isAtLeast(response.entries.length, 1);
684+ assert.isTrue(response.entries.some((entry) => entry.path === "needle-file.ts"));
685+ assert.equal(response.truncated, false);
686+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
687+ );
688+
689+ it.effect("routes websocket rpc projects.searchEntries errors", () =>
690+ Effect.gen(function* () {
691+ yield* buildAppUnderTest();
692+
693+ const wsUrl = yield* getWsServerUrl("/ws");
694+ const result = yield* Effect.scoped(
695+ withWsRpcClient(wsUrl, (client) =>
696+ client[WS_METHODS.projectsSearchEntries]({
697+ cwd: "/definitely/not/a/real/workspace/path",
698+ query: "needle",
699+ limit: 10,
700+ }),
701+ ).pipe(Effect.result),
702+ );
703+
704+ assertTrue(result._tag === "Failure");
705+ assertTrue(result.failure._tag === "ProjectSearchEntriesError");
706+ assertInclude(
707+ result.failure.message,
708+ "Workspace root does not exist: /definitely/not/a/real/workspace/path",
709+ );
710+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
711+ );
712+
713+ it.effect("routes websocket rpc projects.writeFile", () =>
714+ Effect.gen(function* () {
715+ const fs = yield* FileSystem.FileSystem;
716+ const path = yield* Path.Path;
717+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" });
718+
719+ yield* buildAppUnderTest();
720+
721+ const wsUrl = yield* getWsServerUrl("/ws");
722+ const response = yield* Effect.scoped(
723+ withWsRpcClient(wsUrl, (client) =>
724+ client[WS_METHODS.projectsWriteFile]({
725+ cwd: workspaceDir,
726+ relativePath: "nested/created.txt",
727+ contents: "written-by-rpc",
728+ }),
729+ ),
730+ );
731+
732+ assert.equal(response.relativePath, "nested/created.txt");
733+ const persisted = yield* fs.readFileString(path.join(workspaceDir, "nested", "created.txt"));
734+ assert.equal(persisted, "written-by-rpc");
735+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
736+ );
737+
738+ it.effect("routes websocket rpc projects.writeFile errors", () =>
739+ Effect.gen(function* () {
740+ const fs = yield* FileSystem.FileSystem;
741+ const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" });
742+
743+ yield* buildAppUnderTest();
744+
745+ const wsUrl = yield* getWsServerUrl("/ws");
746+ const result = yield* Effect.scoped(
747+ withWsRpcClient(wsUrl, (client) =>
748+ client[WS_METHODS.projectsWriteFile]({
749+ cwd: workspaceDir,
750+ relativePath: "../escape.txt",
751+ contents: "nope",
752+ }),
753+ ).pipe(Effect.result),
754+ );
755+
756+ assertTrue(result._tag === "Failure");
757+ assertTrue(result.failure._tag === "ProjectWriteFileError");
758+ assert.equal(
759+ result.failure.message,
760+ "Workspace file path must stay within the project root.",
761+ );
762+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
763+ );
764+
765+ it.effect("routes websocket rpc shell.openInEditor", () =>
766+ Effect.gen(function* () {
767+ let openedInput: { cwd: string; editor: EditorId } | null = null;
768+ yield* buildAppUnderTest({
769+ layers: {
770+ open: {
771+ openInEditor: (input) =>
772+ Effect.sync(() => {
773+ openedInput = input;
774+ }),
775+ },
776+ },
777+ });
778+
779+ const wsUrl = yield* getWsServerUrl("/ws");
780+ yield* Effect.scoped(
781+ withWsRpcClient(wsUrl, (client) =>
782+ client[WS_METHODS.shellOpenInEditor]({
783+ cwd: "/tmp/project",
784+ editor: "cursor",
785+ }),
786+ ),
787+ );
788+
789+ assert.deepEqual(openedInput, { cwd: "/tmp/project", editor: "cursor" });
790+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
791+ );
792+
793+ it.effect("routes websocket rpc shell.openInEditor errors", () =>
794+ Effect.gen(function* () {
795+ const openError = new OpenError({ message: "Editor command not found: cursor" });
796+ yield* buildAppUnderTest({
797+ layers: {
798+ open: {
799+ openInEditor: () => Effect.fail(openError),
800+ },
801+ },
802+ });
803+
804+ const wsUrl = yield* getWsServerUrl("/ws");
805+ const result = yield* Effect.scoped(
806+ withWsRpcClient(wsUrl, (client) =>
807+ client[WS_METHODS.shellOpenInEditor]({
808+ cwd: "/tmp/project",
809+ editor: "cursor",
810+ }),
811+ ).pipe(Effect.result),
812+ );
813+
814+ assertFailure(result, openError);
815+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
816+ );
817+
818+ it.effect("routes websocket rpc git methods", () =>
819+ Effect.gen(function* () {
820+ yield* buildAppUnderTest({
821+ layers: {
822+ gitManager: {
823+ status: () =>
824+ Effect.succeed({
825+ branch: "main",
826+ hasWorkingTreeChanges: false,
827+ workingTree: { files: [], insertions: 0, deletions: 0 },
828+ hasUpstream: true,
829+ aheadCount: 0,
830+ behindCount: 0,
831+ pr: null,
832+ }),
833+ runStackedAction: (input, options) =>
834+ Effect.gen(function* () {
835+ const result = {
836+ action: "commit" as const,
837+ branch: { status: "skipped_not_requested" as const },
838+ commit: {
839+ status: "created" as const,
840+ commitSha: "abc123",
841+ subject: "feat: demo",
842+ },
843+ push: { status: "skipped_not_requested" as const },
844+ pr: { status: "skipped_not_requested" as const },
845+ };
846+
847+ yield* (
848+ options?.progressReporter?.publish({
849+ actionId: options.actionId ?? input.actionId,
850+ cwd: input.cwd,
851+ action: input.action,
852+ kind: "phase_started",
853+ phase: "commit",
854+ label: "Committing...",
855+ }) ?? Effect.void
856+ );
857+
858+ yield* (
859+ options?.progressReporter?.publish({
860+ actionId: options.actionId ?? input.actionId,
861+ cwd: input.cwd,
862+ action: input.action,
863+ kind: "action_finished",
864+ result,
865+ }) ?? Effect.void
866+ );
867+
868+ return result;
869+ }),
870+ resolvePullRequest: () =>
871+ Effect.succeed({
872+ pullRequest: {
873+ number: 1,
874+ title: "Demo PR",
875+ url: "https://example.com/pr/1",
876+ baseBranch: "main",
877+ headBranch: "feature/demo",
878+ state: "open",
879+ },
880+ }),
881+ preparePullRequestThread: () =>
882+ Effect.succeed({
883+ pullRequest: {
884+ number: 1,
885+ title: "Demo PR",
886+ url: "https://example.com/pr/1",
887+ baseBranch: "main",
888+ headBranch: "feature/demo",
889+ state: "open",
890+ },
891+ branch: "feature/demo",
892+ worktreePath: null,
893+ }),
894+ },
895+ gitCore: {
896+ pullCurrentBranch: () =>
897+ Effect.succeed({
898+ status: "pulled",
899+ branch: "main",
900+ upstreamBranch: "origin/main",
901+ }),
902+ listBranches: () =>
903+ Effect.succeed({
904+ branches: [
905+ {
906+ name: "main",
907+ current: true,
908+ isDefault: true,
909+ worktreePath: null,
910+ },
911+ ],
912+ isRepo: true,
913+ hasOriginRemote: true,
914+ }),
915+ createWorktree: () =>
916+ Effect.succeed({
917+ worktree: { path: "/tmp/wt", branch: "feature/demo" },
918+ }),
919+ removeWorktree: () => Effect.void,
920+ createBranch: () => Effect.void,
921+ checkoutBranch: () => Effect.void,
922+ initRepo: () => Effect.void,
923+ },
924+ },
925+ });
926+
927+ const wsUrl = yield* getWsServerUrl("/ws");
928+
929+ const status = yield* Effect.scoped(
930+ withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitStatus]({ cwd: "/tmp/repo" })),
931+ );
932+ assert.equal(status.branch, "main");
933+
934+ const pull = yield* Effect.scoped(
935+ withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })),
936+ );
937+ assert.equal(pull.status, "pulled");
938+
939+ const stackedEvents = yield* Effect.scoped(
940+ withWsRpcClient(wsUrl, (client) =>
941+ client[WS_METHODS.gitRunStackedAction]({
942+ actionId: "action-1",
943+ cwd: "/tmp/repo",
944+ action: "commit",
945+ }).pipe(
946+ Stream.runCollect,
947+ Effect.map((events) => Array.from(events)),
948+ ),
949+ ),
950+ );
951+ const lastStackedEvent = stackedEvents.at(-1);
952+ assert.equal(lastStackedEvent?.kind, "action_finished");
953+ if (lastStackedEvent?.kind === "action_finished") {
954+ assert.equal(lastStackedEvent.result.action, "commit");
955+ }
956+
957+ const resolvedPr = yield* Effect.scoped(
958+ withWsRpcClient(wsUrl, (client) =>
959+ client[WS_METHODS.gitResolvePullRequest]({
960+ cwd: "/tmp/repo",
961+ reference: "1",
962+ }),
963+ ),
964+ );
965+ assert.equal(resolvedPr.pullRequest.number, 1);
966+
967+ const prepared = yield* Effect.scoped(
968+ withWsRpcClient(wsUrl, (client) =>
969+ client[WS_METHODS.gitPreparePullRequestThread]({
970+ cwd: "/tmp/repo",
971+ reference: "1",
972+ mode: "local",
973+ }),
974+ ),
975+ );
976+ assert.equal(prepared.branch, "feature/demo");
977+
978+ const branches = yield* Effect.scoped(
979+ withWsRpcClient(wsUrl, (client) =>
980+ client[WS_METHODS.gitListBranches]({ cwd: "/tmp/repo" }),
981+ ),
982+ );
983+ assert.equal(branches.branches[0]?.name, "main");
984+
985+ const worktree = yield* Effect.scoped(
986+ withWsRpcClient(wsUrl, (client) =>
987+ client[WS_METHODS.gitCreateWorktree]({
988+ cwd: "/tmp/repo",
989+ branch: "main",
990+ path: null,
991+ }),
992+ ),
993+ );
994+ assert.equal(worktree.worktree.branch, "feature/demo");
995+
996+ yield* Effect.scoped(
997+ withWsRpcClient(wsUrl, (client) =>
998+ client[WS_METHODS.gitRemoveWorktree]({
999+ cwd: "/tmp/repo",
1000+ path: "/tmp/wt",
1001+ }),
1002+ ),
1003+ );
1004+
1005+ yield* Effect.scoped(
1006+ withWsRpcClient(wsUrl, (client) =>
1007+ client[WS_METHODS.gitCreateBranch]({
1008+ cwd: "/tmp/repo",
1009+ branch: "feature/new",
1010+ }),
1011+ ),
1012+ );
1013+
1014+ yield* Effect.scoped(
1015+ withWsRpcClient(wsUrl, (client) =>
1016+ client[WS_METHODS.gitCheckout]({
1017+ cwd: "/tmp/repo",
1018+ branch: "main",
1019+ }),
1020+ ),
1021+ );
1022+
1023+ yield* Effect.scoped(
1024+ withWsRpcClient(wsUrl, (client) =>
1025+ client[WS_METHODS.gitInit]({
1026+ cwd: "/tmp/repo",
1027+ }),
1028+ ),
1029+ );
1030+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1031+ );
1032+
1033+ it.effect("routes websocket rpc git.pull errors", () =>
1034+ Effect.gen(function* () {
1035+ const gitError = new GitCommandError({
1036+ operation: "pull",
1037+ command: "git pull --ff-only",
1038+ cwd: "/tmp/repo",
1039+ detail: "upstream missing",
1040+ });
1041+ yield* buildAppUnderTest({
1042+ layers: {
1043+ gitCore: {
1044+ pullCurrentBranch: () => Effect.fail(gitError),
1045+ },
1046+ },
1047+ });
1048+
1049+ const wsUrl = yield* getWsServerUrl("/ws");
1050+ const result = yield* Effect.scoped(
1051+ withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe(
1052+ Effect.result,
1053+ ),
1054+ );
1055+
1056+ assertFailure(result, gitError);
1057+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1058+ );
1059+
1060+ it.effect("routes websocket rpc orchestration methods", () =>
1061+ Effect.gen(function* () {
1062+ const now = new Date().toISOString();
1063+ const snapshot = {
1064+ snapshotSequence: 1,
1065+ updatedAt: now,
1066+ projects: [
1067+ {
1068+ id: ProjectId.makeUnsafe("project-a"),
1069+ title: "Project A",
1070+ workspaceRoot: "/tmp/project-a",
1071+ defaultModelSelection,
1072+ scripts: [],
1073+ createdAt: now,
1074+ updatedAt: now,
1075+ deletedAt: null,
1076+ },
1077+ ],
1078+ threads: [
1079+ {
1080+ id: ThreadId.makeUnsafe("thread-1"),
1081+ projectId: ProjectId.makeUnsafe("project-a"),
1082+ title: "Thread A",
1083+ modelSelection: defaultModelSelection,
1084+ interactionMode: "default" as const,
1085+ runtimeMode: "full-access" as const,
1086+ branch: null,
1087+ worktreePath: null,
1088+ createdAt: now,
1089+ updatedAt: now,
1090+ archivedAt: null,
1091+ latestTurn: null,
1092+ messages: [],
1093+ session: null,
1094+ activities: [],
1095+ proposedPlans: [],
1096+ checkpoints: [],
1097+ deletedAt: null,
1098+ },
1099+ ],
1100+ };
1101+
1102+ yield* buildAppUnderTest({
1103+ layers: {
1104+ projectionSnapshotQuery: {
1105+ getSnapshot: () => Effect.succeed(snapshot),
1106+ },
1107+ orchestrationEngine: {
1108+ dispatch: () => Effect.succeed({ sequence: 7 }),
1109+ readEvents: () => Stream.empty,
1110+ },
1111+ checkpointDiffQuery: {
1112+ getTurnDiff: () =>
1113+ Effect.succeed({
1114+ threadId: ThreadId.makeUnsafe("thread-1"),
1115+ fromTurnCount: 0,
1116+ toTurnCount: 1,
1117+ diff: "turn-diff",
1118+ }),
1119+ getFullThreadDiff: () =>
1120+ Effect.succeed({
1121+ threadId: ThreadId.makeUnsafe("thread-1"),
1122+ fromTurnCount: 0,
1123+ toTurnCount: 1,
1124+ diff: "full-diff",
1125+ }),
1126+ },
1127+ },
1128+ });
1129+
1130+ const wsUrl = yield* getWsServerUrl("/ws");
1131+ const snapshotResult = yield* Effect.scoped(
1132+ withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})),
1133+ );
1134+ assert.equal(snapshotResult.snapshotSequence, 1);
1135+
1136+ const dispatchResult = yield* Effect.scoped(
1137+ withWsRpcClient(wsUrl, (client) =>
1138+ client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
1139+ type: "thread.session.stop",
1140+ commandId: CommandId.makeUnsafe("cmd-1"),
1141+ threadId: ThreadId.makeUnsafe("thread-1"),
1142+ createdAt: now,
1143+ }),
1144+ ),
1145+ );
1146+ assert.equal(dispatchResult.sequence, 7);
1147+
1148+ const turnDiffResult = yield* Effect.scoped(
1149+ withWsRpcClient(wsUrl, (client) =>
1150+ client[ORCHESTRATION_WS_METHODS.getTurnDiff]({
1151+ threadId: ThreadId.makeUnsafe("thread-1"),
1152+ fromTurnCount: 0,
1153+ toTurnCount: 1,
1154+ }),
1155+ ),
1156+ );
1157+ assert.equal(turnDiffResult.diff, "turn-diff");
1158+
1159+ const fullDiffResult = yield* Effect.scoped(
1160+ withWsRpcClient(wsUrl, (client) =>
1161+ client[ORCHESTRATION_WS_METHODS.getFullThreadDiff]({
1162+ threadId: ThreadId.makeUnsafe("thread-1"),
1163+ toTurnCount: 1,
1164+ }),
1165+ ),
1166+ );
1167+ assert.equal(fullDiffResult.diff, "full-diff");
1168+
1169+ const replayResult = yield* Effect.scoped(
1170+ withWsRpcClient(wsUrl, (client) =>
1171+ client[ORCHESTRATION_WS_METHODS.replayEvents]({
1172+ fromSequenceExclusive: 0,
1173+ }),
1174+ ),
1175+ );
1176+ assert.deepEqual(replayResult, []);
1177+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1178+ );
1179+
1180+ it.effect(
1181+ "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience",
1182+ () =>
1183+ Effect.gen(function* () {
1184+ const now = new Date().toISOString();
1185+ const threadId = ThreadId.makeUnsafe("thread-1");
1186+ let replayCursor: number | null = null;
1187+ const makeEvent = (sequence: number): OrchestrationEvent =>
1188+ ({
1189+ sequence,
1190+ eventId: `event-${sequence}`,
1191+ aggregateKind: "thread",
1192+ aggregateId: threadId,
1193+ occurredAt: now,
1194+ commandId: null,
1195+ causationEventId: null,
1196+ correlationId: null,
1197+ metadata: {},
1198+ type: "thread.reverted",
1199+ payload: {
1200+ threadId,
1201+ turnCount: sequence,
1202+ },
1203+ }) as OrchestrationEvent;
1204+
1205+ yield* buildAppUnderTest({
1206+ layers: {
1207+ orchestrationEngine: {
1208+ getReadModel: () =>
1209+ Effect.succeed({
1210+ ...makeDefaultOrchestrationReadModel(),
1211+ snapshotSequence: 1,
1212+ }),
1213+ readEvents: (fromSequenceExclusive) => {
1214+ replayCursor = fromSequenceExclusive;
1215+ return Stream.make(makeEvent(2), makeEvent(3));
1216+ },
1217+ streamDomainEvents: Stream.make(makeEvent(3), makeEvent(4)),
1218+ },
1219+ },
1220+ });
1221+
1222+ const wsUrl = yield* getWsServerUrl("/ws");
1223+ const events = yield* Effect.scoped(
1224+ withWsRpcClient(wsUrl, (client) =>
1225+ client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
1226+ Stream.take(3),
1227+ Stream.runCollect,
1228+ ),
1229+ ),
1230+ );
1231+
1232+ assert.equal(replayCursor, 1);
1233+ assert.deepEqual(
1234+ Array.from(events).map((event) => event.sequence),
1235+ [2, 3, 4],
1236+ );
1237+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1238+ );
1239+
1240+ it.effect("routes websocket rpc orchestration.getSnapshot errors", () =>
1241+ Effect.gen(function* () {
1242+ yield* buildAppUnderTest({
1243+ layers: {
1244+ projectionSnapshotQuery: {
1245+ getSnapshot: () =>
1246+ Effect.fail(
1247+ new PersistenceSqlError({
1248+ operation: "ProjectionSnapshotQuery.getSnapshot",
1249+ detail: "projection unavailable",
1250+ }),
1251+ ),
1252+ },
1253+ },
1254+ });
1255+
1256+ const wsUrl = yield* getWsServerUrl("/ws");
1257+ const result = yield* Effect.scoped(
1258+ withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})).pipe(
1259+ Effect.result,
1260+ ),
1261+ );
1262+
1263+ assertTrue(result._tag === "Failure");
1264+ assertTrue(result.failure._tag === "OrchestrationGetSnapshotError");
1265+ assertInclude(result.failure.message, "Failed to load orchestration snapshot");
1266+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1267+ );
1268+
1269+ it.effect("routes websocket rpc terminal methods", () =>
1270+ Effect.gen(function* () {
1271+ const snapshot = {
1272+ threadId: "thread-1",
1273+ terminalId: "default",
1274+ cwd: "/tmp/project",
1275+ status: "running" as const,
1276+ pid: 1234,
1277+ history: "",
1278+ exitCode: null,
1279+ exitSignal: null,
1280+ updatedAt: new Date().toISOString(),
1281+ };
1282+
1283+ yield* buildAppUnderTest({
1284+ layers: {
1285+ terminalManager: {
1286+ open: () => Effect.succeed(snapshot),
1287+ write: () => Effect.void,
1288+ resize: () => Effect.void,
1289+ clear: () => Effect.void,
1290+ restart: () => Effect.succeed(snapshot),
1291+ close: () => Effect.void,
1292+ },
1293+ },
1294+ });
1295+
1296+ const wsUrl = yield* getWsServerUrl("/ws");
1297+
1298+ const opened = yield* Effect.scoped(
1299+ withWsRpcClient(wsUrl, (client) =>
1300+ client[WS_METHODS.terminalOpen]({
1301+ threadId: "thread-1",
1302+ terminalId: "default",
1303+ cwd: "/tmp/project",
1304+ }),
1305+ ),
1306+ );
1307+ assert.equal(opened.terminalId, "default");
1308+
1309+ yield* Effect.scoped(
1310+ withWsRpcClient(wsUrl, (client) =>
1311+ client[WS_METHODS.terminalWrite]({
1312+ threadId: "thread-1",
1313+ terminalId: "default",
1314+ data: "echo hi\n",
1315+ }),
1316+ ),
1317+ );
1318+
1319+ yield* Effect.scoped(
1320+ withWsRpcClient(wsUrl, (client) =>
1321+ client[WS_METHODS.terminalResize]({
1322+ threadId: "thread-1",
1323+ terminalId: "default",
1324+ cols: 120,
1325+ rows: 40,
1326+ }),
1327+ ),
1328+ );
1329+
1330+ yield* Effect.scoped(
1331+ withWsRpcClient(wsUrl, (client) =>
1332+ client[WS_METHODS.terminalClear]({
1333+ threadId: "thread-1",
1334+ terminalId: "default",
1335+ }),
1336+ ),
1337+ );
1338+
1339+ const restarted = yield* Effect.scoped(
1340+ withWsRpcClient(wsUrl, (client) =>
1341+ client[WS_METHODS.terminalRestart]({
1342+ threadId: "thread-1",
1343+ terminalId: "default",
1344+ cwd: "/tmp/project",
1345+ cols: 120,
1346+ rows: 40,
1347+ }),
1348+ ),
1349+ );
1350+ assert.equal(restarted.terminalId, "default");
1351+
1352+ yield* Effect.scoped(
1353+ withWsRpcClient(wsUrl, (client) =>
1354+ client[WS_METHODS.terminalClose]({
1355+ threadId: "thread-1",
1356+ terminalId: "default",
1357+ }),
1358+ ),
1359+ );
1360+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1361+ );
1362+
1363+ it.effect("routes websocket rpc terminal.write errors", () =>
1364+ Effect.gen(function* () {
1365+ const terminalError = new TerminalNotRunningError({
1366+ threadId: "thread-1",
1367+ terminalId: "default",
1368+ });
1369+ yield* buildAppUnderTest({
1370+ layers: {
1371+ terminalManager: {
1372+ write: () => Effect.fail(terminalError),
1373+ },
1374+ },
1375+ });
1376+
1377+ const wsUrl = yield* getWsServerUrl("/ws");
1378+ const result = yield* Effect.scoped(
1379+ withWsRpcClient(wsUrl, (client) =>
1380+ client[WS_METHODS.terminalWrite]({
1381+ threadId: "thread-1",
1382+ terminalId: "default",
1383+ data: "echo fail\n",
1384+ }),
1385+ ).pipe(Effect.result),
1386+ );
1387+
1388+ assertFailure(result, terminalError);
1389+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1390+ );
1391+});
apps/server/src/server.ts+234−0
@@ -0,0 +1,234 @@
1+import { Effect, Layer } from "effect";
2+import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
3+
4+import { ServerConfig } from "./config";
5+import { attachmentsRouteLayer, projectFaviconRouteLayer, staticAndDevRouteLayer } from "./http";
6+import { fixPath } from "./os-jank";
7+import { websocketRpcRouteLayer } from "./ws";
8+import { OpenLive } from "./open";
9+import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite";
10+import { ServerLifecycleEventsLive } from "./serverLifecycleEvents";
11+import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
12+import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
13+import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
14+import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
15+import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
16+import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";
17+import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
18+import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
19+import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
20+import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
21+import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
22+import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
23+import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
24+import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
25+import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
26+import { GitCoreLive } from "./git/Layers/GitCore";
27+import { GitHubCliLive } from "./git/Layers/GitHubCli";
28+import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";
29+import { TerminalManagerLive } from "./terminal/Layers/Manager";
30+import { GitManagerLive } from "./git/Layers/GitManager";
31+import { KeybindingsLive } from "./keybindings";
32+import { ServerLoggerLive } from "./serverLogger";
33+import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup";
34+import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
35+import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus";
36+import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
37+import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
38+import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
39+import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";
40+import { ServerSettingsLive } from "./serverSettings";
41+import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver";
42+import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries";
43+import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem";
44+import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";
45+
46+const PtyAdapterLive = Layer.unwrap(
47+ Effect.gen(function* () {
48+ if (typeof Bun !== "undefined") {
49+ const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY"));
50+ return BunPTY.layer;
51+ } else {
52+ const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY"));
53+ return NodePTY.layer;
54+ }
55+ }),
56+);
57+
58+const HttpServerLive = Layer.unwrap(
59+ Effect.gen(function* () {
60+ const config = yield* ServerConfig;
61+ if (typeof Bun !== "undefined") {
62+ const BunHttpServer = yield* Effect.promise(
63+ () => import("@effect/platform-bun/BunHttpServer"),
64+ );
65+ return BunHttpServer.layer({
66+ port: config.port,
67+ ...(config.host ? { hostname: config.host } : {}),
68+ });
69+ } else {
70+ const [NodeHttpServer, NodeHttp] = yield* Effect.all([
71+ Effect.promise(() => import("@effect/platform-node/NodeHttpServer")),
72+ Effect.promise(() => import("node:http")),
73+ ]);
74+ return NodeHttpServer.layer(NodeHttp.createServer, {
75+ host: config.host,
76+ port: config.port,
77+ });
78+ }
79+ }),
80+);
81+
82+const PlatformServicesLive = Layer.unwrap(
83+ Effect.gen(function* () {
84+ if (typeof Bun !== "undefined") {
85+ const { layer } = yield* Effect.promise(() => import("@effect/platform-bun/BunServices"));
86+ return layer;
87+ } else {
88+ const { layer } = yield* Effect.promise(() => import("@effect/platform-node/NodeServices"));
89+ return layer;
90+ }
91+ }),
92+);
93+
94+const ReactorLayerLive = Layer.empty.pipe(
95+ Layer.provideMerge(OrchestrationReactorLive),
96+ Layer.provideMerge(ProviderRuntimeIngestionLive),
97+ Layer.provideMerge(ProviderCommandReactorLive),
98+ Layer.provideMerge(CheckpointReactorLive),
99+ Layer.provideMerge(RuntimeReceiptBusLive),
100+);
101+
102+const OrchestrationLayerLive = Layer.empty.pipe(
103+ Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
104+ Layer.provideMerge(OrchestrationEngineLive),
105+ Layer.provideMerge(OrchestrationProjectionPipelineLive),
106+ Layer.provideMerge(OrchestrationEventStoreLive),
107+ Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive),
108+);
109+
110+const CheckpointingLayerLive = Layer.empty.pipe(
111+ Layer.provideMerge(CheckpointDiffQueryLive),
112+ Layer.provideMerge(CheckpointStoreLive),
113+);
114+
115+const ProviderLayerLive = Layer.unwrap(
116+ Effect.gen(function* () {
117+ const { providerEventLogPath } = yield* ServerConfig;
118+ const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
119+ stream: "native",
120+ });
121+ const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
122+ stream: "canonical",
123+ });
124+ const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
125+ Layer.provide(ProviderSessionRuntimeRepositoryLive),
126+ );
127+ const codexAdapterLayer = makeCodexAdapterLive(
128+ nativeEventLogger ? { nativeEventLogger } : undefined,
129+ );
130+ const claudeAdapterLayer = makeClaudeAdapterLive(
131+ nativeEventLogger ? { nativeEventLogger } : undefined,
132+ );
133+ const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
134+ Layer.provide(codexAdapterLayer),
135+ Layer.provide(claudeAdapterLayer),
136+ Layer.provideMerge(providerSessionDirectoryLayer),
137+ );
138+ return makeProviderServiceLive(
139+ canonicalEventLogger ? { canonicalEventLogger } : undefined,
140+ ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
141+ }),
142+);
143+
144+const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive));
145+
146+const GitLayerLive = Layer.empty.pipe(
147+ Layer.provideMerge(
148+ GitManagerLive.pipe(
149+ Layer.provideMerge(GitCoreLive),
150+ Layer.provideMerge(GitHubCliLive),
151+ Layer.provideMerge(RoutingTextGenerationLive),
152+ ),
153+ ),
154+ Layer.provideMerge(GitCoreLive),
155+);
156+
157+const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive));
158+
159+const WorkspaceLayerLive = Layer.mergeAll(
160+ WorkspacePathsLive,
161+ WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)),
162+ WorkspaceFileSystemLive.pipe(
163+ Layer.provide(WorkspacePathsLive),
164+ Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
165+ ),
166+);
167+
168+const RuntimeServicesLive = Layer.empty.pipe(
169+ Layer.provideMerge(ServerRuntimeStartupLive),
170+ Layer.provideMerge(ReactorLayerLive),
171+
172+ // Core Services
173+ Layer.provideMerge(CheckpointingLayerLive),
174+ Layer.provideMerge(OrchestrationLayerLive),
175+ Layer.provideMerge(ProviderLayerLive),
176+ Layer.provideMerge(GitLayerLive),
177+ Layer.provideMerge(TerminalLayerLive),
178+ Layer.provideMerge(PersistenceLayerLive),
179+ Layer.provideMerge(KeybindingsLive),
180+ Layer.provideMerge(ProviderRegistryLive),
181+ Layer.provideMerge(ServerSettingsLive),
182+ Layer.provideMerge(WorkspaceLayerLive),
183+ Layer.provideMerge(ProjectFaviconResolverLive),
184+
185+ // Misc.
186+ Layer.provideMerge(AnalyticsServiceLayerLive),
187+ Layer.provideMerge(OpenLive),
188+ Layer.provideMerge(ServerLifecycleEventsLive),
189+);
190+
191+export const makeRoutesLayer = Layer.mergeAll(
192+ attachmentsRouteLayer,
193+ projectFaviconRouteLayer,
194+ staticAndDevRouteLayer,
195+ websocketRpcRouteLayer,
196+);
197+
198+export const makeServerLayer = Layer.unwrap(
199+ Effect.gen(function* () {
200+ const config = yield* ServerConfig;
201+
202+ fixPath();
203+
204+ const httpListeningLayer = Layer.effectDiscard(
205+ Effect.gen(function* () {
206+ yield* HttpServer.HttpServer;
207+ const startup = yield* ServerRuntimeStartup;
208+ yield* startup.markHttpListening;
209+ }),
210+ );
211+
212+ const serverApplicationLayer = Layer.mergeAll(
213+ HttpRouter.serve(makeRoutesLayer, {
214+ disableLogger: !config.logWebSocketEvents,
215+ }),
216+ httpListeningLayer,
217+ );
218+
219+ return serverApplicationLayer.pipe(
220+ Layer.provideMerge(RuntimeServicesLive),
221+ Layer.provideMerge(HttpServerLive),
222+ Layer.provide(ServerLoggerLive),
223+ Layer.provideMerge(FetchHttpClient.layer),
224+ Layer.provideMerge(PlatformServicesLive),
225+ );
226+ }),
227+);
228+
229+// Important: Only `ServerConfig` should be provided by the CLI layer!!! Don't let other requirements leak into the launch layer.
230+export const runServer = Layer.launch(makeServerLayer) satisfies Effect.Effect<
231+ never,
232+ any,
233+ ServerConfig
234+>;
apps/server/src/serverLayers.ts+0−161
@@ -1,161 +0,0 @@
1−import * as NodeServices from "@effect/platform-node/NodeServices";
2−import { Effect, FileSystem, Layer, Path } from "effect";
3−import * as SqlClient from "effect/unstable/sql/SqlClient";
4−
5−import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
6−import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
7−import { ServerConfig } from "./config";
8−import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
9−import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
10−import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
11−import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
12−import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
13−import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
14−import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
15−import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
16−import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
17−import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
18−import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus";
19−import { ProviderUnsupportedError } from "./provider/Errors";
20−import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";
21−import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
22−import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
23−import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
24−import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
25−import { ProviderService } from "./provider/Services/ProviderService";
26−import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
27−import { ServerSettingsService } from "./serverSettings";
28−
29−import { TerminalManagerLive } from "./terminal/Layers/Manager";
30−import { KeybindingsLive } from "./keybindings";
31−import { GitManagerLive } from "./git/Layers/GitManager";
32−import { GitCoreLive } from "./git/Layers/GitCore";
33−import { GitHubCliLive } from "./git/Layers/GitHubCli";
34−import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";
35−import { PtyAdapter } from "./terminal/Services/PTY";
36−import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
37−import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
38−import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
39−import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
40−import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
41−
42−type RuntimePtyAdapterLoader = {
43− layer: Layer.Layer<PtyAdapter, never, FileSystem.FileSystem | Path.Path>;
44−};
45−
46−const runtimePtyAdapterLoaders = {
47− bun: () => import("./terminal/Layers/BunPTY"),
48− node: () => import("./terminal/Layers/NodePTY"),
49−} satisfies Record<string, () => Promise<RuntimePtyAdapterLoader>>;
50−
51−const makeRuntimePtyAdapterLayer = () =>
52− Effect.gen(function* () {
53− const runtime = process.versions.bun !== undefined ? "bun" : "node";
54− const loader = runtimePtyAdapterLoaders[runtime];
55− const ptyAdapterModule = yield* Effect.promise<RuntimePtyAdapterLoader>(loader);
56− return ptyAdapterModule.layer;
57− }).pipe(Layer.unwrap);
58−
59−export function makeServerProviderLayer(): Layer.Layer<
60− ProviderService,
61− ProviderUnsupportedError,
62− | SqlClient.SqlClient
63− | ServerConfig
64− | ServerSettingsService
65− | FileSystem.FileSystem
66− | AnalyticsService
67−> {
68− return Effect.gen(function* () {
69− const { providerEventLogPath } = yield* ServerConfig;
70− const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
71− stream: "native",
72− });
73− const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
74− stream: "canonical",
75− });
76− const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
77− Layer.provide(ProviderSessionRuntimeRepositoryLive),
78− );
79− const codexAdapterLayer = makeCodexAdapterLive(
80− nativeEventLogger ? { nativeEventLogger } : undefined,
81− );
82− const claudeAdapterLayer = makeClaudeAdapterLive(
83− nativeEventLogger ? { nativeEventLogger } : undefined,
84− );
85− const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
86− Layer.provide(codexAdapterLayer),
87− Layer.provide(claudeAdapterLayer),
88− Layer.provideMerge(providerSessionDirectoryLayer),
89− );
90− return makeProviderServiceLive(
91− canonicalEventLogger ? { canonicalEventLogger } : undefined,
92− ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
93− }).pipe(Layer.unwrap);
94−}
95−
96−export function makeServerRuntimeServicesLayer() {
97− const textGenerationLayer = RoutingTextGenerationLive;
98− const gitCoreLayer = GitCoreLive;
99− const checkpointStoreLayer = CheckpointStoreLive;
100−
101− const orchestrationLayer = OrchestrationEngineLive.pipe(
102− Layer.provide(OrchestrationProjectionPipelineLive),
103− Layer.provide(OrchestrationEventStoreLive),
104− Layer.provide(OrchestrationCommandReceiptRepositoryLive),
105− );
106−
107− const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe(
108− Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
109− Layer.provideMerge(checkpointStoreLayer),
110− );
111−
112− const runtimeServicesLayer = Layer.mergeAll(
113− orchestrationLayer,
114− OrchestrationProjectionSnapshotQueryLive,
115− checkpointStoreLayer,
116− checkpointDiffQueryLayer,
117− RuntimeReceiptBusLive,
118− );
119− const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
120− Layer.provideMerge(runtimeServicesLayer),
121− );
122− const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
123− Layer.provideMerge(runtimeServicesLayer),
124− Layer.provideMerge(textGenerationLayer),
125− );
126− const checkpointReactorLayer = CheckpointReactorLive.pipe(
127− Layer.provideMerge(runtimeServicesLayer),
128− Layer.provideMerge(WorkspaceEntriesLive),
129− );
130− const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
131− Layer.provideMerge(runtimeIngestionLayer),
132− Layer.provideMerge(providerCommandReactorLayer),
133− Layer.provideMerge(checkpointReactorLayer),
134− );
135−
136− const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer()));
137−
138− const gitManagerLayer = GitManagerLive.pipe(
139− Layer.provideMerge(GitHubCliLive),
140− Layer.provideMerge(textGenerationLayer),
141− );
142−
143− const workspacePathsLayer = WorkspacePathsLive;
144− const workspaceEntriesLayer = WorkspaceEntriesLive;
145− const workspaceFileSystemLayer = WorkspaceFileSystemLive.pipe(
146− Layer.provide(workspacePathsLayer),
147− Layer.provide(workspaceEntriesLayer),
148− );
149− const projectFaviconResolverLayer = ProjectFaviconResolverLive;
150−
151− return Layer.mergeAll(
152− orchestrationReactorLayer,
153− workspacePathsLayer,
154− workspaceEntriesLayer,
155− workspaceFileSystemLayer,
156− projectFaviconResolverLayer,
157− gitManagerLayer,
158− terminalLayer,
159− KeybindingsLive,
160− ).pipe(Layer.provideMerge(gitCoreLayer), Layer.provideMerge(NodeServices.layer));
161−}
apps/server/src/serverLifecycleEvents.test.ts+42−0
@@ -0,0 +1,42 @@
1+import { assert, it } from "@effect/vitest";
2+import { assertTrue } from "@effect/vitest/utils";
3+import { Effect, Option } from "effect";
4+
5+import { ServerLifecycleEvents, ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts";
6+
7+it.effect(
8+ "publishes lifecycle events without subscribers and snapshots the latest welcome/ready",
9+ () =>
10+ Effect.gen(function* () {
11+ const lifecycleEvents = yield* ServerLifecycleEvents;
12+
13+ const welcome = yield* lifecycleEvents
14+ .publish({
15+ version: 1,
16+ type: "welcome",
17+ payload: {
18+ cwd: "/tmp/project",
19+ projectName: "project",
20+ },
21+ })
22+ .pipe(Effect.timeoutOption("50 millis"));
23+ assertTrue(Option.isSome(welcome));
24+ assert.equal(welcome.value.sequence, 1);
25+
26+ const ready = yield* lifecycleEvents
27+ .publish({
28+ version: 1,
29+ type: "ready",
30+ payload: {
31+ at: new Date().toISOString(),
32+ },
33+ })
34+ .pipe(Effect.timeoutOption("50 millis"));
35+ assertTrue(Option.isSome(ready));
36+ assert.equal(ready.value.sequence, 2);
37+
38+ const snapshot = yield* lifecycleEvents.snapshot;
39+ assert.equal(snapshot.sequence, 2);
40+ assert.deepEqual(snapshot.events.map((event) => event.type).toSorted(), ["ready", "welcome"]);
41+ }).pipe(Effect.provide(ServerLifecycleEventsLive)),
42+);
apps/server/src/serverLifecycleEvents.ts+53−0
@@ -0,0 +1,53 @@
1+import type { ServerLifecycleStreamEvent } from "@t3tools/contracts";
2+import { Effect, Layer, PubSub, Ref, ServiceMap, Stream } from "effect";
3+
4+type LifecycleEventInput =
5+ | Omit<Extract<ServerLifecycleStreamEvent, { type: "welcome" }>, "sequence">
6+ | Omit<Extract<ServerLifecycleStreamEvent, { type: "ready" }>, "sequence">;
7+
8+interface SnapshotState {
9+ readonly sequence: number;
10+ readonly events: ReadonlyArray<ServerLifecycleStreamEvent>;
11+}
12+
13+export interface ServerLifecycleEventsShape {
14+ readonly publish: (event: LifecycleEventInput) => Effect.Effect<ServerLifecycleStreamEvent>;
15+ readonly snapshot: Effect.Effect<SnapshotState>;
16+ readonly stream: Stream.Stream<ServerLifecycleStreamEvent>;
17+}
18+
19+export class ServerLifecycleEvents extends ServiceMap.Service<
20+ ServerLifecycleEvents,
21+ ServerLifecycleEventsShape
22+>()("t3/serverLifecycleEvents") {}
23+
24+export const ServerLifecycleEventsLive = Layer.effect(
25+ ServerLifecycleEvents,
26+ Effect.gen(function* () {
27+ const pubsub = yield* PubSub.unbounded<ServerLifecycleStreamEvent>();
28+ const state = yield* Ref.make<SnapshotState>({
29+ sequence: 0,
30+ events: [],
31+ });
32+
33+ return {
34+ publish: (event) =>
35+ Ref.modify(state, (current) => {
36+ const nextSequence = current.sequence + 1;
37+ const nextEvent = {
38+ ...event,
39+ sequence: nextSequence,
40+ } satisfies ServerLifecycleStreamEvent;
41+ const nextEvents =
42+ nextEvent.type === "welcome"
43+ ? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")]
44+ : [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")];
45+ return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const;
46+ }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))),
47+ snapshot: Ref.get(state),
48+ get stream() {
49+ return Stream.fromPubSub(pubsub);
50+ },
51+ } satisfies ServerLifecycleEventsShape;
52+ }),
53+);
apps/server/src/serverLogger.ts+7−11
@@ -1,20 +1,16 @@
1−import fs from "node:fs";
2−
3−import { Effect, Logger } from "effect";
4−import * as Layer from "effect/Layer";
1+import { Effect, Logger, References, Layer } from "effect";
52
63 import { ServerConfig } from "./config";
74
85 export const ServerLoggerLive = Effect.gen(function* () {
9− const { logsDir, serverLogPath } = yield* ServerConfig;
10−
11− yield* Effect.sync(() => {
12− fs.mkdirSync(logsDir, { recursive: true });
13− });
6+ const config = yield* ServerConfig;
7+ const { serverLogPath } = config;
148
159 const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath));
16−
17− return Logger.layer([Logger.defaultLogger, fileLogger], {
10+ const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel);
11+ const loggerLayer = Logger.layer([Logger.consolePretty(), fileLogger], {
1812 mergeWithExisting: false,
1913 });
14+
15+ return Layer.mergeAll(loggerLayer, minimumLogLevelLayer);
2016 }).pipe(Layer.unwrap);
apps/server/src/serverRuntimeStartup.test.ts+49−0
@@ -0,0 +1,49 @@
1+import { assert, it } from "@effect/vitest";
2+import { Deferred, Effect, Fiber, Ref } from "effect";
3+import { TestClock } from "effect/testing";
4+
5+import { makeCommandGate, ServerRuntimeStartupError } from "./serverRuntimeStartup.ts";
6+
7+it.effect("enqueueCommand waits for readiness and then drains queued work", () =>
8+ Effect.scoped(
9+ Effect.gen(function* () {
10+ const executionCount = yield* Ref.make(0);
11+ const commandGate = yield* makeCommandGate;
12+
13+ const queuedCommandFiber = yield* commandGate
14+ .enqueueCommand(Ref.updateAndGet(executionCount, (count) => count + 1))
15+ .pipe(Effect.forkScoped);
16+
17+ yield* TestClock.adjust("50 millis");
18+ assert.equal(yield* Ref.get(executionCount), 0);
19+
20+ yield* commandGate.signalCommandReady;
21+
22+ const result = yield* Fiber.join(queuedCommandFiber);
23+ assert.equal(result, 1);
24+ assert.equal(yield* Ref.get(executionCount), 1);
25+ }),
26+ ),
27+);
28+
29+it.effect("enqueueCommand fails queued work when readiness fails", () =>
30+ Effect.scoped(
31+ Effect.gen(function* () {
32+ const commandGate = yield* makeCommandGate;
33+ const failure = yield* Deferred.make<void, never>();
34+
35+ const queuedCommandFiber = yield* commandGate
36+ .enqueueCommand(Deferred.await(failure).pipe(Effect.as("should-not-run")))
37+ .pipe(Effect.forkScoped);
38+
39+ yield* commandGate.failCommandReady(
40+ new ServerRuntimeStartupError({
41+ message: "startup failed",
42+ }),
43+ );
44+
45+ const error = yield* Effect.flip(Fiber.join(queuedCommandFiber));
46+ assert.equal(error.message, "startup failed");
47+ }),
48+ ),
49+);
apps/server/src/serverRuntimeStartup.ts+334−0
@@ -0,0 +1,334 @@
1+import {
2+ CommandId,
3+ DEFAULT_PROVIDER_INTERACTION_MODE,
4+ type ModelSelection,
5+ ProjectId,
6+ ThreadId,
7+} from "@t3tools/contracts";
8+import { Data, Deferred, Effect, Exit, Layer, Path, Queue, Ref, Scope, ServiceMap } from "effect";
9+
10+import { ServerConfig } from "./config";
11+import { Keybindings } from "./keybindings";
12+import { Open } from "./open";
13+import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
14+import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
15+import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
16+import { ServerLifecycleEvents } from "./serverLifecycleEvents";
17+import { ServerSettingsService } from "./serverSettings";
18+import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
19+
20+const isWildcardHost = (host: string | undefined): boolean =>
21+ host === "0.0.0.0" || host === "::" || host === "[::]";
22+
23+const formatHostForUrl = (host: string): string =>
24+ host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
25+
26+export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{
27+ readonly message: string;
28+ readonly cause?: unknown;
29+}> {}
30+
31+export interface ServerRuntimeStartupShape {
32+ readonly awaitCommandReady: Effect.Effect<void, ServerRuntimeStartupError>;
33+ readonly markHttpListening: Effect.Effect<void>;
34+ readonly enqueueCommand: <A, E>(
35+ effect: Effect.Effect<A, E>,
36+ ) => Effect.Effect<A, E | ServerRuntimeStartupError>;
37+}
38+
39+export class ServerRuntimeStartup extends ServiceMap.Service<
40+ ServerRuntimeStartup,
41+ ServerRuntimeStartupShape
42+>()("t3/serverRuntimeStartup") {}
43+
44+interface QueuedCommand {
45+ readonly run: Effect.Effect<void, never>;
46+}
47+
48+type CommandReadinessState = "pending" | "ready" | ServerRuntimeStartupError;
49+
50+interface CommandGate {
51+ readonly awaitCommandReady: Effect.Effect<void, ServerRuntimeStartupError>;
52+ readonly signalCommandReady: Effect.Effect<void>;
53+ readonly failCommandReady: (error: ServerRuntimeStartupError) => Effect.Effect<void>;
54+ readonly enqueueCommand: <A, E>(
55+ effect: Effect.Effect<A, E>,
56+ ) => Effect.Effect<A, E | ServerRuntimeStartupError>;
57+}
58+
59+const settleQueuedCommand = <A, E>(deferred: Deferred.Deferred<A, E>, exit: Exit.Exit<A, E>) =>
60+ Exit.isSuccess(exit)
61+ ? Deferred.succeed(deferred, exit.value)
62+ : Deferred.failCause(deferred, exit.cause);
63+
64+export const makeCommandGate = Effect.gen(function* () {
65+ const commandReady = yield* Deferred.make<void, ServerRuntimeStartupError>();
66+ const commandQueue = yield* Queue.unbounded<QueuedCommand>();
67+ const commandReadinessState = yield* Ref.make<CommandReadinessState>("pending");
68+
69+ const commandWorker = Effect.forever(
70+ Queue.take(commandQueue).pipe(Effect.flatMap((command) => command.run)),
71+ );
72+ yield* Effect.forkScoped(commandWorker);
73+
74+ return {
75+ awaitCommandReady: Deferred.await(commandReady),
76+ signalCommandReady: Effect.gen(function* () {
77+ yield* Ref.set(commandReadinessState, "ready");
78+ yield* Deferred.succeed(commandReady, undefined).pipe(Effect.orDie);
79+ }),
80+ failCommandReady: (error) =>
81+ Effect.gen(function* () {
82+ yield* Ref.set(commandReadinessState, error);
83+ yield* Deferred.fail(commandReady, error).pipe(Effect.orDie);
84+ }),
85+ enqueueCommand: <A, E>(effect: Effect.Effect<A, E>) =>
86+ Effect.gen(function* () {
87+ const readinessState = yield* Ref.get(commandReadinessState);
88+ if (readinessState === "ready") {
89+ return yield* effect;
90+ }
91+ if (readinessState !== "pending") {
92+ return yield* readinessState;
93+ }
94+
95+ const result = yield* Deferred.make<A, E | ServerRuntimeStartupError>();
96+ yield* Queue.offer(commandQueue, {
97+ run: Deferred.await(commandReady).pipe(
98+ Effect.flatMap(() => effect),
99+ Effect.exit,
100+ Effect.flatMap((exit) => settleQueuedCommand(result, exit)),
101+ ),
102+ });
103+ return yield* Deferred.await(result);
104+ }),
105+ } satisfies CommandGate;
106+});
107+
108+const recordStartupHeartbeat = Effect.gen(function* () {
109+ const analytics = yield* AnalyticsService;
110+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
111+
112+ const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe(
113+ Effect.map((snapshot) => ({
114+ threadCount: snapshot.threads.length,
115+ projectCount: snapshot.projects.length,
116+ })),
117+ Effect.catch((cause) =>
118+ Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe(
119+ Effect.as({
120+ threadCount: 0,
121+ projectCount: 0,
122+ }),
123+ ),
124+ ),
125+ );
126+
127+ yield* analytics.record("server.boot.heartbeat", {
128+ threadCount,
129+ projectCount,
130+ });
131+});
132+
133+const autoBootstrapWelcome = Effect.gen(function* () {
134+ const serverConfig = yield* ServerConfig;
135+ const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
136+ const orchestrationEngine = yield* OrchestrationEngineService;
137+ const path = yield* Path.Path;
138+
139+ let bootstrapProjectId: ProjectId | undefined;
140+ let bootstrapThreadId: ThreadId | undefined;
141+
142+ if (serverConfig.autoBootstrapProjectFromCwd) {
143+ yield* Effect.gen(function* () {
144+ const snapshot = yield* projectionReadModelQuery.getSnapshot();
145+ const existingProject = snapshot.projects.find(
146+ (project) => project.workspaceRoot === serverConfig.cwd && project.deletedAt === null,
147+ );
148+ let nextProjectId: ProjectId;
149+ let nextProjectDefaultModelSelection: ModelSelection;
150+
151+ if (!existingProject) {
152+ const createdAt = new Date().toISOString();
153+ nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID());
154+ const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project";
155+ nextProjectDefaultModelSelection = {
156+ provider: "codex",
157+ model: "gpt-5-codex",
158+ };
159+ yield* orchestrationEngine.dispatch({
160+ type: "project.create",
161+ commandId: CommandId.makeUnsafe(crypto.randomUUID()),
162+ projectId: nextProjectId,
163+ title: bootstrapProjectTitle,
164+ workspaceRoot: serverConfig.cwd,
165+ defaultModelSelection: nextProjectDefaultModelSelection,
166+ createdAt,
167+ });
168+ } else {
169+ nextProjectId = existingProject.id;
170+ nextProjectDefaultModelSelection = existingProject.defaultModelSelection ?? {
171+ provider: "codex",
172+ model: "gpt-5-codex",
173+ };
174+ }
175+
176+ const existingThread = snapshot.threads.find(
177+ (thread) => thread.projectId === nextProjectId && thread.deletedAt === null,
178+ );
179+ if (!existingThread) {
180+ const createdAt = new Date().toISOString();
181+ const createdThreadId = ThreadId.makeUnsafe(crypto.randomUUID());
182+ yield* orchestrationEngine.dispatch({
183+ type: "thread.create",
184+ commandId: CommandId.makeUnsafe(crypto.randomUUID()),
185+ threadId: createdThreadId,
186+ projectId: nextProjectId,
187+ title: "New thread",
188+ modelSelection: nextProjectDefaultModelSelection,
189+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
190+ runtimeMode: "full-access",
191+ branch: null,
192+ worktreePath: null,
193+ createdAt,
194+ });
195+ bootstrapProjectId = nextProjectId;
196+ bootstrapThreadId = createdThreadId;
197+ } else {
198+ bootstrapProjectId = nextProjectId;
199+ bootstrapThreadId = existingThread.id;
200+ }
201+ });
202+ }
203+
204+ const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean);
205+ const projectName = segments[segments.length - 1] ?? "project";
206+
207+ return {
208+ cwd: serverConfig.cwd,
209+ projectName,
210+ ...(bootstrapProjectId ? { bootstrapProjectId } : {}),
211+ ...(bootstrapThreadId ? { bootstrapThreadId } : {}),
212+ } as const;
213+});
214+
215+const maybeOpenBrowser = Effect.gen(function* () {
216+ const serverConfig = yield* ServerConfig;
217+ if (serverConfig.noBrowser) {
218+ return;
219+ }
220+ const { openBrowser } = yield* Open;
221+ const localUrl = `http://localhost:${serverConfig.port}`;
222+ const bindUrl =
223+ serverConfig.host && !isWildcardHost(serverConfig.host)
224+ ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}`
225+ : localUrl;
226+ const target = serverConfig.devUrl?.toString() ?? bindUrl;
227+
228+ yield* openBrowser(target).pipe(
229+ Effect.catch(() =>
230+ Effect.logInfo("browser auto-open unavailable", {
231+ hint: `Open ${target} in your browser.`,
232+ }),
233+ ),
234+ );
235+});
236+
237+const makeServerRuntimeStartup = Effect.gen(function* () {
238+ const keybindings = yield* Keybindings;
239+ const orchestrationReactor = yield* OrchestrationReactor;
240+ const lifecycleEvents = yield* ServerLifecycleEvents;
241+ const serverSettings = yield* ServerSettingsService;
242+
243+ const commandGate = yield* makeCommandGate;
244+ const httpListening = yield* Deferred.make<void>();
245+ const reactorScope = yield* Scope.make("sequential");
246+
247+ yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void));
248+
249+ const startup = Effect.gen(function* () {
250+ yield* Effect.logDebug("startup phase: starting keybindings runtime");
251+ yield* keybindings.start.pipe(
252+ Effect.catch((error) =>
253+ Effect.logWarning("failed to start keybindings runtime", {
254+ path: error.configPath,
255+ detail: error.detail,
256+ cause: error.cause,
257+ }),
258+ ),
259+ Effect.forkScoped,
260+ );
261+
262+ yield* Effect.logDebug("startup phase: starting server settings runtime");
263+ yield* serverSettings.start.pipe(
264+ Effect.catch((error) =>
265+ Effect.logWarning("failed to start server settings runtime", {
266+ path: error.settingsPath,
267+ detail: error.detail,
268+ cause: error.cause,
269+ }),
270+ ),
271+ Effect.forkScoped,
272+ );
273+
274+ yield* Effect.logDebug("startup phase: starting orchestration reactors");
275+ yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope));
276+
277+ yield* Effect.logDebug("startup phase: preparing welcome payload");
278+ const welcome = yield* autoBootstrapWelcome;
279+ yield* Effect.logDebug("startup phase: publishing welcome event", {
280+ cwd: welcome.cwd,
281+ projectName: welcome.projectName,
282+ bootstrapProjectId: welcome.bootstrapProjectId,
283+ bootstrapThreadId: welcome.bootstrapThreadId,
284+ });
285+ yield* lifecycleEvents.publish({
286+ version: 1,
287+ type: "welcome",
288+ payload: welcome,
289+ });
290+ });
291+
292+ yield* Effect.forkScoped(
293+ Effect.gen(function* () {
294+ const startupExit = yield* Effect.exit(startup);
295+ if (Exit.isFailure(startupExit)) {
296+ const error = new ServerRuntimeStartupError({
297+ message: "Server runtime startup failed before command readiness.",
298+ cause: startupExit.cause,
299+ });
300+ yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause });
301+ yield* commandGate.failCommandReady(error);
302+ return;
303+ }
304+
305+ yield* Effect.logDebug("Accepting commands");
306+ yield* commandGate.signalCommandReady;
307+ yield* Effect.logDebug("startup phase: waiting for http listener");
308+ yield* Deferred.await(httpListening);
309+ yield* Effect.logDebug("startup phase: publishing ready event");
310+ yield* lifecycleEvents.publish({
311+ version: 1,
312+ type: "ready",
313+ payload: { at: new Date().toISOString() },
314+ });
315+
316+ yield* Effect.logDebug("startup phase: recording startup heartbeat");
317+ yield* recordStartupHeartbeat;
318+ yield* Effect.logDebug("startup phase: browser open check");
319+ yield* maybeOpenBrowser;
320+ yield* Effect.logDebug("startup phase: complete");
321+ }),
322+ );
323+
324+ return {
325+ awaitCommandReady: commandGate.awaitCommandReady,
326+ markHttpListening: Deferred.succeed(httpListening, undefined),
327+ enqueueCommand: commandGate.enqueueCommand,
328+ } satisfies ServerRuntimeStartupShape;
329+});
330+
331+export const ServerRuntimeStartupLive = Layer.effect(
332+ ServerRuntimeStartup,
333+ makeServerRuntimeStartup,
334+);
apps/server/src/serverSettings.ts+3−13
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 type ModelSelection,
1717 type ProviderKind,
1818 ServerSettings,
19+ ServerSettingsError,
1920 type ServerSettingsPatch,
2021 } from "@t3tools/contracts";
2122 import {
@@ -35,25 +36,13 @@ import { · 13 unmodified lines
3536 Scope,
3637 ServiceMap,
3738 Stream,
39+ Cause,
3840 } from "effect";
3941 import * as Semaphore from "effect/Semaphore";
4042 import { ServerConfig } from "./config";
4143 import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
4244 import { fromLenientJson } from "@t3tools/shared/schemaJson";
4345
44−export class ServerSettingsError extends Schema.TaggedErrorClass<ServerSettingsError>()(
45− "ServerSettingsError",
46− {
47− settingsPath: Schema.String,
48− detail: Schema.String,
49− cause: Schema.optional(Schema.Defect),
50− },
51−) {
52− override get message(): string {
53− return `Server settings error at ${this.settingsPath}: ${this.detail}`;
54− }
55−}
56−
5746 export interface ServerSettingsShape {
5847 /** Start the settings runtime and attach file watching. */
5948 readonly start: Effect.Effect<void, ServerSettingsError>;
@@ -215,6 +204,7 @@ const makeServerSettings = Effect.gen(function* () { · 155 unmodified lines
215204 if (decoded._tag === "Failure") {
216205 yield* Effect.logWarning("failed to parse settings.json, using defaults", {
217206 path: settingsPath,
207+ issues: Cause.pretty(decoded.cause),
218208 });
219209 return DEFAULT_SERVER_SETTINGS;
220210 }
apps/server/src/terminal/Services/Manager.ts+39−65
@@ -10,85 +10,59 @@ import { · 9 unmodified lines
1010 TerminalClearInput,
1111 TerminalCloseInput,
1212 TerminalEvent,
13+ TerminalCwdError,
14+ TerminalError,
15+ TerminalHistoryError,
16+ TerminalNotRunningError,
1317 TerminalOpenInput,
1418 TerminalResizeInput,
1519 TerminalRestartInput,
1620 TerminalSessionSnapshot,
21+ TerminalSessionLookupError,
22+ TerminalSessionStatus,
1723 TerminalWriteInput,
1824 } from "@t3tools/contracts";
19−import { Effect, Schema, ServiceMap } from "effect";
25+import { PtyProcess } from "./PTY";
26+import { Effect, ServiceMap } from "effect";
2027
21−export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()(
22− "TerminalCwdError",
23− {
24− cwd: Schema.String,
25− reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]),
26− cause: Schema.optional(Schema.Defect),
27− },
28−) {
29− override get message() {
30− if (this.reason === "notDirectory") {
31− return `Terminal cwd is not a directory: ${this.cwd}`;
32− }
33− if (this.reason === "notFound") {
34− return `Terminal cwd does not exist: ${this.cwd}`;
35− }
36− const causeMessage =
37− this.cause && typeof this.cause === "object" && "message" in this.cause
38− ? this.cause.message
39− : undefined;
40− return causeMessage
41− ? `Failed to access terminal cwd: ${this.cwd} (${causeMessage})`
42− : `Failed to access terminal cwd: ${this.cwd}`;
43− }
44−}
28+export {
29+ TerminalCwdError,
30+ TerminalError,
31+ TerminalHistoryError,
32+ TerminalNotRunningError,
33+ TerminalSessionLookupError,
34+};
4535
46−export class TerminalHistoryError extends Schema.TaggedErrorClass<TerminalHistoryError>()(
47− "TerminalHistoryError",
48− {
49− operation: Schema.Literals(["read", "truncate", "migrate"]),
50− threadId: Schema.String,
51− terminalId: Schema.String,
52− cause: Schema.optional(Schema.Defect),
53− },
54−) {
55− override get message() {
56− return `Failed to ${this.operation} terminal history for thread: ${this.threadId}, terminal: ${this.terminalId}`;
57− }
36+export interface TerminalSessionState {
37+ threadId: string;
38+ terminalId: string;
39+ cwd: string;
40+ status: TerminalSessionStatus;
41+ pid: number | null;
42+ history: string;
43+ pendingHistoryControlSequence: string;
44+ exitCode: number | null;
45+ exitSignal: number | null;
46+ updatedAt: string;
47+ cols: number;
48+ rows: number;
49+ process: PtyProcess | null;
50+ unsubscribeData: (() => void) | null;
51+ unsubscribeExit: (() => void) | null;
52+ hasRunningSubprocess: boolean;
53+ runtimeEnv: Record<string, string> | null;
5854 }
5955
60−export class TerminalSessionLookupError extends Schema.TaggedErrorClass<TerminalSessionLookupError>()(
61− "TerminalSessionLookupError",
62− {
63− threadId: Schema.String,
64− terminalId: Schema.String,
65− },
66−) {
67− override get message() {
68− return `Unknown terminal thread: ${this.threadId}, terminal: ${this.terminalId}`;
69− }
56+export interface ShellCandidate {
57+ shell: string;
58+ args?: string[];
7059 }
7160
72−export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNotRunningError>()(
73− "TerminalNotRunningError",
74− {
75− threadId: Schema.String,
76− terminalId: Schema.String,
77− },
78−) {
79− override get message() {
80− return `Terminal is not running for thread: ${this.threadId}, terminal: ${this.terminalId}`;
81− }
61+export interface TerminalStartInput extends TerminalOpenInput {
62+ cols: number;
63+ rows: number;
8264 }
8365
84−export const TerminalError = Schema.Union([
85− TerminalCwdError,
86− TerminalHistoryError,
87− TerminalSessionLookupError,
88− TerminalNotRunningError,
89−]);
90−export type TerminalError = typeof TerminalError.Type;
91−
9266 /**
9367 * TerminalManagerShape - Service API for terminal session lifecycle operations.
9468 */
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+3−1
@@ -9,9 +9,11 @@ import { GitCoreLive } from "../../git/Layers/GitCore.ts"; · 8 unmodified lines
99 import { GitCore } from "../../git/Services/GitCore.ts";
1010 import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";
1111 import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts";
12+import { WorkspacePathsLive } from "./WorkspacePaths.ts";
1213
1314 const TestLayer = Layer.empty.pipe(
14− Layer.provideMerge(WorkspaceEntriesLive),
15+ Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
16+ Layer.provideMerge(WorkspacePathsLive),
1517 Layer.provideMerge(GitCoreLive),
1618 Layer.provide(
1719 ServerConfig.layerTest(process.cwd(), {
apps/server/src/workspace/Layers/WorkspaceEntries.ts+27−2
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 WorkspaceEntriesError,
1212 type WorkspaceEntriesShape,
1313 } from "../Services/WorkspaceEntries.ts";
14+import { WorkspacePaths } from "../Services/WorkspacePaths.ts";
1415
1516 const WORKSPACE_CACHE_TTL_MS = 15_000;
1617 const WORKSPACE_CACHE_MAX_KEYS = 4;
@@ -219,6 +220,7 @@ const processErrorDetail = (cause: unknown): string => · 202 unmodified lines
219220 export const makeWorkspaceEntries = Effect.gen(function* () {
220221 const path = yield* Path.Path;
221222 const gitOption = yield* Effect.serviceOption(GitCore);
223+ const workspacePaths = yield* WorkspacePaths;
222224
223225 const isInsideGitWorkTree = (cwd: string): Effect.Effect<boolean> =>
224226 Option.match(gitOption, {
@@ -435,15 +437,38 @@ export const makeWorkspaceEntries = Effect.gen(function* () { · 210 unmodified lines
435437 Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero,
436438 });
437439
440+ const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* (
441+ cwd: string,
442+ ): Effect.fn.Return<string, WorkspaceEntriesError> {
443+ return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
444+ Effect.mapError(
445+ (cause) =>
446+ new WorkspaceEntriesError({
447+ cwd,
448+ operation: "workspaceEntries.normalizeWorkspaceRoot",
449+ detail: cause.message,
450+ cause,
451+ }),
452+ ),
453+ );
454+ });
455+
438456 const invalidate: WorkspaceEntriesShape["invalidate"] = Effect.fn("WorkspaceEntries.invalidate")(
439457 function* (cwd) {
440− return yield* Cache.invalidate(workspaceIndexCache, cwd);
458+ const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe(
459+ Effect.catch(() => Effect.succeed(cwd)),
460+ );
461+ yield* Cache.invalidate(workspaceIndexCache, cwd);
462+ if (normalizedCwd !== cwd) {
463+ yield* Cache.invalidate(workspaceIndexCache, normalizedCwd);
464+ }
441465 },
442466 );
443467
444468 const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")(
445469 function* (input) {
446− return yield* Cache.get(workspaceIndexCache, input.cwd).pipe(
470+ const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
471+ return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe(
447472 Effect.map((index) => {
448473 const normalizedQuery = normalizeQuery(input.query);
449474 const limit = Math.max(0, Math.floor(input.limit));
apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts+3−2
@@ -12,12 +12,13 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts"; · 11 unmodified lines
1212
1313 const ProjectLayer = WorkspaceFileSystemLive.pipe(
1414 Layer.provide(WorkspacePathsLive),
15− Layer.provide(WorkspaceEntriesLive),
15+ Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
1616 );
1717
1818 const TestLayer = Layer.empty.pipe(
1919 Layer.provideMerge(ProjectLayer),
20− Layer.provideMerge(WorkspaceEntriesLive),
20+ Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
21+ Layer.provideMerge(WorkspacePathsLive),
2122 Layer.provideMerge(GitCoreLive),
2223 Layer.provide(
2324 ServerConfig.layerTest(process.cwd(), {
apps/server/src/ws.ts+343−0
@@ -0,0 +1,343 @@
1+import { Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect";
2+import {
3+ type GitActionProgressEvent,
4+ type GitManagerServiceError,
5+ OrchestrationDispatchCommandError,
6+ type OrchestrationEvent,
7+ OrchestrationGetFullThreadDiffError,
8+ OrchestrationGetSnapshotError,
9+ OrchestrationGetTurnDiffError,
10+ ORCHESTRATION_WS_METHODS,
11+ ProjectSearchEntriesError,
12+ ProjectWriteFileError,
13+ OrchestrationReplayEventsError,
14+ type TerminalEvent,
15+ WS_METHODS,
16+ WsRpcGroup,
17+} from "@t3tools/contracts";
18+import { clamp } from "effect/Number";
19+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
20+import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
21+
22+import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
23+import { ServerConfig } from "./config";
24+import { GitCore } from "./git/Services/GitCore";
25+import { GitManager } from "./git/Services/GitManager";
26+import { Keybindings } from "./keybindings";
27+import { Open, resolveAvailableEditors } from "./open";
28+import { normalizeDispatchCommand } from "./orchestration/Normalizer";
29+import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
30+import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
31+import { ProviderRegistry } from "./provider/Services/ProviderRegistry";
32+import { ServerLifecycleEvents } from "./serverLifecycleEvents";
33+import { ServerRuntimeStartup } from "./serverRuntimeStartup";
34+import { ServerSettingsService } from "./serverSettings";
35+import { TerminalManager } from "./terminal/Services/Manager";
36+import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries";
37+import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem";
38+import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths";
39+
40+const WsRpcLayer = WsRpcGroup.toLayer(
41+ Effect.gen(function* () {
42+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
43+ const orchestrationEngine = yield* OrchestrationEngineService;
44+ const checkpointDiffQuery = yield* CheckpointDiffQuery;
45+ const keybindings = yield* Keybindings;
46+ const open = yield* Open;
47+ const gitManager = yield* GitManager;
48+ const git = yield* GitCore;
49+ const terminalManager = yield* TerminalManager;
50+ const providerRegistry = yield* ProviderRegistry;
51+ const config = yield* ServerConfig;
52+ const lifecycleEvents = yield* ServerLifecycleEvents;
53+ const serverSettings = yield* ServerSettingsService;
54+ const startup = yield* ServerRuntimeStartup;
55+ const workspaceEntries = yield* WorkspaceEntries;
56+ const workspaceFileSystem = yield* WorkspaceFileSystem;
57+
58+ const loadServerConfig = Effect.gen(function* () {
59+ const keybindingsConfig = yield* keybindings.loadConfigState;
60+ const providers = yield* providerRegistry.getProviders;
61+ const settings = yield* serverSettings.getSettings;
62+
63+ return {
64+ cwd: config.cwd,
65+ keybindingsConfigPath: config.keybindingsConfigPath,
66+ keybindings: keybindingsConfig.keybindings,
67+ issues: keybindingsConfig.issues,
68+ providers,
69+ availableEditors: resolveAvailableEditors(),
70+ settings,
71+ };
72+ });
73+
74+ return WsRpcGroup.of({
75+ [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) =>
76+ projectionSnapshotQuery.getSnapshot().pipe(
77+ Effect.mapError(
78+ (cause) =>
79+ new OrchestrationGetSnapshotError({
80+ message: "Failed to load orchestration snapshot",
81+ cause,
82+ }),
83+ ),
84+ ),
85+ [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) =>
86+ Effect.gen(function* () {
87+ const normalizedCommand = yield* normalizeDispatchCommand(command);
88+ return yield* startup.enqueueCommand(orchestrationEngine.dispatch(normalizedCommand));
89+ }).pipe(
90+ Effect.mapError((cause) =>
91+ Schema.is(OrchestrationDispatchCommandError)(cause)
92+ ? cause
93+ : new OrchestrationDispatchCommandError({
94+ message: "Failed to dispatch orchestration command",
95+ cause,
96+ }),
97+ ),
98+ ),
99+ [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) =>
100+ checkpointDiffQuery.getTurnDiff(input).pipe(
101+ Effect.mapError(
102+ (cause) =>
103+ new OrchestrationGetTurnDiffError({
104+ message: "Failed to load turn diff",
105+ cause,
106+ }),
107+ ),
108+ ),
109+ [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) =>
110+ checkpointDiffQuery.getFullThreadDiff(input).pipe(
111+ Effect.mapError(
112+ (cause) =>
113+ new OrchestrationGetFullThreadDiffError({
114+ message: "Failed to load full thread diff",
115+ cause,
116+ }),
117+ ),
118+ ),
119+ [ORCHESTRATION_WS_METHODS.replayEvents]: (input) =>
120+ Stream.runCollect(
121+ orchestrationEngine.readEvents(
122+ clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }),
123+ ),
124+ ).pipe(
125+ Effect.map((events) => Array.from(events)),
126+ Effect.mapError(
127+ (cause) =>
128+ new OrchestrationReplayEventsError({
129+ message: "Failed to replay orchestration events",
130+ cause,
131+ }),
132+ ),
133+ ),
134+ [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) =>
135+ Stream.unwrap(
136+ Effect.gen(function* () {
137+ const snapshot = yield* orchestrationEngine.getReadModel();
138+ const fromSequenceExclusive = snapshot.snapshotSequence;
139+ const replayEvents: Array<OrchestrationEvent> = yield* Stream.runCollect(
140+ orchestrationEngine.readEvents(fromSequenceExclusive),
141+ ).pipe(
142+ Effect.map((events) => Array.from(events)),
143+ Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),
144+ );
145+ const replayStream = Stream.fromIterable(replayEvents);
146+ const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents);
147+ type SequenceState = {
148+ readonly nextSequence: number;
149+ readonly pendingBySequence: Map<number, OrchestrationEvent>;
150+ };
151+ const state = yield* Ref.make<SequenceState>({
152+ nextSequence: fromSequenceExclusive + 1,
153+ pendingBySequence: new Map<number, OrchestrationEvent>(),
154+ });
155+
156+ return source.pipe(
157+ Stream.mapEffect((event) =>
158+ Ref.modify(
159+ state,
160+ ({
161+ nextSequence,
162+ pendingBySequence,
163+ }): [Array<OrchestrationEvent>, SequenceState] => {
164+ if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) {
165+ return [[], { nextSequence, pendingBySequence }];
166+ }
167+
168+ const updatedPending = new Map(pendingBySequence);
169+ updatedPending.set(event.sequence, event);
170+
171+ const emit: Array<OrchestrationEvent> = [];
172+ let expected = nextSequence;
173+ for (;;) {
174+ const expectedEvent = updatedPending.get(expected);
175+ if (!expectedEvent) {
176+ break;
177+ }
178+ emit.push(expectedEvent);
179+ updatedPending.delete(expected);
180+ expected += 1;
181+ }
182+
183+ return [emit, { nextSequence: expected, pendingBySequence: updatedPending }];
184+ },
185+ ),
186+ ),
187+ Stream.flatMap((events) => Stream.fromIterable(events)),
188+ );
189+ }),
190+ ),
191+ [WS_METHODS.serverGetConfig]: (_input) => loadServerConfig,
192+ [WS_METHODS.serverRefreshProviders]: (_input) =>
193+ providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))),
194+ [WS_METHODS.serverUpsertKeybinding]: (rule) =>
195+ Effect.gen(function* () {
196+ const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule);
197+ return { keybindings: keybindingsConfig, issues: [] };
198+ }),
199+ [WS_METHODS.serverGetSettings]: (_input) => serverSettings.getSettings,
200+ [WS_METHODS.serverUpdateSettings]: ({ patch }) => serverSettings.updateSettings(patch),
201+ [WS_METHODS.projectsSearchEntries]: (input) =>
202+ workspaceEntries.search(input).pipe(
203+ Effect.mapError(
204+ (cause) =>
205+ new ProjectSearchEntriesError({
206+ message: `Failed to search workspace entries: ${cause.detail}`,
207+ cause,
208+ }),
209+ ),
210+ ),
211+ [WS_METHODS.projectsWriteFile]: (input) =>
212+ workspaceFileSystem.writeFile(input).pipe(
213+ Effect.mapError((cause) => {
214+ const message = Schema.is(WorkspacePathOutsideRootError)(cause)
215+ ? "Workspace file path must stay within the project root."
216+ : "Failed to write workspace file";
217+ return new ProjectWriteFileError({
218+ message,
219+ cause,
220+ });
221+ }),
222+ ),
223+ [WS_METHODS.shellOpenInEditor]: (input) => open.openInEditor(input),
224+ [WS_METHODS.gitStatus]: (input) => gitManager.status(input),
225+ [WS_METHODS.gitPull]: (input) => git.pullCurrentBranch(input.cwd),
226+ [WS_METHODS.gitRunStackedAction]: (input) =>
227+ Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) =>
228+ gitManager
229+ .runStackedAction(input, {
230+ actionId: input.actionId,
231+ progressReporter: {
232+ publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid),
233+ },
234+ })
235+ .pipe(
236+ Effect.matchCauseEffect({
237+ onFailure: (cause) => Queue.failCause(queue, cause),
238+ onSuccess: () => Queue.end(queue).pipe(Effect.asVoid),
239+ }),
240+ ),
241+ ),
242+ [WS_METHODS.gitResolvePullRequest]: (input) => gitManager.resolvePullRequest(input),
243+ [WS_METHODS.gitPreparePullRequestThread]: (input) =>
244+ gitManager.preparePullRequestThread(input),
245+ [WS_METHODS.gitListBranches]: (input) => git.listBranches(input),
246+ [WS_METHODS.gitCreateWorktree]: (input) => git.createWorktree(input),
247+ [WS_METHODS.gitRemoveWorktree]: (input) => git.removeWorktree(input),
248+ [WS_METHODS.gitCreateBranch]: (input) => git.createBranch(input),
249+ [WS_METHODS.gitCheckout]: (input) => Effect.scoped(git.checkoutBranch(input)),
250+ [WS_METHODS.gitInit]: (input) => git.initRepo(input),
251+ [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input),
252+ [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input),
253+ [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input),
254+ [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input),
255+ [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input),
256+ [WS_METHODS.terminalClose]: (input) => terminalManager.close(input),
257+ [WS_METHODS.subscribeTerminalEvents]: (_input) =>
258+ Stream.callback<TerminalEvent>((queue) =>
259+ Effect.acquireRelease(
260+ terminalManager.subscribe((event) => Queue.offer(queue, event)),
261+ (unsubscribe) => Effect.sync(unsubscribe),
262+ ),
263+ ),
264+ [WS_METHODS.subscribeServerConfig]: (_input) =>
265+ Stream.unwrap(
266+ Effect.gen(function* () {
267+ const keybindingsUpdates = keybindings.streamChanges.pipe(
268+ Stream.map((event) => ({
269+ version: 1 as const,
270+ type: "keybindingsUpdated" as const,
271+ payload: {
272+ issues: event.issues,
273+ },
274+ })),
275+ );
276+ const providerStatuses = providerRegistry.streamChanges.pipe(
277+ Stream.map((providers) => ({
278+ version: 1 as const,
279+ type: "providerStatuses" as const,
280+ payload: { providers },
281+ })),
282+ );
283+ const settingsUpdates = serverSettings.streamChanges.pipe(
284+ Stream.map((settings) => ({
285+ version: 1 as const,
286+ type: "settingsUpdated" as const,
287+ payload: { settings },
288+ })),
289+ );
290+
291+ return Stream.concat(
292+ Stream.make({
293+ version: 1 as const,
294+ type: "snapshot" as const,
295+ config: yield* loadServerConfig,
296+ }),
297+ Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)),
298+ );
299+ }),
300+ ),
301+ [WS_METHODS.subscribeServerLifecycle]: (_input) =>
302+ Stream.unwrap(
303+ Effect.gen(function* () {
304+ const snapshot = yield* lifecycleEvents.snapshot;
305+ const snapshotEvents = Array.from(snapshot.events).toSorted(
306+ (left, right) => left.sequence - right.sequence,
307+ );
308+ const liveEvents = lifecycleEvents.stream.pipe(
309+ Stream.filter((event) => event.sequence > snapshot.sequence),
310+ );
311+ return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents);
312+ }),
313+ ),
314+ });
315+ }),
316+);
317+
318+export const websocketRpcRouteLayer = Layer.unwrap(
319+ Effect.gen(function* () {
320+ const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup).pipe(
321+ Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson)),
322+ );
323+ return HttpRouter.add(
324+ "GET",
325+ "/ws",
326+ Effect.gen(function* () {
327+ const request = yield* HttpServerRequest.HttpServerRequest;
328+ const config = yield* ServerConfig;
329+ if (config.authToken) {
330+ const url = HttpServerRequest.toURL(request);
331+ if (Option.isNone(url)) {
332+ return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 });
333+ }
334+ const token = url.value.searchParams.get("token");
335+ if (token !== config.authToken) {
336+ return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 });
337+ }
338+ }
339+ return yield* rpcWebSocketHttpEffect;
340+ }),
341+ );
342+ }),
343+);
apps/server/src/wsServer.test.ts+0−2069
@@ -1,2069 +0,0 @@
1−import * as Http from "node:http";
2−import fs from "node:fs";
3−import os from "node:os";
4−import path from "node:path";
5−
6−import * as NodeServices from "@effect/platform-node/NodeServices";
7−import {
8− Effect,
9− Exit,
10− Fiber,
11− Layer,
12− ManagedRuntime,
13− PlatformError,
14− PubSub,
15− Scope,
16− Stream,
17−} from "effect";
18−import { describe, expect, it, afterEach, vi } from "vitest";
19−import { createServer } from "./wsServer";
20−import WebSocket from "ws";
21−import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config";
22−import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
23−
24−import {
25− DEFAULT_TERMINAL_ID,
26− DEFAULT_SERVER_SETTINGS,
27− EDITORS,
28− EventId,
29− ORCHESTRATION_WS_CHANNELS,
30− ORCHESTRATION_WS_METHODS,
31− ProviderItemId,
32− type ServerSettings,
33− ThreadId,
34− TurnId,
35− WS_CHANNELS,
36− WS_METHODS,
37− type WebSocketResponse,
38− type ProviderRuntimeEvent,
39− type ServerProvider,
40− type KeybindingsConfig,
41− type ResolvedKeybindingsConfig,
42− type WsPushChannel,
43− type WsPushMessage,
44− type WsPush,
45−} from "@t3tools/contracts";
46−import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
47−import type {
48− TerminalClearInput,
49− TerminalCloseInput,
50− TerminalEvent,
51− TerminalOpenInput,
52− TerminalResizeInput,
53− TerminalSessionSnapshot,
54− TerminalWriteInput,
55−} from "@t3tools/contracts";
56−import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager";
57−import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
58−import { SqlClient, SqlError } from "effect/unstable/sql";
59−import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
60−import { ProviderRegistry, type ProviderRegistryShape } from "./provider/Services/ProviderRegistry";
61−import { Open, type OpenShape } from "./open";
62−import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
63−import type { GitCoreShape } from "./git/Services/GitCore.ts";
64−import { GitCore } from "./git/Services/GitCore.ts";
65−import { GitCommandError, GitManagerError } from "./git/Errors.ts";
66−import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
67−import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
68−import { ServerSettingsService } from "./serverSettings.ts";
69−
70−const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
71−const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
72−const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
73−const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
74−
75−const defaultOpenService: OpenShape = {
76− openBrowser: () => Effect.void,
77− openInEditor: () => Effect.void,
78−};
79−
80−const defaultProviderStatuses: ReadonlyArray<ServerProvider> = [
81− {
82− provider: "codex",
83− enabled: true,
84− installed: true,
85− version: "0.116.0",
86− status: "ready",
87− auth: { status: "authenticated" },
88− checkedAt: "2026-01-01T00:00:00.000Z",
89− models: [],
90− },
91−];
92−
93−const defaultProviderRegistryService: ProviderRegistryShape = {
94− getProviders: Effect.succeed(defaultProviderStatuses),
95− refresh: () => Effect.succeed(defaultProviderStatuses),
96− streamChanges: Stream.empty,
97−};
98−
99−const defaultServerSettings = DEFAULT_SERVER_SETTINGS;
100−
101−class MockTerminalManager implements TerminalManagerShape {
102− private readonly sessions = new Map<string, TerminalSessionSnapshot>();
103− private readonly eventPubSub = Effect.runSync(PubSub.unbounded<TerminalEvent>());
104− private activeSubscriptions = 0;
105−
106− private key(threadId: string, terminalId: string): string {
107− return `${threadId}\u0000${terminalId}`;
108− }
109−
110− emitEvent(event: TerminalEvent): void {
111− Effect.runSync(PubSub.publish(this.eventPubSub, event));
112− }
113−
114− subscriptionCount(): number {
115− return this.activeSubscriptions;
116− }
117−
118− readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) =>
119− Effect.sync(() => {
120− const now = new Date().toISOString();
121− const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
122− const snapshot: TerminalSessionSnapshot = {
123− threadId: input.threadId,
124− terminalId,
125− cwd: input.cwd,
126− status: "running",
127− pid: 4242,
128− history: "",
129− exitCode: null,
130− exitSignal: null,
131− updatedAt: now,
132− };
133− this.sessions.set(this.key(input.threadId, terminalId), snapshot);
134− queueMicrotask(() => {
135− this.emitEvent({
136− type: "started",
137− threadId: input.threadId,
138− terminalId,
139− createdAt: now,
140− snapshot,
141− });
142− });
143− return snapshot;
144− });
145−
146− readonly write: TerminalManagerShape["write"] = (input: TerminalWriteInput) =>
147− Effect.sync(() => {
148− const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
149− const existing = this.sessions.get(this.key(input.threadId, terminalId));
150− if (!existing) {
151− throw new Error(`Unknown terminal thread: ${input.threadId}`);
152− }
153− queueMicrotask(() => {
154− this.emitEvent({
155− type: "output",
156− threadId: input.threadId,
157− terminalId,
158− createdAt: new Date().toISOString(),
159− data: input.data,
160− });
161− });
162− });
163−
164− readonly resize: TerminalManagerShape["resize"] = (_input: TerminalResizeInput) => Effect.void;
165−
166− readonly clear: TerminalManagerShape["clear"] = (input: TerminalClearInput) =>
167− Effect.sync(() => {
168− const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
169− queueMicrotask(() => {
170− this.emitEvent({
171− type: "cleared",
172− threadId: input.threadId,
173− terminalId,
174− createdAt: new Date().toISOString(),
175− });
176− });
177− });
178−
179− readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) =>
180− Effect.sync(() => {
181− const now = new Date().toISOString();
182− const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
183− const snapshot: TerminalSessionSnapshot = {
184− threadId: input.threadId,
185− terminalId,
186− cwd: input.cwd,
187− status: "running",
188− pid: 5252,
189− history: "",
190− exitCode: null,
191− exitSignal: null,
192− updatedAt: now,
193− };
194− this.sessions.set(this.key(input.threadId, terminalId), snapshot);
195− queueMicrotask(() => {
196− this.emitEvent({
197− type: "restarted",
198− threadId: input.threadId,
199− terminalId,
200− createdAt: now,
201− snapshot,
202− });
203− });
204− return snapshot;
205− });
206−
207− readonly close: TerminalManagerShape["close"] = (input: TerminalCloseInput) =>
208− Effect.sync(() => {
209− if (input.terminalId) {
210− this.sessions.delete(this.key(input.threadId, input.terminalId));
211− return;
212− }
213− for (const key of this.sessions.keys()) {
214− if (key.startsWith(`${input.threadId}\u0000`)) {
215− this.sessions.delete(key);
216− }
217− }
218− });
219−
220− readonly subscribe: TerminalManagerShape["subscribe"] = (listener) =>
221− Effect.sync(() => {
222− this.activeSubscriptions += 1;
223− const fiber = Effect.runFork(
224− Stream.runForEach(Stream.fromPubSub(this.eventPubSub), (event) => listener(event)),
225− );
226− return () => {
227− this.activeSubscriptions -= 1;
228− Effect.runFork(Fiber.interrupt(fiber).pipe(Effect.ignore));
229− };
230− });
231−}
232−
233−// ---------------------------------------------------------------------------
234−// WebSocket test harness
235−//
236−// Incoming messages are split into two channels:
237−// - pushChannel: server push envelopes (type === "push")
238−// - responseChannel: request/response envelopes (have an "id" field)
239−//
240−// This means sendRequest never has to skip push messages and waitForPush
241−// never has to skip response messages, eliminating a class of ordering bugs.
242−// ---------------------------------------------------------------------------
243−
244−interface MessageChannel<T> {
245− queue: T[];
246− waiters: Array<{
247− resolve: (value: T) => void;
248− reject: (error: Error) => void;
249− timeoutId: ReturnType<typeof setTimeout> | null;
250− }>;
251−}
252−
253−interface SocketChannels {
254− push: MessageChannel<WsPush>;
255− response: MessageChannel<WebSocketResponse>;
256−}
257−
258−const channelsBySocket = new WeakMap<WebSocket, SocketChannels>();
259−
260−function enqueue<T>(channel: MessageChannel<T>, item: T) {
261− const waiter = channel.waiters.shift();
262− if (waiter) {
263− if (waiter.timeoutId !== null) clearTimeout(waiter.timeoutId);
264− waiter.resolve(item);
265− return;
266− }
267− channel.queue.push(item);
268−}
269−
270−function dequeue<T>(channel: MessageChannel<T>, timeoutMs: number): Promise<T> {
271− const queued = channel.queue.shift();
272− if (queued !== undefined) {
273− return Promise.resolve(queued);
274− }
275−
276− return new Promise((resolve, reject) => {
277− const waiter = {
278− resolve,
279− reject,
280− timeoutId: setTimeout(() => {
281− const index = channel.waiters.indexOf(waiter);
282− if (index >= 0) channel.waiters.splice(index, 1);
283− reject(new Error(`Timed out waiting for WebSocket message after ${timeoutMs}ms`));
284− }, timeoutMs) as ReturnType<typeof setTimeout>,
285− };
286− channel.waiters.push(waiter);
287− });
288−}
289−
290−function isWsPushEnvelope(message: unknown): message is WsPush {
291− if (typeof message !== "object" || message === null) return false;
292− if (!("type" in message) || !("channel" in message)) return false;
293− return (message as { type?: unknown }).type === "push";
294−}
295−
296−function asWebSocketResponse(message: unknown): WebSocketResponse | null {
297− if (typeof message !== "object" || message === null) return null;
298− if (!("id" in message)) return null;
299− const id = (message as { id?: unknown }).id;
300− if (typeof id !== "string") return null;
301− return message as WebSocketResponse;
302−}
303−
304−function connectWsOnce(port: number, token?: string): Promise<WebSocket> {
305− return new Promise((resolve, reject) => {
306− const query = token ? `?token=${encodeURIComponent(token)}` : "";
307− const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`);
308− const channels: SocketChannels = {
309− push: { queue: [], waiters: [] },
310− response: { queue: [], waiters: [] },
311− };
312− channelsBySocket.set(ws, channels);
313−
314− ws.on("message", (raw) => {
315− const parsed = JSON.parse(String(raw));
316− if (isWsPushEnvelope(parsed)) {
317− enqueue(channels.push, parsed);
318− } else {
319− const response = asWebSocketResponse(parsed);
320− if (response) {
321− enqueue(channels.response, response);
322− }
323− }
324− });
325−
326− ws.once("open", () => resolve(ws));
327− ws.once("error", () => reject(new Error("WebSocket connection failed")));
328− });
329−}
330−
331−async function connectWs(port: number, token?: string, attempts = 5): Promise<WebSocket> {
332− let lastError: unknown = new Error("WebSocket connection failed");
333−
334− for (let attempt = 0; attempt < attempts; attempt += 1) {
335− try {
336− return await connectWsOnce(port, token);
337− } catch (error) {
338− lastError = error;
339− if (attempt < attempts - 1) {
340− await new Promise((resolve) => setTimeout(resolve, 25));
341− }
342− }
343− }
344−
345− throw lastError;
346−}
347−
348−/** Connect and wait for the server.welcome push. Returns [ws, welcomeData]. */
349−async function connectAndAwaitWelcome(
350− port: number,
351− token?: string,
352−): Promise<[WebSocket, WsPushMessage<typeof WS_CHANNELS.serverWelcome>]> {
353− const ws = await connectWs(port, token);
354− const welcome = await waitForPush(ws, WS_CHANNELS.serverWelcome);
355− return [ws, welcome];
356−}
357−
358−async function sendRequest(
359− ws: WebSocket,
360− method: string,
361− params?: unknown,
362−): Promise<WebSocketResponse> {
363− const channels = channelsBySocket.get(ws);
364− if (!channels) throw new Error("WebSocket not initialized");
365−
366− const id = crypto.randomUUID();
367− const body =
368− method === ORCHESTRATION_WS_METHODS.dispatchCommand
369− ? { _tag: method, command: params }
370− : params && typeof params === "object" && !Array.isArray(params)
371− ? { _tag: method, ...(params as Record<string, unknown>) }
372− : { _tag: method };
373− ws.send(JSON.stringify({ id, body }));
374−
375− // Response channel only contains responses — no push filtering needed
376− while (true) {
377− const response = await dequeue(channels.response, 60_000);
378− if (response.id === id || response.id === "unknown") {
379− return response;
380− }
381− }
382−}
383−
384−async function waitForPush<C extends WsPushChannel>(
385− ws: WebSocket,
386− channel: C,
387− predicate?: (push: WsPushMessage<C>) => boolean,
388− maxMessages = 120,
389− idleTimeoutMs = 5_000,
390−): Promise<WsPushMessage<C>> {
391− const channels = channelsBySocket.get(ws);
392− if (!channels) throw new Error("WebSocket not initialized");
393−
394− for (let remaining = maxMessages; remaining > 0; remaining--) {
395− const push = await dequeue(channels.push, idleTimeoutMs);
396− if (push.channel !== channel) continue;
397− const typed = push as WsPushMessage<C>;
398− if (!predicate || predicate(typed)) return typed;
399− }
400− throw new Error(`Timed out waiting for push on ${channel}`);
401−}
402−
403−async function rewriteKeybindingsAndWaitForPush(
404− ws: WebSocket,
405− keybindingsPath: string,
406− contents: string,
407− predicate: (push: WsPushMessage<typeof WS_CHANNELS.serverConfigUpdated>) => boolean,
408− attempts = 3,
409−): Promise<WsPushMessage<typeof WS_CHANNELS.serverConfigUpdated>> {
410− let lastError: unknown;
411− for (let attempt = 0; attempt < attempts; attempt++) {
412− fs.writeFileSync(keybindingsPath, contents, "utf8");
413− try {
414− return await waitForPush(ws, WS_CHANNELS.serverConfigUpdated, predicate, 20, 3_000);
415− } catch (error) {
416− lastError = error;
417− }
418− }
419− throw lastError;
420−}
421−
422−async function requestPath(
423− port: number,
424− requestPath: string,
425−): Promise<{ statusCode: number; body: string }> {
426− return new Promise((resolve, reject) => {
427− const req = Http.request(
428− {
429− hostname: "127.0.0.1",
430− port,
431− path: requestPath,
432− method: "GET",
433− },
434− (res) => {
435− const chunks: Buffer[] = [];
436− res.on("data", (chunk) => {
437− chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
438− });
439− res.on("end", () => {
440− resolve({
441− statusCode: res.statusCode ?? 0,
442− body: Buffer.concat(chunks).toString("utf8"),
443− });
444− });
445− },
446− );
447− req.once("error", reject);
448− req.end();
449− });
450−}
451−
452−function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
453− const resolved: Array<ResolvedKeybindingsConfig[number]> = [];
454− for (const binding of bindings) {
455− const compiled = compileResolvedKeybindingRule(binding);
456− if (!compiled) {
457− throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`);
458− }
459− resolved.push(compiled);
460− }
461− return resolved;
462−}
463−
464−const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);
465−const VALID_EDITOR_IDS = new Set(EDITORS.map((editor) => editor.id));
466−
467−function expectAvailableEditors(value: unknown): void {
468− expect(Array.isArray(value)).toBe(true);
469− for (const editorId of value as unknown[]) {
470− expect(typeof editorId).toBe("string");
471− expect(VALID_EDITOR_IDS.has(editorId as (typeof EDITORS)[number]["id"])).toBe(true);
472− }
473−}
474−
475−function ensureParentDir(filePath: string): void {
476− fs.mkdirSync(path.dirname(filePath), { recursive: true });
477−}
478−
479−function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) {
480− return Effect.runSync(
481− deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer)),
482− );
483−}
484−
485−describe("WebSocket Server", () => {
486− let server: Http.Server | null = null;
487− let serverScope: Scope.Closeable | null = null;
488− let disposeServerRuntime: (() => Promise<void>) | null = null;
489− const connections: WebSocket[] = [];
490− const tempDirs: string[] = [];
491−
492− function makeTempDir(prefix: string): string {
493− const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
494− tempDirs.push(dir);
495− return dir;
496− }
497−
498− async function createTestServer(
499− options: {
500− persistenceLayer?: Layer.Layer<
501− SqlClient.SqlClient,
502− SqlError.SqlError | MigrationError | PlatformError.PlatformError
503− >;
504− cwd?: string;
505− autoBootstrapProjectFromCwd?: boolean;
506− logWebSocketEvents?: boolean;
507− devUrl?: string;
508− authToken?: string;
509− baseDir?: string;
510− staticDir?: string;
511− providerLayer?: Layer.Layer<ProviderService, never>;
512− providerRegistry?: ProviderRegistryShape;
513− open?: OpenShape;
514− gitManager?: GitManagerShape;
515− gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">;
516− terminalManager?: TerminalManagerShape;
517− serverSettings?: Partial<ServerSettings>;
518− } = {},
519− ): Promise<Http.Server> {
520− if (serverScope) {
521− throw new Error("Test server is already running");
522− }
523−
524− const baseDir = options.baseDir ?? makeTempDir("t3code-ws-base-");
525− const devUrl = options.devUrl ? new URL(options.devUrl) : undefined;
526− const derivedPaths = deriveServerPathsSync(baseDir, devUrl);
527− const scope = await Effect.runPromise(Scope.make("sequential"));
528− const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
529− const providerLayer = options.providerLayer ?? makeServerProviderLayer();
530− const providerRegistryLayer = Layer.succeed(
531− ProviderRegistry,
532− options.providerRegistry ?? defaultProviderRegistryService,
533− );
534− const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService);
535− const nodeServicesLayer = NodeServices.layer;
536− const serverSettingsLayer = ServerSettingsService.layerTest(options.serverSettings);
537− const serverSettingsRuntimeLayer = serverSettingsLayer.pipe(
538− Layer.provideMerge(nodeServicesLayer),
539− );
540− const analyticsLayer = AnalyticsService.layerTest;
541− const serverConfigLayer = Layer.succeed(ServerConfig, {
542− mode: "web",
543− port: 0,
544− host: undefined,
545− cwd: options.cwd ?? "/test/project",
546− baseDir,
547− ...derivedPaths,
548− staticDir: options.staticDir,
549− devUrl,
550− noBrowser: true,
551− authToken: options.authToken,
552− autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false,
553− logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl),
554− } satisfies ServerConfigShape);
555− const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
556− const providerRuntimeLayer = infrastructureLayer.pipe(
557− Layer.provideMerge(serverConfigLayer),
558− Layer.provideMerge(serverSettingsRuntimeLayer),
559− Layer.provideMerge(analyticsLayer),
560− );
561− const runtimeOverrides = Layer.mergeAll(
562− options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty,
563− options.gitCore
564− ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape)
565− : Layer.empty,
566− options.terminalManager
567− ? Layer.succeed(TerminalManager, options.terminalManager)
568− : Layer.empty,
569− );
570−
571− const runtimeLayer = Layer.merge(
572− Layer.merge(
573− makeServerRuntimeServicesLayer().pipe(
574− Layer.provideMerge(providerRuntimeLayer),
575− Layer.provideMerge(serverConfigLayer),
576− Layer.provideMerge(serverSettingsRuntimeLayer),
577− Layer.provideMerge(analyticsLayer),
578− Layer.provideMerge(nodeServicesLayer),
579− ),
580− Layer.mergeAll(providerRuntimeLayer, serverSettingsRuntimeLayer, analyticsLayer),
581− ),
582− runtimeOverrides,
583− );
584− const dependenciesLayer = Layer.mergeAll(
585− runtimeLayer,
586− providerRegistryLayer,
587− openLayer,
588− serverConfigLayer,
589− nodeServicesLayer,
590− );
591− const runtime = ManagedRuntime.make(dependenciesLayer);
592− try {
593− const httpServer = await runtime.runPromise(createServer().pipe(Scope.provide(scope)));
594− disposeServerRuntime = () => runtime.dispose();
595− serverScope = scope;
596− return httpServer;
597− } catch (error) {
598− await runtime.dispose();
599− await Effect.runPromise(Scope.close(scope, Exit.void));
600− throw error;
601− }
602− }
603−
604− async function closeTestServer() {
605− if (!serverScope && !disposeServerRuntime) return;
606− const scope = serverScope;
607− const disposeRuntime = disposeServerRuntime;
608− serverScope = null;
609− disposeServerRuntime = null;
610− if (scope) {
611− await Effect.runPromise(Scope.close(scope, Exit.void));
612− }
613− if (disposeRuntime) {
614− await disposeRuntime();
615− }
616− }
617−
618− afterEach(async () => {
619− for (const ws of connections) {
620− ws.close();
621− }
622− connections.length = 0;
623− await closeTestServer();
624− server = null;
625− for (const dir of tempDirs.splice(0, tempDirs.length)) {
626− fs.rmSync(dir, { recursive: true, force: true });
627− }
628− vi.restoreAllMocks();
629− });
630−
631− it("sends welcome message on connect", async () => {
632− server = await createTestServer({ cwd: "/test/project" });
633− const addr = server.address();
634− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
635− expect(port).toBeGreaterThan(0);
636−
637− const [ws, welcome] = await connectAndAwaitWelcome(port);
638− connections.push(ws);
639−
640− expect(welcome.type).toBe("push");
641− expect(welcome.data).toEqual({
642− cwd: "/test/project",
643− projectName: "project",
644− });
645− });
646−
647− it("serves persisted attachments from stateDir", async () => {
648− const baseDir = makeTempDir("t3code-state-attachments-");
649− const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);
650− const attachmentPath = path.join(attachmentsDir, "thread-a", "message-a", "0.png");
651− fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
652− fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment"));
653−
654− server = await createTestServer({ cwd: "/test/project", baseDir });
655− const addr = server.address();
656− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
657− expect(port).toBeGreaterThan(0);
658−
659− const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`);
660− expect(response.status).toBe(200);
661− expect(response.headers.get("content-type")).toContain("image/png");
662− const bytes = Buffer.from(await response.arrayBuffer());
663− expect(bytes).toEqual(Buffer.from("hello-attachment"));
664− });
665−
666− it("serves persisted attachments for URL-encoded paths", async () => {
667− const baseDir = makeTempDir("t3code-state-attachments-encoded-");
668− const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);
669− const attachmentPath = path.join(
670− attachmentsDir,
671− "thread%20folder",
672− "message%20folder",
673− "file%20name.png",
674− );
675− fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
676− fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment"));
677−
678− server = await createTestServer({ cwd: "/test/project", baseDir });
679− const addr = server.address();
680− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
681− expect(port).toBeGreaterThan(0);
682−
683− const response = await fetch(
684− `http://127.0.0.1:${port}/attachments/thread%20folder/message%20folder/file%20name.png`,
685− );
686− expect(response.status).toBe(200);
687− expect(response.headers.get("content-type")).toContain("image/png");
688− const bytes = Buffer.from(await response.arrayBuffer());
689− expect(bytes).toEqual(Buffer.from("hello-encoded-attachment"));
690− });
691−
692− it("serves static index for root path", async () => {
693− const baseDir = makeTempDir("t3code-state-static-root-");
694− const staticDir = makeTempDir("t3code-static-root-");
695− fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>static-root</h1>", "utf8");
696−
697− server = await createTestServer({ cwd: "/test/project", baseDir, staticDir });
698− const addr = server.address();
699− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
700− expect(port).toBeGreaterThan(0);
701−
702− const response = await fetch(`http://127.0.0.1:${port}/`);
703− expect(response.status).toBe(200);
704− expect(await response.text()).toContain("static-root");
705− });
706−
707− it("rejects static path traversal attempts", async () => {
708− const baseDir = makeTempDir("t3code-state-static-traversal-");
709− const staticDir = makeTempDir("t3code-static-traversal-");
710− fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>safe</h1>", "utf8");
711−
712− server = await createTestServer({ cwd: "/test/project", baseDir, staticDir });
713− const addr = server.address();
714− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
715− expect(port).toBeGreaterThan(0);
716−
717− const response = await requestPath(port, "/..%2f..%2fetc/passwd");
718− expect(response.statusCode).toBe(400);
719− expect(response.body).toBe("Invalid static file path");
720− });
721−
722− it("bootstraps the cwd project on startup when enabled", async () => {
723− server = await createTestServer({
724− cwd: "/test/bootstrap-workspace",
725− autoBootstrapProjectFromCwd: true,
726− });
727− const addr = server.address();
728− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
729− expect(port).toBeGreaterThan(0);
730−
731− const [ws, welcome] = await connectAndAwaitWelcome(port);
732− connections.push(ws);
733− expect(welcome.data).toEqual(
734− expect.objectContaining({
735− cwd: "/test/bootstrap-workspace",
736− projectName: "bootstrap-workspace",
737− bootstrapProjectId: expect.any(String),
738− bootstrapThreadId: expect.any(String),
739− }),
740− );
741−
742− const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot);
743− expect(snapshotResponse.error).toBeUndefined();
744− const snapshot = snapshotResponse.result as {
745− projects: Array<{
746− id: string;
747− workspaceRoot: string;
748− title: string;
749− defaultModelSelection: {
750− provider: string;
751− model: string;
752− } | null;
753− }>;
754− threads: Array<{
755− id: string;
756− projectId: string;
757− title: string;
758− modelSelection: {
759− provider: string;
760− model: string;
761− };
762− branch: string | null;
763− worktreePath: string | null;
764− }>;
765− };
766− const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }).bootstrapProjectId;
767− const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }).bootstrapThreadId;
768− expect(bootstrapProjectId).toBeDefined();
769− expect(bootstrapThreadId).toBeDefined();
770−
771− expect(snapshot.projects).toEqual(
772− expect.arrayContaining([
773− expect.objectContaining({
774− id: bootstrapProjectId,
775− workspaceRoot: "/test/bootstrap-workspace",
776− title: "bootstrap-workspace",
777− defaultModelSelection: {
778− provider: "codex",
779− model: "gpt-5-codex",
780− },
781− }),
782− ]),
783− );
784− expect(snapshot.threads).toEqual(
785− expect.arrayContaining([
786− expect.objectContaining({
787− id: bootstrapThreadId,
788− projectId: bootstrapProjectId,
789− title: "New thread",
790− modelSelection: {
791− provider: "codex",
792− model: "gpt-5-codex",
793− },
794− branch: null,
795− worktreePath: null,
796− }),
797− ]),
798− );
799− });
800−
801− it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => {
802− const baseDir = makeTempDir("t3code-state-bootstrap-existing-");
803− const { dbPath } = deriveServerPathsSync(baseDir, undefined);
804− const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe(
805− Layer.provide(NodeServices.layer),
806− );
807− const cwd = "/test/bootstrap-existing";
808−
809− server = await createTestServer({
810− cwd,
811− baseDir,
812− persistenceLayer,
813− autoBootstrapProjectFromCwd: true,
814− });
815− let addr = server.address();
816− let port = typeof addr === "object" && addr !== null ? addr.port : 0;
817− expect(port).toBeGreaterThan(0);
818−
819− const [firstWs, firstWelcome] = await connectAndAwaitWelcome(port);
820− connections.push(firstWs);
821− const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string })
822− .bootstrapProjectId;
823− const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string })
824− .bootstrapThreadId;
825− expect(firstBootstrapProjectId).toBeDefined();
826− expect(firstBootstrapThreadId).toBeDefined();
827−
828− firstWs.close();
829− await closeTestServer();
830− server = null;
831−
832− server = await createTestServer({
833− cwd,
834− baseDir,
835− persistenceLayer,
836− autoBootstrapProjectFromCwd: true,
837− });
838− addr = server.address();
839− port = typeof addr === "object" && addr !== null ? addr.port : 0;
840− expect(port).toBeGreaterThan(0);
841−
842− const [secondWs, secondWelcome] = await connectAndAwaitWelcome(port);
843− connections.push(secondWs);
844− expect(secondWelcome.data).toEqual(
845− expect.objectContaining({
846− cwd,
847− projectName: "bootstrap-existing",
848− bootstrapProjectId: firstBootstrapProjectId,
849− bootstrapThreadId: firstBootstrapThreadId,
850− }),
851− );
852− });
853−
854− it("logs outbound websocket push events in dev mode", async () => {
855− const logSpy = vi.spyOn(console, "log").mockImplementation(() => {
856− // Keep test output clean while verifying websocket logs.
857− });
858−
859− server = await createTestServer({
860− cwd: "/test/project",
861− devUrl: "http://localhost:5173",
862− });
863− const addr = server.address();
864− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
865− expect(port).toBeGreaterThan(0);
866−
867− const [ws] = await connectAndAwaitWelcome(port);
868− connections.push(ws);
869−
870− expect(
871− logSpy.mock.calls.some(([message]) => {
872− if (typeof message !== "string") return false;
873− return (
874− message.includes("[ws]") &&
875− message.includes("outgoing push") &&
876− message.includes(`channel="${WS_CHANNELS.serverWelcome}"`)
877− );
878− }),
879− ).toBe(true);
880− });
881−
882− it("responds to server.getConfig", async () => {
883− const baseDir = makeTempDir("t3code-state-get-config-");
884− const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
885− ensureParentDir(keybindingsPath);
886− fs.writeFileSync(keybindingsPath, "[]", "utf8");
887−
888− server = await createTestServer({ cwd: "/my/workspace", baseDir });
889− const addr = server.address();
890− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
891−
892− const [ws] = await connectAndAwaitWelcome(port);
893− connections.push(ws);
894−
895− const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
896− expect(response.error).toBeUndefined();
897− expect(response.result).toEqual({
898− cwd: "/my/workspace",
899− keybindingsConfigPath: keybindingsPath,
900− keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
901− issues: [],
902− providers: defaultProviderStatuses,
903− availableEditors: expect.any(Array),
904− settings: defaultServerSettings,
905− });
906− expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
907− });
908−
909− it("bootstraps default keybindings file when missing", async () => {
910− const baseDir = makeTempDir("t3code-state-bootstrap-keybindings-");
911− const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
912− expect(fs.existsSync(keybindingsPath)).toBe(false);
913−
914− server = await createTestServer({ cwd: "/my/workspace", baseDir });
915− const addr = server.address();
916− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
917−
918− const [ws] = await connectAndAwaitWelcome(port);
919− connections.push(ws);
920−
921− const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
922− expect(response.error).toBeUndefined();
923− expect(response.result).toEqual({
924− cwd: "/my/workspace",
925− keybindingsConfigPath: keybindingsPath,
926− keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
927− issues: [],
928− providers: defaultProviderStatuses,
929− availableEditors: expect.any(Array),
930− settings: defaultServerSettings,
931− });
932− expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
933−
934− const persistedConfig = JSON.parse(
935− fs.readFileSync(keybindingsPath, "utf8"),
936− ) as KeybindingsConfig;
937− expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS);
938− });
939−
940− it("falls back to defaults and reports malformed keybindings config issues", async () => {
941− const baseDir = makeTempDir("t3code-state-malformed-keybindings-");
942− const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
943− ensureParentDir(keybindingsPath);
944− fs.writeFileSync(keybindingsPath, "{ not-json", "utf8");
945−
946− server = await createTestServer({ cwd: "/my/workspace", baseDir });
947− const addr = server.address();
948− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
949−
950− const [ws] = await connectAndAwaitWelcome(port);
951− connections.push(ws);
952−
953− const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
954− expect(response.error).toBeUndefined();
955− expect(response.result).toEqual({
956− cwd: "/my/workspace",
957− keybindingsConfigPath: keybindingsPath,
958− keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
959− issues: [
960− {
961− kind: "keybindings.malformed-config",
962− message: expect.stringContaining("expected JSON array"),
963− },
964− ],
965− providers: defaultProviderStatuses,
966− availableEditors: expect.any(Array),
967− settings: defaultServerSettings,
968− });
969− expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
970− expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json");
971− });
972−
973− it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => {
974− const baseDir = makeTempDir("t3code-state-partial-invalid-keybindings-");
975− const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
976− ensureParentDir(keybindingsPath);
977− fs.writeFileSync(
978− keybindingsPath,
979− JSON.stringify([
980− { key: "mod+j", command: "terminal.toggle" },
981− { key: "mod+shift+d+o", command: "terminal.new" },
982− { key: "mod+x", command: "not-a-real-command" },
983− ]),
984− "utf8",
985− );
986−
987− server = await createTestServer({ cwd: "/my/workspace", baseDir });
988− const addr = server.address();
989− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
990−
991− const [ws] = await connectAndAwaitWelcome(port);
992− connections.push(ws);
993−
994− const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
995− expect(response.error).toBeUndefined();
996− const result = response.result as {
997− cwd: string;
998− keybindingsConfigPath: string;
999− keybindings: ResolvedKeybindingsConfig;
1000− issues: Array<{ kind: string; index?: number; message: string }>;
1001− providers: ReadonlyArray<ServerProvider>;
1002− availableEditors: unknown;
1003− };
1004− expect(result.cwd).toBe("/my/workspace");
1005− expect(result.keybindingsConfigPath).toBe(keybindingsPath);
1006− expect(result.issues).toEqual([
1007− {
1008− kind: "keybindings.invalid-entry",
1009− index: 1,
1010− message: expect.any(String),
1011− },
1012− {
1013− kind: "keybindings.invalid-entry",
1014− index: 2,
1015− message: expect.any(String),
1016− },
1017− ]);
1018− expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length);
1019− expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true);
1020− expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true);
1021− expect(result.providers).toEqual(defaultProviderStatuses);
1022− expectAvailableEditors(result.availableEditors);
1023− });
1024−
1025− it("pushes server.configUpdated issues when keybindings file changes", async () => {
1026− const baseDir = makeTempDir("t3code-state-keybindings-watch-");
1027− const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
1028− ensureParentDir(keybindingsPath);
1029− fs.writeFileSync(keybindingsPath, "[]", "utf8");
1030−
1031− server = await createTestServer({ cwd: "/my/workspace", baseDir });
1032− const addr = server.address();
1033− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1034−
1035− const [ws] = await connectAndAwaitWelcome(port);
1036− connections.push(ws);
1037−
1038− const malformedPush = await rewriteKeybindingsAndWaitForPush(
1039− ws,
1040− keybindingsPath,
1041− "{ not-json",
1042− (push) =>
1043− Array.isArray(push.data.issues) &&
1044− Boolean(push.data.issues[0]) &&
1045− push.data.issues[0]!.kind === "keybindings.malformed-config",
1046− );
1047− expect(malformedPush.data).toEqual({
1048− issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }],
1049− });
1050−
1051− const successPush = await rewriteKeybindingsAndWaitForPush(
1052− ws,
1053− keybindingsPath,
1054− "[]",
1055− (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0,
1056− );
1057− expect(successPush.data).toEqual({ issues: [] });
1058− });
1059−
1060− it("routes shell.openInEditor through the injected open service", async () => {
1061− const openCalls: Array<{ cwd: string; editor: string }> = [];
1062− const openService: OpenShape = {
1063− openBrowser: () => Effect.void,
1064− openInEditor: (input) => {
1065− openCalls.push({ cwd: input.cwd, editor: input.editor });
1066− return Effect.void;
1067− },
1068− };
1069−
1070− server = await createTestServer({ cwd: "/my/workspace", open: openService });
1071− const addr = server.address();
1072− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1073−
1074− const [ws] = await connectAndAwaitWelcome(port);
1075− connections.push(ws);
1076−
1077− const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, {
1078− cwd: "/my/workspace",
1079− editor: "cursor",
1080− });
1081− expect(response.error).toBeUndefined();
1082− expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]);
1083− });
1084−
1085− it("reads keybindings from the configured state directory", async () => {
1086− const baseDir = makeTempDir("t3code-state-keybindings-");
1087− const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
1088− ensureParentDir(keybindingsPath);
1089− fs.writeFileSync(
1090− keybindingsPath,
1091− JSON.stringify([
1092− { key: "cmd+j", command: "terminal.toggle" },
1093− { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
1094− { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
1095− ]),
1096− "utf8",
1097− );
1098− server = await createTestServer({ cwd: "/my/workspace", baseDir });
1099− const addr = server.address();
1100− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1101−
1102− const [ws] = await connectAndAwaitWelcome(port);
1103− connections.push(ws);
1104−
1105− const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
1106− expect(response.error).toBeUndefined();
1107− const persistedConfig = JSON.parse(
1108− fs.readFileSync(keybindingsPath, "utf8"),
1109− ) as KeybindingsConfig;
1110− expect(response.result).toEqual({
1111− cwd: "/my/workspace",
1112− keybindingsConfigPath: keybindingsPath,
1113− keybindings: compileKeybindings(persistedConfig),
1114− issues: [],
1115− providers: defaultProviderStatuses,
1116− availableEditors: expect.any(Array),
1117− settings: defaultServerSettings,
1118− });
1119− expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
1120− });
1121−
1122− it("upserts keybinding rules and updates cached server config", async () => {
1123− const baseDir = makeTempDir("t3code-state-upsert-keybinding-");
1124− const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
1125− ensureParentDir(keybindingsPath);
1126− fs.writeFileSync(
1127− keybindingsPath,
1128− JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
1129− "utf8",
1130− );
1131−
1132− server = await createTestServer({ cwd: "/my/workspace", baseDir });
1133− const addr = server.address();
1134− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1135−
1136− const [ws] = await connectAndAwaitWelcome(port);
1137− connections.push(ws);
1138−
1139− const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, {
1140− key: "mod+shift+r",
1141− command: "script.run-tests.run",
1142− });
1143− expect(upsertResponse.error).toBeUndefined();
1144− const persistedConfig = JSON.parse(
1145− fs.readFileSync(keybindingsPath, "utf8"),
1146− ) as KeybindingsConfig;
1147− const persistedCommands = new Set(persistedConfig.map((entry) => entry.command));
1148− for (const defaultRule of DEFAULT_KEYBINDINGS) {
1149− expect(persistedCommands.has(defaultRule.command)).toBe(true);
1150− }
1151− expect(persistedCommands.has("script.run-tests.run")).toBe(true);
1152− expect(upsertResponse.result).toEqual({
1153− keybindings: compileKeybindings(persistedConfig),
1154− issues: [],
1155− });
1156−
1157− const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
1158− expect(configResponse.error).toBeUndefined();
1159− expect(configResponse.result).toEqual({
1160− cwd: "/my/workspace",
1161− keybindingsConfigPath: keybindingsPath,
1162− keybindings: compileKeybindings(persistedConfig),
1163− issues: [],
1164− providers: defaultProviderStatuses,
1165− availableEditors: expect.any(Array),
1166− settings: defaultServerSettings,
1167− });
1168− expectAvailableEditors(
1169− (configResponse.result as { availableEditors: unknown }).availableEditors,
1170− );
1171− });
1172−
1173− it("returns error for unknown methods", async () => {
1174− server = await createTestServer({ cwd: "/test" });
1175− const addr = server.address();
1176− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1177−
1178− const [ws] = await connectAndAwaitWelcome(port);
1179− connections.push(ws);
1180−
1181− const response = await sendRequest(ws, "nonexistent.method");
1182− expect(response.error).toBeDefined();
1183− expect(response.error!.message).toContain("Invalid request format");
1184− });
1185−
1186− it("returns error when requesting turn diff for unknown thread", async () => {
1187− server = await createTestServer({ cwd: "/test" });
1188− const addr = server.address();
1189− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1190−
1191− const [ws] = await connectAndAwaitWelcome(port);
1192− connections.push(ws);
1193−
1194− const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
1195− threadId: "thread-missing",
1196− fromTurnCount: 1,
1197− toTurnCount: 2,
1198− });
1199− expect(response.result).toBeUndefined();
1200− expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
1201− });
1202−
1203− it("returns error when requesting turn diff with an inverted range", async () => {
1204− server = await createTestServer({ cwd: "/test" });
1205− const addr = server.address();
1206− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1207−
1208− const [ws] = await connectAndAwaitWelcome(port);
1209− connections.push(ws);
1210−
1211− const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
1212− threadId: "thread-any",
1213− fromTurnCount: 2,
1214− toTurnCount: 1,
1215− });
1216− expect(response.result).toBeUndefined();
1217− expect(response.error?.message).toContain(
1218− "fromTurnCount must be less than or equal to toTurnCount",
1219− );
1220− });
1221−
1222− it("returns error when requesting full thread diff for unknown thread", async () => {
1223− server = await createTestServer({ cwd: "/test" });
1224− const addr = server.address();
1225− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1226−
1227− const [ws] = await connectAndAwaitWelcome(port);
1228− connections.push(ws);
1229−
1230− const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, {
1231− threadId: "thread-missing",
1232− toTurnCount: 2,
1233− });
1234− expect(response.result).toBeUndefined();
1235− expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
1236− });
1237−
1238− it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => {
1239− server = await createTestServer({ cwd: "/test" });
1240− const addr = server.address();
1241− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1242−
1243− const [ws] = await connectAndAwaitWelcome(port);
1244− connections.push(ws);
1245−
1246− const workspaceRoot = makeTempDir("t3code-ws-diff-project-");
1247− const createdAt = new Date().toISOString();
1248− const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1249− type: "project.create",
1250− commandId: "cmd-diff-project-create",
1251− projectId: "project-diff",
1252− title: "Diff Project",
1253− workspaceRoot,
1254− defaultModelSelection: {
1255− provider: "codex",
1256− model: "gpt-5-codex",
1257− },
1258− createdAt,
1259− });
1260− expect(createProjectResponse.error).toBeUndefined();
1261− const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1262− type: "thread.create",
1263− commandId: "cmd-diff-thread-create",
1264− threadId: "thread-diff",
1265− projectId: "project-diff",
1266− title: "Diff Thread",
1267− modelSelection: {
1268− provider: "codex",
1269− model: "gpt-5-codex",
1270− },
1271− runtimeMode: "full-access",
1272− interactionMode: "default",
1273− branch: null,
1274− worktreePath: null,
1275− createdAt,
1276− });
1277− expect(createThreadResponse.error).toBeUndefined();
1278−
1279− const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
1280− threadId: "thread-diff",
1281− fromTurnCount: 0,
1282− toTurnCount: 1,
1283− });
1284− expect(response.result).toBeUndefined();
1285− expect(response.error?.message).toContain("exceeds current turn count");
1286− });
1287−
1288− it("rejects project.create when the workspace root does not exist", async () => {
1289− server = await createTestServer({ cwd: "/test" });
1290− const addr = server.address();
1291− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1292−
1293− const [ws] = await connectAndAwaitWelcome(port);
1294− connections.push(ws);
1295−
1296− const missingWorkspaceRoot = path.join(makeTempDir("t3code-ws-project-missing-"), "missing");
1297− const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1298− type: "project.create",
1299− commandId: "cmd-ws-project-create-missing",
1300− projectId: "project-missing",
1301− title: "Missing Project",
1302− workspaceRoot: missingWorkspaceRoot,
1303− defaultModelSelection: {
1304− provider: "codex",
1305− model: "gpt-5-codex",
1306− },
1307− createdAt: new Date().toISOString(),
1308− });
1309−
1310− expect(response.result).toBeUndefined();
1311− expect(response.error?.message).toContain("Workspace root does not exist:");
1312− });
1313−
1314− it("keeps orchestration domain push behavior for provider runtime events", async () => {
1315− const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
1316− const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {
1317− Effect.runSync(PubSub.publish(runtimeEventPubSub, event));
1318− };
1319− const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
1320− const providerService: ProviderServiceShape = {
1321− startSession: (threadId) =>
1322− Effect.succeed({
1323− provider: "codex",
1324− status: "ready",
1325− runtimeMode: "full-access",
1326− threadId,
1327− createdAt: new Date().toISOString(),
1328− updatedAt: new Date().toISOString(),
1329− }),
1330− sendTurn: ({ threadId }) =>
1331− Effect.succeed({
1332− threadId,
1333− turnId: asTurnId("provider-turn-1"),
1334− }),
1335− interruptTurn: () => unsupported(),
1336− respondToRequest: () => unsupported(),
1337− respondToUserInput: () => unsupported(),
1338− stopSession: () => unsupported(),
1339− listSessions: () => Effect.succeed([]),
1340− getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
1341− rollbackConversation: () => unsupported(),
1342− streamEvents: Stream.fromPubSub(runtimeEventPubSub),
1343− };
1344− const providerLayer = Layer.succeed(ProviderService, providerService);
1345−
1346− server = await createTestServer({
1347− cwd: "/test",
1348− providerLayer,
1349− serverSettings: { enableAssistantStreaming: true },
1350− });
1351− const addr = server.address();
1352− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1353−
1354− const [ws] = await connectAndAwaitWelcome(port);
1355− connections.push(ws);
1356−
1357− const workspaceRoot = makeTempDir("t3code-ws-project-");
1358− const createdAt = new Date().toISOString();
1359− const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1360− type: "project.create",
1361− commandId: "cmd-ws-project-create",
1362− projectId: "project-1",
1363− title: "WS Project",
1364− workspaceRoot,
1365− defaultModelSelection: {
1366− provider: "codex",
1367− model: "gpt-5-codex",
1368− },
1369− createdAt,
1370− });
1371− expect(createProjectResponse.error).toBeUndefined();
1372− const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1373− type: "thread.create",
1374− commandId: "cmd-ws-runtime-thread-create",
1375− threadId: "thread-1",
1376− projectId: "project-1",
1377− title: "Thread 1",
1378− modelSelection: {
1379− provider: "codex",
1380− model: "gpt-5-codex",
1381− },
1382− runtimeMode: "full-access",
1383− interactionMode: "default",
1384− branch: null,
1385− worktreePath: null,
1386− createdAt,
1387− });
1388− expect(createThreadResponse.error).toBeUndefined();
1389−
1390− const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1391− type: "thread.turn.start",
1392− commandId: "cmd-ws-runtime-turn-start",
1393− threadId: "thread-1",
1394− message: {
1395− messageId: "msg-ws-runtime-1",
1396− role: "user",
1397− text: "hello",
1398− attachments: [],
1399− },
1400− runtimeMode: "approval-required",
1401− interactionMode: "default",
1402− createdAt,
1403− });
1404− expect(startTurnResponse.error).toBeUndefined();
1405−
1406− await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
1407− const event = push.data as { type?: string };
1408− return event.type === "thread.session-set";
1409− });
1410−
1411− emitRuntimeEvent({
1412− type: "content.delta",
1413− eventId: asEventId("evt-ws-runtime-message-delta"),
1414− provider: "codex",
1415− threadId: asThreadId("thread-1"),
1416− createdAt: new Date().toISOString(),
1417− turnId: asTurnId("turn-1"),
1418− itemId: asProviderItemId("item-1"),
1419− payload: {
1420− streamKind: "assistant_text",
1421− delta: "hello from runtime",
1422− },
1423− } as unknown as ProviderRuntimeEvent);
1424−
1425− const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
1426− const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } };
1427− return (
1428− event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1"
1429− );
1430− });
1431−
1432− const domainEvent = domainPush.data as {
1433− type: string;
1434− payload: { messageId: string; text: string };
1435− };
1436− expect(domainEvent.type).toBe("thread.message-sent");
1437− expect(domainEvent.payload.messageId).toBe("assistant:item-1");
1438− expect(domainEvent.payload.text).toBe("hello from runtime");
1439− });
1440−
1441− it("routes terminal RPC methods and broadcasts terminal events", async () => {
1442− const cwd = makeTempDir("t3code-ws-terminal-cwd-");
1443− const terminalManager = new MockTerminalManager();
1444− server = await createTestServer({
1445− cwd: "/test",
1446− terminalManager,
1447− });
1448− const addr = server.address();
1449− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1450−
1451− const [ws] = await connectAndAwaitWelcome(port);
1452− connections.push(ws);
1453−
1454− const open = await sendRequest(ws, WS_METHODS.terminalOpen, {
1455− threadId: "thread-1",
1456− cwd,
1457− cols: 100,
1458− rows: 24,
1459− });
1460− expect(open.error).toBeUndefined();
1461− expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1");
1462− expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID);
1463−
1464− const write = await sendRequest(ws, WS_METHODS.terminalWrite, {
1465− threadId: "thread-1",
1466− data: "echo hello\n",
1467− });
1468− expect(write.error).toBeUndefined();
1469−
1470− const resize = await sendRequest(ws, WS_METHODS.terminalResize, {
1471− threadId: "thread-1",
1472− cols: 120,
1473− rows: 30,
1474− });
1475− expect(resize.error).toBeUndefined();
1476−
1477− const clear = await sendRequest(ws, WS_METHODS.terminalClear, {
1478− threadId: "thread-1",
1479− });
1480− expect(clear.error).toBeUndefined();
1481−
1482− const restart = await sendRequest(ws, WS_METHODS.terminalRestart, {
1483− threadId: "thread-1",
1484− cwd,
1485− cols: 120,
1486− rows: 30,
1487− });
1488− expect(restart.error).toBeUndefined();
1489−
1490− const close = await sendRequest(ws, WS_METHODS.terminalClose, {
1491− threadId: "thread-1",
1492− deleteHistory: true,
1493− });
1494− expect(close.error).toBeUndefined();
1495−
1496− const manualEvent: TerminalEvent = {
1497− type: "output",
1498− threadId: "thread-1",
1499− terminalId: DEFAULT_TERMINAL_ID,
1500− createdAt: new Date().toISOString(),
1501− data: "manual test output\n",
1502− };
1503− terminalManager.emitEvent(manualEvent);
1504−
1505− const push = await waitForPush(
1506− ws,
1507− WS_CHANNELS.terminalEvent,
1508− (candidate) => (candidate.data as TerminalEvent).type === "output",
1509− );
1510− expect(push.type).toBe("push");
1511− expect(push.channel).toBe(WS_CHANNELS.terminalEvent);
1512− });
1513−
1514− it("shuts down cleanly for injected terminal managers", async () => {
1515− const terminalManager = new MockTerminalManager();
1516− server = await createTestServer({
1517− cwd: "/test",
1518− terminalManager,
1519− });
1520−
1521− await closeTestServer();
1522− server = null;
1523−
1524− expect(() =>
1525− terminalManager.emitEvent({
1526− type: "output",
1527− threadId: "thread-1",
1528− terminalId: DEFAULT_TERMINAL_ID,
1529− createdAt: new Date().toISOString(),
1530− data: "after shutdown\n",
1531− }),
1532− ).not.toThrow();
1533− });
1534−
1535− it("returns validation errors for invalid terminal open params", async () => {
1536− server = await createTestServer({ cwd: "/test" });
1537− const addr = server.address();
1538− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1539−
1540− const [ws] = await connectAndAwaitWelcome(port);
1541− connections.push(ws);
1542−
1543− const response = await sendRequest(ws, WS_METHODS.terminalOpen, {
1544− threadId: "",
1545− cwd: "",
1546− cols: 1,
1547− rows: 1,
1548− });
1549− expect(response.error).toBeDefined();
1550− });
1551−
1552− it("handles invalid JSON gracefully", async () => {
1553− server = await createTestServer({ cwd: "/test" });
1554− const addr = server.address();
1555− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1556−
1557− const [ws] = await connectAndAwaitWelcome(port);
1558− connections.push(ws);
1559−
1560− // Send garbage
1561− ws.send("not json at all");
1562−
1563− // Error response goes to the response channel
1564− const channels = channelsBySocket.get(ws)!;
1565− let response: WebSocketResponse | null = null;
1566− for (let attempt = 0; attempt < 5; attempt += 1) {
1567− const message = await dequeue(channels.response, 5_000);
1568− if (message.id === "unknown") {
1569− response = message;
1570− break;
1571− }
1572− if (message.error) {
1573− response = message;
1574− break;
1575− }
1576− }
1577− expect(response).toBeDefined();
1578− expect(response!.error).toBeDefined();
1579− expect(response!.error!.message).toContain("Invalid request format");
1580− });
1581−
1582− it("catches websocket message handler rejections and keeps the socket usable", async () => {
1583− const unhandledRejections: unknown[] = [];
1584− const onUnhandledRejection = (reason: unknown) => {
1585− unhandledRejections.push(reason);
1586− };
1587− process.on("unhandledRejection", onUnhandledRejection);
1588−
1589− const brokenOpenService: OpenShape = {
1590− openBrowser: () => Effect.void,
1591− openInEditor: () =>
1592− Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)),
1593− };
1594−
1595− try {
1596− server = await createTestServer({ cwd: "/test", open: brokenOpenService });
1597− const addr = server.address();
1598− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1599−
1600− const [ws] = await connectAndAwaitWelcome(port);
1601− connections.push(ws);
1602−
1603− ws.send(
1604− JSON.stringify({
1605− id: "req-broken-open",
1606− body: {
1607− _tag: WS_METHODS.shellOpenInEditor,
1608− cwd: "/tmp",
1609− editor: "cursor",
1610− },
1611− }),
1612− );
1613−
1614− await new Promise((resolve) => setTimeout(resolve, 50));
1615− expect(unhandledRejections).toHaveLength(0);
1616−
1617− const workspace = makeTempDir("t3code-ws-handler-still-usable-");
1618− fs.writeFileSync(path.join(workspace, "file.txt"), "ok\n", "utf8");
1619− const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
1620− cwd: workspace,
1621− query: "file",
1622− limit: 5,
1623− });
1624− expect(response.error).toBeUndefined();
1625− expect(response.result).toEqual(
1626− expect.objectContaining({
1627− entries: expect.arrayContaining([
1628− expect.objectContaining({
1629− path: "file.txt",
1630− kind: "file",
1631− }),
1632− ]),
1633− }),
1634− );
1635− } finally {
1636− process.off("unhandledRejection", onUnhandledRejection);
1637− }
1638− });
1639−
1640− it("returns errors for removed projects CRUD methods", async () => {
1641− server = await createTestServer({ cwd: "/test" });
1642− const addr = server.address();
1643− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1644−
1645− const [ws] = await connectAndAwaitWelcome(port);
1646− connections.push(ws);
1647−
1648− const listResponse = await sendRequest(ws, WS_METHODS.projectsList);
1649− expect(listResponse.result).toBeUndefined();
1650− expect(listResponse.error?.message).toContain("Invalid request format");
1651−
1652− const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, {
1653− cwd: "/tmp/project-a",
1654− });
1655− expect(addResponse.result).toBeUndefined();
1656− expect(addResponse.error?.message).toContain("Invalid request format");
1657−
1658− const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, {
1659− id: "project-a",
1660− });
1661− expect(removeResponse.result).toBeUndefined();
1662− expect(removeResponse.error?.message).toContain("Invalid request format");
1663− });
1664−
1665− it("supports projects.searchEntries", async () => {
1666− const workspace = makeTempDir("t3code-ws-workspace-entries-");
1667− fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true });
1668− fs.writeFileSync(
1669− path.join(workspace, "src", "components", "Composer.tsx"),
1670− "export {};",
1671− "utf8",
1672− );
1673− fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8");
1674− fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });
1675− fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
1676−
1677− server = await createTestServer({ cwd: "/test" });
1678− const addr = server.address();
1679− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1680−
1681− const [ws] = await connectAndAwaitWelcome(port);
1682− connections.push(ws);
1683−
1684− const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
1685− cwd: workspace,
1686− query: "comp",
1687− limit: 10,
1688− });
1689− expect(response.error).toBeUndefined();
1690− expect(response.result).toEqual({
1691− entries: expect.arrayContaining([
1692− expect.objectContaining({ path: "src/components", kind: "directory" }),
1693− expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }),
1694− ]),
1695− truncated: false,
1696− });
1697− });
1698−
1699− it("supports projects.writeFile within the workspace root", async () => {
1700− const workspace = makeTempDir("t3code-ws-write-file-");
1701−
1702− server = await createTestServer({ cwd: "/test" });
1703− const addr = server.address();
1704− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1705−
1706− const [ws] = await connectAndAwaitWelcome(port);
1707− connections.push(ws);
1708−
1709− const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
1710− cwd: workspace,
1711− relativePath: "plans/effect-rpc.md",
1712− contents: "# Plan\n\n- step 1\n",
1713− });
1714−
1715− expect(response.error).toBeUndefined();
1716− expect(response.result).toEqual({
1717− relativePath: "plans/effect-rpc.md",
1718− });
1719− expect(fs.readFileSync(path.join(workspace, "plans", "effect-rpc.md"), "utf8")).toBe(
1720− "# Plan\n\n- step 1\n",
1721− );
1722− });
1723−
1724− it("invalidates workspace entry search cache after projects.writeFile", async () => {
1725− const workspace = makeTempDir("t3code-ws-write-file-invalidate-");
1726− fs.mkdirSync(path.join(workspace, "src"), { recursive: true });
1727− fs.writeFileSync(path.join(workspace, "src", "existing.ts"), "export {};\n", "utf8");
1728−
1729− server = await createTestServer({ cwd: "/test" });
1730− const addr = server.address();
1731− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1732−
1733− const [ws] = await connectAndAwaitWelcome(port);
1734− connections.push(ws);
1735−
1736− const beforeWrite = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
1737− cwd: workspace,
1738− query: "rpc",
1739− limit: 10,
1740− });
1741− expect(beforeWrite.error).toBeUndefined();
1742− expect(beforeWrite.result).toEqual({
1743− entries: [],
1744− truncated: false,
1745− });
1746−
1747− const writeResponse = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
1748− cwd: workspace,
1749− relativePath: "plans/effect-rpc.md",
1750− contents: "# Plan\n",
1751− });
1752− expect(writeResponse.error).toBeUndefined();
1753−
1754− const afterWrite = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
1755− cwd: workspace,
1756− query: "rpc",
1757− limit: 10,
1758− });
1759− expect(afterWrite.error).toBeUndefined();
1760− expect(afterWrite.result).toEqual({
1761− entries: expect.arrayContaining([
1762− expect.objectContaining({ path: "plans/effect-rpc.md", kind: "file" }),
1763− ]),
1764− truncated: false,
1765− });
1766− });
1767−
1768− it("rejects projects.writeFile paths outside the workspace root", async () => {
1769− const workspace = makeTempDir("t3code-ws-write-file-reject-");
1770−
1771− server = await createTestServer({ cwd: "/test" });
1772− const addr = server.address();
1773− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1774−
1775− const [ws] = await connectAndAwaitWelcome(port);
1776− connections.push(ws);
1777−
1778− const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
1779− cwd: workspace,
1780− relativePath: "../escape.md",
1781− contents: "# no\n",
1782− });
1783−
1784− expect(response.result).toBeUndefined();
1785− expect(response.error?.message).toContain(
1786− "Workspace file path must be relative to the project root: ../escape.md",
1787− );
1788− expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false);
1789− });
1790−
1791− it("routes git core methods over websocket", async () => {
1792− const listBranches = vi.fn(() =>
1793− Effect.succeed({
1794− branches: [],
1795− isRepo: false,
1796− hasOriginRemote: false,
1797− }),
1798− );
1799− const initRepo = vi.fn(() => Effect.void);
1800− const pullCurrentBranch = vi.fn(() =>
1801− Effect.fail(
1802− new GitCommandError({
1803− operation: "GitCore.test.pullCurrentBranch",
1804− detail: "No upstream configured",
1805− command: "git pull",
1806− cwd: "/repo/path",
1807− }),
1808− ),
1809− );
1810−
1811− server = await createTestServer({
1812− cwd: "/test",
1813− gitCore: {
1814− listBranches,
1815− initRepo,
1816− pullCurrentBranch,
1817− },
1818− });
1819− const addr = server.address();
1820− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1821−
1822− const [ws] = await connectAndAwaitWelcome(port);
1823− connections.push(ws);
1824−
1825− const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" });
1826− expect(listResponse.error).toBeUndefined();
1827− expect(listResponse.result).toEqual({ branches: [], isRepo: false, hasOriginRemote: false });
1828− expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" });
1829−
1830− const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" });
1831− expect(initResponse.error).toBeUndefined();
1832− expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" });
1833−
1834− const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" });
1835− expect(pullResponse.result).toBeUndefined();
1836− expect(pullResponse.error?.message).toContain("No upstream configured");
1837− expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");
1838− });
1839−
1840− it("supports git.status over websocket", async () => {
1841− const statusResult = {
1842− branch: "feature/test",
1843− hasWorkingTreeChanges: true,
1844− workingTree: {
1845− files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
1846− insertions: 7,
1847− deletions: 2,
1848− },
1849− hasUpstream: false,
1850− aheadCount: 0,
1851− behindCount: 0,
1852− pr: null,
1853− };
1854−
1855− const status = vi.fn(() => Effect.succeed(statusResult));
1856− const runStackedAction = vi.fn(() => Effect.void as any);
1857− const resolvePullRequest = vi.fn(() => Effect.void as any);
1858− const preparePullRequestThread = vi.fn(() => Effect.void as any);
1859− const gitManager: GitManagerShape = {
1860− status,
1861− resolvePullRequest,
1862− preparePullRequestThread,
1863− runStackedAction,
1864− };
1865−
1866− server = await createTestServer({ cwd: "/test", gitManager });
1867− const addr = server.address();
1868− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1869−
1870− const [ws] = await connectAndAwaitWelcome(port);
1871− connections.push(ws);
1872−
1873− const response = await sendRequest(ws, WS_METHODS.gitStatus, {
1874− cwd: "/test",
1875− });
1876− expect(response.error).toBeUndefined();
1877− expect(response.result).toEqual(statusResult);
1878− expect(status).toHaveBeenCalledWith({ cwd: "/test" });
1879− });
1880−
1881− it("supports git pull request routing over websocket", async () => {
1882− const resolvePullRequestResult = {
1883− pullRequest: {
1884− number: 42,
1885− title: "PR thread flow",
1886− url: "https://github.com/pingdotgg/codething-mvp/pull/42",
1887− baseBranch: "main",
1888− headBranch: "feature/pr-threads",
1889− state: "open" as const,
1890− },
1891− };
1892− const preparePullRequestThreadResult = {
1893− ...resolvePullRequestResult,
1894− branch: "feature/pr-threads",
1895− worktreePath: "/tmp/pr-threads",
1896− };
1897−
1898− const gitManager: GitManagerShape = {
1899− status: vi.fn(() => Effect.void as any),
1900− resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)),
1901− preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)),
1902− runStackedAction: vi.fn(() => Effect.void as any),
1903− };
1904−
1905− server = await createTestServer({ cwd: "/test", gitManager });
1906− const addr = server.address();
1907− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1908−
1909− const [ws] = await connectAndAwaitWelcome(port);
1910− connections.push(ws);
1911−
1912− const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, {
1913− cwd: "/test",
1914− reference: "#42",
1915− });
1916− expect(resolveResponse.error).toBeUndefined();
1917− expect(resolveResponse.result).toEqual(resolvePullRequestResult);
1918−
1919− const prepareResponse = await sendRequest(ws, WS_METHODS.gitPreparePullRequestThread, {
1920− cwd: "/test",
1921− reference: "42",
1922− mode: "worktree",
1923− });
1924− expect(prepareResponse.error).toBeUndefined();
1925− expect(prepareResponse.result).toEqual(preparePullRequestThreadResult);
1926− expect(gitManager.resolvePullRequest).toHaveBeenCalledWith({
1927− cwd: "/test",
1928− reference: "#42",
1929− });
1930− expect(gitManager.preparePullRequestThread).toHaveBeenCalledWith({
1931− cwd: "/test",
1932− reference: "42",
1933− mode: "worktree",
1934− });
1935− });
1936−
1937− it("returns errors from git.runStackedAction", async () => {
1938− const runStackedAction = vi.fn(() =>
1939− Effect.fail(
1940− new GitManagerError({
1941− operation: "GitManager.test.runStackedAction",
1942− detail: "Cannot push from detached HEAD.",
1943− }),
1944− ),
1945− );
1946− const gitManager: GitManagerShape = {
1947− status: vi.fn(() => Effect.void as any),
1948− resolvePullRequest: vi.fn(() => Effect.void as any),
1949− preparePullRequestThread: vi.fn(() => Effect.void as any),
1950− runStackedAction,
1951− };
1952−
1953− server = await createTestServer({ cwd: "/test", gitManager });
1954− const addr = server.address();
1955− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1956−
1957− const [ws] = await connectAndAwaitWelcome(port);
1958− connections.push(ws);
1959−
1960− const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, {
1961− actionId: "client-action-1",
1962− cwd: "/test",
1963− action: "commit_push",
1964− modelSelection: {
1965− provider: "codex",
1966− model: "gpt-5.4-mini",
1967− },
1968− });
1969− expect(response.result).toBeUndefined();
1970− expect(response.error?.message).toContain("detached HEAD");
1971− expect(runStackedAction).toHaveBeenCalledWith(
1972− {
1973− actionId: "client-action-1",
1974− cwd: "/test",
1975− action: "commit_push",
1976− },
1977− expect.objectContaining({
1978− actionId: "client-action-1",
1979− progressReporter: expect.any(Object),
1980− }),
1981− );
1982− });
1983−
1984− it("publishes git action progress only to the initiating websocket", async () => {
1985− const runStackedAction = vi.fn(
1986− (_input, options) =>
1987− options?.progressReporter
1988− ?.publish({
1989− actionId: options.actionId ?? "action-1",
1990− cwd: "/test",
1991− action: "commit",
1992− kind: "phase_started",
1993− phase: "commit",
1994− label: "Committing...",
1995− })
1996− .pipe(
1997− Effect.flatMap(() =>
1998− Effect.succeed({
1999− action: "commit" as const,
2000− branch: { status: "skipped_not_requested" as const },
2001− commit: {
2002− status: "created" as const,
2003− commitSha: "abc1234",
2004− subject: "Test commit",
2005− },
2006− push: { status: "skipped_not_requested" as const },
2007− pr: { status: "skipped_not_requested" as const },
2008− }),
2009− ),
2010− ) ?? Effect.void,
2011− );
2012− const gitManager: GitManagerShape = {
2013− status: vi.fn(() => Effect.void as any),
2014− resolvePullRequest: vi.fn(() => Effect.void as any),
2015− preparePullRequestThread: vi.fn(() => Effect.void as any),
2016− runStackedAction,
2017− };
2018−
2019− server = await createTestServer({ cwd: "/test", gitManager });
2020− const addr = server.address();
2021− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
2022−
2023− const [initiatingWs] = await connectAndAwaitWelcome(port);
2024− const [otherWs] = await connectAndAwaitWelcome(port);
2025− connections.push(initiatingWs, otherWs);
2026−
2027− const responsePromise = sendRequest(initiatingWs, WS_METHODS.gitRunStackedAction, {
2028− actionId: "client-action-2",
2029− cwd: "/test",
2030− action: "commit",
2031− modelSelection: {
2032− provider: "codex",
2033− model: "gpt-5.4-mini",
2034− },
2035− });
2036− const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress);
2037−
2038− expect(progressPush.data).toEqual({
2039− actionId: "client-action-2",
2040− cwd: "/test",
2041− action: "commit",
2042− kind: "phase_started",
2043− phase: "commit",
2044− label: "Committing...",
2045− });
2046−
2047− await expect(
2048− waitForPush(otherWs, WS_CHANNELS.gitActionProgress, undefined, 10, 100),
2049− ).rejects.toThrow("Timed out waiting for WebSocket message after 100ms");
2050− await expect(responsePromise).resolves.toEqual(
2051− expect.objectContaining({
2052− result: expect.objectContaining({
2053− action: "commit",
2054− }),
2055− }),
2056− );
2057− });
2058−
2059− it("rejects websocket connections without a valid auth token", async () => {
2060− server = await createTestServer({ cwd: "/test", authToken: "secret-token" });
2061− const addr = server.address();
2062− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
2063−
2064− await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed");
2065−
2066− const [authorizedWs] = await connectAndAwaitWelcome(port, "secret-token");
2067− connections.push(authorizedWs);
2068− });
2069−});
apps/server/src/wsServer.ts+0−998
@@ -1,998 +0,0 @@
1−/**
2− * Server - HTTP/WebSocket server service interface.
3− *
4− * Owns startup and shutdown lifecycle of the HTTP server, static asset serving,
5− * and WebSocket request routing.
6− *
7− * @module Server
8− */
9−import http from "node:http";
10−import type { Duplex } from "node:stream";
11−
12−import Mime from "@effect/platform-node/Mime";
13−import {
14− CommandId,
15− DEFAULT_PROVIDER_INTERACTION_MODE,
16− type ClientOrchestrationCommand,
17− type OrchestrationCommand,
18− ORCHESTRATION_WS_CHANNELS,
19− ORCHESTRATION_WS_METHODS,
20− PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
21− ProjectId,
22− ThreadId,
23− WS_CHANNELS,
24− WS_METHODS,
25− WebSocketRequest,
26− type WsResponse as WsResponseMessage,
27− WsResponse,
28− type WsPushEnvelopeBase,
29−} from "@t3tools/contracts";
30−import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
31−import {
32− Cause,
33− Effect,
34− Exit,
35− FileSystem,
36− Layer,
37− Path,
38− Ref,
39− Result,
40− Schema,
41− Scope,
42− ServiceMap,
43− Stream,
44− Struct,
45−} from "effect";
46−import { WebSocketServer, type WebSocket } from "ws";
47−
48−import { createLogger } from "./logger";
49−import { GitManager } from "./git/Services/GitManager.ts";
50−import { TerminalManager } from "./terminal/Services/Manager.ts";
51−import { Keybindings } from "./keybindings";
52−import { ServerSettingsService } from "./serverSettings";
53−import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
54−import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
55−import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
56−import { ProviderService } from "./provider/Services/ProviderService";
57−import { ProviderRegistry } from "./provider/Services/ProviderRegistry";
58−import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
59−import { clamp } from "effect/Number";
60−import { Open, resolveAvailableEditors } from "./open";
61−import { ServerConfig } from "./config";
62−import { GitCore } from "./git/Services/GitCore.ts";
63−import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
64−import {
65− ATTACHMENTS_ROUTE_PREFIX,
66− normalizeAttachmentRelativePath,
67− resolveAttachmentRelativePath,
68−} from "./attachmentPaths";
69−
70−import {
71− createAttachmentId,
72− resolveAttachmentPath,
73− resolveAttachmentPathById,
74−} from "./attachmentStore.ts";
75−import { parseBase64DataUrl } from "./imageMime.ts";
76−import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
77−import { makeServerPushBus } from "./wsServer/pushBus.ts";
78−import { makeServerReadiness } from "./wsServer/readiness.ts";
79−import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
80−import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
81−import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
82−import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
83−import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";
84−
85−/**
86− * ServerShape - Service API for server lifecycle control.
87− */
88−export interface ServerShape {
89− /**
90− * Start HTTP and WebSocket listeners.
91− */
92− readonly start: Effect.Effect<
93− http.Server,
94− ServerLifecycleError,
95− Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
96− >;
97−
98− /**
99− * Wait for process shutdown signals.
100− */
101− readonly stopSignal: Effect.Effect<void, never>;
102−}
103−
104−/**
105− * Server - Service tag for HTTP/WebSocket lifecycle management.
106− */
107−export class Server extends ServiceMap.Service<Server, ServerShape>()("t3/wsServer/Server") {}
108−
109−const isServerNotRunningError = (error: Error): boolean => {
110− const maybeCode = (error as NodeJS.ErrnoException).code;
111− return (
112− maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running")
113− );
114−};
115−
116−function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void {
117− socket.end(
118− `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` +
119− "Connection: close\r\n" +
120− "Content-Type: text/plain\r\n" +
121− `Content-Length: ${Buffer.byteLength(message)}\r\n` +
122− "\r\n" +
123− message,
124− );
125−}
126−
127−function websocketRawToString(raw: unknown): string | null {
128− if (typeof raw === "string") {
129− return raw;
130− }
131− if (raw instanceof Uint8Array) {
132− return Buffer.from(raw).toString("utf8");
133− }
134− if (raw instanceof ArrayBuffer) {
135− return Buffer.from(new Uint8Array(raw)).toString("utf8");
136− }
137− if (Array.isArray(raw)) {
138− const chunks: string[] = [];
139− for (const chunk of raw) {
140− if (typeof chunk === "string") {
141− chunks.push(chunk);
142− continue;
143− }
144− if (chunk instanceof Uint8Array) {
145− chunks.push(Buffer.from(chunk).toString("utf8"));
146− continue;
147− }
148− if (chunk instanceof ArrayBuffer) {
149− chunks.push(Buffer.from(new Uint8Array(chunk)).toString("utf8"));
150− continue;
151− }
152− return null;
153− }
154− return chunks.join("");
155− }
156− return null;
157−}
158−
159−function stripRequestTag<T extends { _tag: string }>(body: T) {
160− return Struct.omit(body, ["_tag"]);
161−}
162−
163−const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse));
164−const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest);
165−
166−export type ServerCoreRuntimeServices =
167− | OrchestrationEngineService
168− | ProjectionSnapshotQuery
169− | CheckpointDiffQuery
170− | OrchestrationReactor
171− | ProviderService
172− | ProviderRegistry;
173−
174−export type ServerRuntimeServices =
175− | ServerCoreRuntimeServices
176− | GitManager
177− | GitCore
178− | TerminalManager
179− | Keybindings
180− | ServerSettingsService
181− | ProjectFaviconResolver
182− | WorkspaceEntries
183− | WorkspaceFileSystem
184− | WorkspacePaths
185− | Open
186− | AnalyticsService;
187−
188−export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(
189− "ServerLifecycleError",
190− {
191− operation: Schema.String,
192− cause: Schema.optional(Schema.Defect),
193− },
194−) {}
195−
196−class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("RouteRequestError", {
197− message: Schema.String,
198−}) {}
199−
200−export const createServer = Effect.fn(function* (): Effect.fn.Return<
201− http.Server,
202− ServerLifecycleError,
203− Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
204−> {
205− const serverConfig = yield* ServerConfig;
206− const {
207− port,
208− cwd,
209− keybindingsConfigPath,
210− staticDir,
211− devUrl,
212− authToken,
213− host,
214− logWebSocketEvents,
215− autoBootstrapProjectFromCwd,
216− } = serverConfig;
217− const availableEditors = resolveAvailableEditors();
218−
219− const runtimeServices = yield* Effect.services<
220− ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
221− >();
222− const runPromise = Effect.runPromiseWith(runtimeServices);
223−
224− const gitManager = yield* GitManager;
225− const terminalManager = yield* TerminalManager;
226− const keybindingsManager = yield* Keybindings;
227− const serverSettingsManager = yield* ServerSettingsService;
228− const providerRegistry = yield* ProviderRegistry;
229− const git = yield* GitCore;
230− const workspaceEntries = yield* WorkspaceEntries;
231− const workspaceFileSystem = yield* WorkspaceFileSystem;
232− const workspacePaths = yield* WorkspacePaths;
233− const fileSystem = yield* FileSystem.FileSystem;
234− const path = yield* Path.Path;
235−
236− yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe(
237− Effect.catch((error) =>
238− Effect.logWarning("failed to sync keybindings defaults on startup", {
239− path: error.configPath,
240− detail: error.detail,
241− cause: error.cause,
242− }),
243− ),
244− );
245−
246− const providersRef = yield* Ref.make(yield* providerRegistry.getProviders);
247−
248− const clients = yield* Ref.make(new Set<WebSocket>());
249− const logger = createLogger("ws");
250− const readiness = yield* makeServerReadiness;
251−
252− function logOutgoingPush(push: WsPushEnvelopeBase, recipients: number) {
253− if (!logWebSocketEvents) return;
254− logger.event("outgoing push", {
255− channel: push.channel,
256− sequence: push.sequence,
257− recipients,
258− payload: push.data,
259− });
260− }
261−
262− const pushBus = yield* makeServerPushBus({
263− clients,
264− logOutgoingPush,
265− });
266− yield* readiness.markPushBusReady;
267− yield* keybindingsManager.start.pipe(
268− Effect.mapError(
269− (cause) => new ServerLifecycleError({ operation: "keybindingsRuntimeStart", cause }),
270− ),
271− );
272− yield* readiness.markKeybindingsReady;
273− yield* serverSettingsManager.start.pipe(
274− Effect.mapError(
275− (cause) => new ServerLifecycleError({ operation: "serverSettingsRuntimeStart", cause }),
276− ),
277− );
278−
279− const normalizeDispatchCommand = Effect.fnUntraced(function* (input: {
280− readonly command: ClientOrchestrationCommand;
281− }) {
282− if (input.command.type === "project.create") {
283− return {
284− ...input.command,
285− workspaceRoot: yield* workspacePaths
286− .normalizeWorkspaceRoot(input.command.workspaceRoot)
287− .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))),
288− } satisfies OrchestrationCommand;
289− }
290−
291− if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) {
292− return {
293− ...input.command,
294− workspaceRoot: yield* workspacePaths
295− .normalizeWorkspaceRoot(input.command.workspaceRoot)
296− .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))),
297− } satisfies OrchestrationCommand;
298− }
299−
300− if (input.command.type !== "thread.turn.start") {
301− return input.command as OrchestrationCommand;
302− }
303− const turnStartCommand = input.command;
304−
305− const normalizedAttachments = yield* Effect.forEach(
306− turnStartCommand.message.attachments,
307− (attachment) =>
308− Effect.gen(function* () {
309− const parsed = parseBase64DataUrl(attachment.dataUrl);
310− if (!parsed || !parsed.mimeType.startsWith("image/")) {
311− return yield* new RouteRequestError({
312− message: `Invalid image attachment payload for '${attachment.name}'.`,
313− });
314− }
315−
316− const bytes = Buffer.from(parsed.base64, "base64");
317− if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
318− return yield* new RouteRequestError({
319− message: `Image attachment '${attachment.name}' is empty or too large.`,
320− });
321− }
322−
323− const attachmentId = createAttachmentId(turnStartCommand.threadId);
324− if (!attachmentId) {
325− return yield* new RouteRequestError({
326− message: "Failed to create a safe attachment id.",
327− });
328− }
329−
330− const persistedAttachment = {
331− type: "image" as const,
332− id: attachmentId,
333− name: attachment.name,
334− mimeType: parsed.mimeType.toLowerCase(),
335− sizeBytes: bytes.byteLength,
336− };
337−
338− const attachmentPath = resolveAttachmentPath({
339− attachmentsDir: serverConfig.attachmentsDir,
340− attachment: persistedAttachment,
341− });
342− if (!attachmentPath) {
343− return yield* new RouteRequestError({
344− message: `Failed to resolve persisted path for '${attachment.name}'.`,
345− });
346− }
347−
348− yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe(
349− Effect.mapError(
350− () =>
351− new RouteRequestError({
352− message: `Failed to create attachment directory for '${attachment.name}'.`,
353− }),
354− ),
355− );
356− yield* fileSystem.writeFile(attachmentPath, bytes).pipe(
357− Effect.mapError(
358− () =>
359− new RouteRequestError({
360− message: `Failed to persist attachment '${attachment.name}'.`,
361− }),
362− ),
363− );
364−
365− return persistedAttachment;
366− }),
367− { concurrency: 1 },
368− );
369−
370− return {
371− ...turnStartCommand,
372− message: {
373− ...turnStartCommand.message,
374− attachments: normalizedAttachments,
375− },
376− } satisfies OrchestrationCommand;
377− });
378−
379− // HTTP server — serves static files or redirects to Vite dev server
380− const httpServer = http.createServer((req, res) => {
381− const respond = (
382− statusCode: number,
383− headers: Record<string, string>,
384− body?: string | Uint8Array,
385− ) => {
386− res.writeHead(statusCode, headers);
387− res.end(body);
388− };
389−
390− void runPromise(
391− Effect.gen(function* () {
392− const url = new URL(req.url ?? "/", `http://localhost:${port}`);
393− if (yield* tryHandleProjectFaviconRequest(url, res)) {
394− return;
395− }
396−
397− if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) {
398− const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
399− const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);
400− if (!normalizedRelativePath) {
401− respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
402− return;
403− }
404−
405− const isIdLookup =
406− !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");
407− const filePath = isIdLookup
408− ? resolveAttachmentPathById({
409− attachmentsDir: serverConfig.attachmentsDir,
410− attachmentId: normalizedRelativePath,
411− })
412− : resolveAttachmentRelativePath({
413− attachmentsDir: serverConfig.attachmentsDir,
414− relativePath: normalizedRelativePath,
415− });
416− if (!filePath) {
417− respond(
418− isIdLookup ? 404 : 400,
419− { "Content-Type": "text/plain" },
420− isIdLookup ? "Not Found" : "Invalid attachment path",
421− );
422− return;
423− }
424−
425− const fileInfo = yield* fileSystem
426− .stat(filePath)
427− .pipe(Effect.catch(() => Effect.succeed(null)));
428− if (!fileInfo || fileInfo.type !== "File") {
429− respond(404, { "Content-Type": "text/plain" }, "Not Found");
430− return;
431− }
432−
433− const contentType = Mime.getType(filePath) ?? "application/octet-stream";
434− res.writeHead(200, {
435− "Content-Type": contentType,
436− "Cache-Control": "public, max-age=31536000, immutable",
437− });
438− const streamExit = yield* Stream.runForEach(fileSystem.stream(filePath), (chunk) =>
439− Effect.sync(() => {
440− if (!res.destroyed) {
441− res.write(chunk);
442− }
443− }),
444− ).pipe(Effect.exit);
445− if (Exit.isFailure(streamExit)) {
446− if (!res.destroyed) {
447− res.destroy();
448− }
449− return;
450− }
451− if (!res.writableEnded) {
452− res.end();
453− }
454− return;
455− }
456−
457− // In dev mode, redirect to Vite dev server
458− if (devUrl) {
459− respond(302, { Location: devUrl.href });
460− return;
461− }
462−
463− // Serve static files from the web app build
464− if (!staticDir) {
465− respond(
466− 503,
467− { "Content-Type": "text/plain" },
468− "No static directory configured and no dev URL set.",
469− );
470− return;
471− }
472−
473− const staticRoot = path.resolve(staticDir);
474− const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname;
475− const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
476− const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
477− const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, "");
478− const hasPathTraversalSegment = staticRelativePath.startsWith("..");
479− if (
480− staticRelativePath.length === 0 ||
481− hasRawLeadingParentSegment ||
482− hasPathTraversalSegment ||
483− staticRelativePath.includes("\0")
484− ) {
485− respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
486− return;
487− }
488−
489− const isWithinStaticRoot = (candidate: string) =>
490− candidate === staticRoot ||
491− candidate.startsWith(
492− staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`,
493− );
494−
495− let filePath = path.resolve(staticRoot, staticRelativePath);
496− if (!isWithinStaticRoot(filePath)) {
497− respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
498− return;
499− }
500−
501− const ext = path.extname(filePath);
502− if (!ext) {
503− filePath = path.resolve(filePath, "index.html");
504− if (!isWithinStaticRoot(filePath)) {
505− respond(400, { "Content-Type": "text/plain" }, "Invalid static file path");
506− return;
507− }
508− }
509−
510− const fileInfo = yield* fileSystem
511− .stat(filePath)
512− .pipe(Effect.catch(() => Effect.succeed(null)));
513− if (!fileInfo || fileInfo.type !== "File") {
514− const indexPath = path.resolve(staticRoot, "index.html");
515− const indexData = yield* fileSystem
516− .readFile(indexPath)
517− .pipe(Effect.catch(() => Effect.succeed(null)));
518− if (!indexData) {
519− respond(404, { "Content-Type": "text/plain" }, "Not Found");
520− return;
521− }
522− respond(200, { "Content-Type": "text/html; charset=utf-8" }, indexData);
523− return;
524− }
525−
526− const contentType = Mime.getType(filePath) ?? "application/octet-stream";
527− const data = yield* fileSystem
528− .readFile(filePath)
529− .pipe(Effect.catch(() => Effect.succeed(null)));
530− if (!data) {
531− respond(500, { "Content-Type": "text/plain" }, "Internal Server Error");
532− return;
533− }
534− respond(200, { "Content-Type": contentType }, data);
535− }),
536− ).catch(() => {
537− if (!res.headersSent) {
538− respond(500, { "Content-Type": "text/plain" }, "Internal Server Error");
539− }
540− });
541− });
542−
543− // WebSocket server — upgrades from the HTTP server
544− const wss = new WebSocketServer({ noServer: true });
545−
546− const closeWebSocketServer = Effect.callback<void, ServerLifecycleError>((resume) => {
547− wss.close((error) => {
548− if (error && !isServerNotRunningError(error)) {
549− resume(
550− Effect.fail(
551− new ServerLifecycleError({ operation: "closeWebSocketServer", cause: error }),
552− ),
553− );
554− } else {
555− resume(Effect.void);
556− }
557− });
558− });
559−
560− const closeAllClients = Ref.get(clients).pipe(
561− Effect.flatMap(Effect.forEach((client) => Effect.sync(() => client.close()))),
562− Effect.flatMap(() => Ref.set(clients, new Set())),
563− );
564−
565− const listenOptions = host ? { host, port } : { port };
566−
567− const orchestrationEngine = yield* OrchestrationEngineService;
568− const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
569− const checkpointDiffQuery = yield* CheckpointDiffQuery;
570− const orchestrationReactor = yield* OrchestrationReactor;
571− const { openInEditor } = yield* Open;
572−
573− const subscriptionsScope = yield* Scope.make("sequential");
574− yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void));
575−
576− yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>
577− pushBus.publishAll(ORCHESTRATION_WS_CHANNELS.domainEvent, event),
578− ).pipe(Effect.forkIn(subscriptionsScope));
579−
580− yield* Stream.runForEach(keybindingsManager.streamChanges, (event) =>
581− pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
582− issues: event.issues,
583− }),
584− ).pipe(Effect.forkIn(subscriptionsScope));
585−
586− yield* Stream.runForEach(serverSettingsManager.streamChanges, (settings) =>
587− pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
588− issues: [],
589− settings,
590− }),
591− ).pipe(Effect.forkIn(subscriptionsScope));
592−
593− yield* Stream.runForEach(providerRegistry.streamChanges, (providers) =>
594− Effect.gen(function* () {
595− yield* Ref.set(providersRef, providers);
596− yield* pushBus.publishAll(WS_CHANNELS.serverProvidersUpdated, {
597− providers,
598− });
599− }),
600− ).pipe(Effect.forkIn(subscriptionsScope));
601−
602− yield* Scope.provide(orchestrationReactor.start(), subscriptionsScope);
603− yield* readiness.markOrchestrationSubscriptionsReady;
604−
605− let welcomeBootstrapProjectId: ProjectId | undefined;
606− let welcomeBootstrapThreadId: ThreadId | undefined;
607−
608− if (autoBootstrapProjectFromCwd) {
609− yield* Effect.gen(function* () {
610− const snapshot = yield* projectionReadModelQuery.getSnapshot();
611− const existingProject = snapshot.projects.find(
612− (project) => project.workspaceRoot === cwd && project.deletedAt === null,
613− );
614− let bootstrapProjectId: ProjectId;
615− let bootstrapProjectDefaultModelSelection;
616−
617− if (!existingProject) {
618− const createdAt = new Date().toISOString();
619− bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID());
620− const bootstrapProjectTitle = path.basename(cwd) || "project";
621− bootstrapProjectDefaultModelSelection = {
622− provider: "codex" as const,
623− model: "gpt-5-codex",
624− };
625− yield* orchestrationEngine.dispatch({
626− type: "project.create",
627− commandId: CommandId.makeUnsafe(crypto.randomUUID()),
628− projectId: bootstrapProjectId,
629− title: bootstrapProjectTitle,
630− workspaceRoot: cwd,
631− defaultModelSelection: bootstrapProjectDefaultModelSelection,
632− createdAt,
633− });
634− } else {
635− bootstrapProjectId = existingProject.id;
636− bootstrapProjectDefaultModelSelection = existingProject.defaultModelSelection ?? {
637− provider: "codex" as const,
638− model: "gpt-5-codex",
639− };
640− }
641−
642− const existingThread = snapshot.threads.find(
643− (thread) => thread.projectId === bootstrapProjectId && thread.deletedAt === null,
644− );
645− if (!existingThread) {
646− const createdAt = new Date().toISOString();
647− const threadId = ThreadId.makeUnsafe(crypto.randomUUID());
648− yield* orchestrationEngine.dispatch({
649− type: "thread.create",
650− commandId: CommandId.makeUnsafe(crypto.randomUUID()),
651− threadId,
652− projectId: bootstrapProjectId,
653− title: "New thread",
654− modelSelection: bootstrapProjectDefaultModelSelection,
655− interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
656− runtimeMode: "full-access",
657− branch: null,
658− worktreePath: null,
659− createdAt,
660− });
661− welcomeBootstrapProjectId = bootstrapProjectId;
662− welcomeBootstrapThreadId = threadId;
663− } else {
664− welcomeBootstrapProjectId = bootstrapProjectId;
665− welcomeBootstrapThreadId = existingThread.id;
666− }
667− }).pipe(
668− Effect.mapError(
669− (cause) => new ServerLifecycleError({ operation: "autoBootstrapProject", cause }),
670− ),
671− );
672− }
673−
674− const unsubscribeTerminalEvents = yield* terminalManager.subscribe((event) =>
675− pushBus.publishAll(WS_CHANNELS.terminalEvent, event),
676− );
677− yield* Scope.addFinalizer(subscriptionsScope, Effect.sync(unsubscribeTerminalEvents));
678− yield* readiness.markTerminalSubscriptionsReady;
679−
680− yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe(
681− Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })),
682− );
683− yield* readiness.markHttpListening;
684−
685− yield* Effect.addFinalizer(() =>
686− Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]),
687− );
688−
689− const routeRequest = Effect.fnUntraced(function* (ws: WebSocket, request: WebSocketRequest) {
690− switch (request.body._tag) {
691− case ORCHESTRATION_WS_METHODS.getSnapshot:
692− return yield* projectionReadModelQuery.getSnapshot();
693−
694− case ORCHESTRATION_WS_METHODS.dispatchCommand: {
695− const { command } = request.body;
696− const normalizedCommand = yield* normalizeDispatchCommand({ command });
697− return yield* orchestrationEngine.dispatch(normalizedCommand);
698− }
699−
700− case ORCHESTRATION_WS_METHODS.getTurnDiff: {
701− const body = stripRequestTag(request.body);
702− return yield* checkpointDiffQuery.getTurnDiff(body);
703− }
704−
705− case ORCHESTRATION_WS_METHODS.getFullThreadDiff: {
706− const body = stripRequestTag(request.body);
707− return yield* checkpointDiffQuery.getFullThreadDiff(body);
708− }
709−
710− case ORCHESTRATION_WS_METHODS.replayEvents: {
711− const { fromSequenceExclusive } = request.body;
712− return yield* Stream.runCollect(
713− orchestrationEngine.readEvents(
714− clamp(fromSequenceExclusive, {
715− maximum: Number.MAX_SAFE_INTEGER,
716− minimum: 0,
717− }),
718− ),
719− ).pipe(Effect.map((events) => Array.from(events)));
720− }
721−
722− case WS_METHODS.projectsSearchEntries: {
723− const body = stripRequestTag(request.body);
724− return yield* workspaceEntries.search(body).pipe(
725− Effect.mapError(
726− (cause) =>
727− new RouteRequestError({
728− message: `Failed to search workspace entries: ${cause.detail}`,
729− }),
730− ),
731− );
732− }
733−
734− case WS_METHODS.projectsWriteFile: {
735− const body = stripRequestTag(request.body);
736− return yield* workspaceFileSystem.writeFile(body).pipe(
737− Effect.mapError(
738− (cause) =>
739− new RouteRequestError({
740− message: `Failed to write workspace file: ${cause.message}`,
741− }),
742− ),
743− );
744− }
745−
746− case WS_METHODS.shellOpenInEditor: {
747− const body = stripRequestTag(request.body);
748− return yield* openInEditor(body);
749− }
750−
751− case WS_METHODS.gitStatus: {
752− const body = stripRequestTag(request.body);
753− return yield* gitManager.status(body);
754− }
755−
756− case WS_METHODS.gitPull: {
757− const body = stripRequestTag(request.body);
758− return yield* git.pullCurrentBranch(body.cwd);
759− }
760−
761− case WS_METHODS.gitRunStackedAction: {
762− const body = stripRequestTag(request.body);
763− return yield* gitManager.runStackedAction(body, {
764− actionId: body.actionId,
765− progressReporter: {
766− publish: (event) =>
767− pushBus.publishClient(ws, WS_CHANNELS.gitActionProgress, event).pipe(Effect.asVoid),
768− },
769− });
770− }
771−
772− case WS_METHODS.gitResolvePullRequest: {
773− const body = stripRequestTag(request.body);
774− return yield* gitManager.resolvePullRequest(body);
775− }
776−
777− case WS_METHODS.gitPreparePullRequestThread: {
778− const body = stripRequestTag(request.body);
779− return yield* gitManager.preparePullRequestThread(body);
780− }
781−
782− case WS_METHODS.gitListBranches: {
783− const body = stripRequestTag(request.body);
784− return yield* git.listBranches(body);
785− }
786−
787− case WS_METHODS.gitCreateWorktree: {
788− const body = stripRequestTag(request.body);
789− return yield* git.createWorktree(body);
790− }
791−
792− case WS_METHODS.gitRemoveWorktree: {
793− const body = stripRequestTag(request.body);
794− return yield* git.removeWorktree(body);
795− }
796−
797− case WS_METHODS.gitCreateBranch: {
798− const body = stripRequestTag(request.body);
799− return yield* git.createBranch(body);
800− }
801−
802− case WS_METHODS.gitCheckout: {
803− const body = stripRequestTag(request.body);
804− return yield* Effect.scoped(git.checkoutBranch(body));
805− }
806−
807− case WS_METHODS.gitInit: {
808− const body = stripRequestTag(request.body);
809− return yield* git.initRepo(body);
810− }
811−
812− case WS_METHODS.terminalOpen: {
813− const body = stripRequestTag(request.body);
814− return yield* terminalManager.open(body);
815− }
816−
817− case WS_METHODS.terminalWrite: {
818− const body = stripRequestTag(request.body);
819− return yield* terminalManager.write(body);
820− }
821−
822− case WS_METHODS.terminalResize: {
823− const body = stripRequestTag(request.body);
824− return yield* terminalManager.resize(body);
825− }
826−
827− case WS_METHODS.terminalClear: {
828− const body = stripRequestTag(request.body);
829− return yield* terminalManager.clear(body);
830− }
831−
832− case WS_METHODS.terminalRestart: {
833− const body = stripRequestTag(request.body);
834− return yield* terminalManager.restart(body);
835− }
836−
837− case WS_METHODS.terminalClose: {
838− const body = stripRequestTag(request.body);
839− return yield* terminalManager.close(body);
840− }
841−
842− case WS_METHODS.serverGetConfig: {
843− const keybindingsConfig = yield* keybindingsManager.loadConfigState;
844− const settings = yield* serverSettingsManager.getSettings;
845− const providers = yield* Ref.get(providersRef);
846− return {
847− cwd,
848− keybindingsConfigPath,
849− keybindings: keybindingsConfig.keybindings,
850− issues: keybindingsConfig.issues,
851− providers,
852− availableEditors,
853− settings,
854− };
855− }
856−
857− case WS_METHODS.serverRefreshProviders: {
858− const providers = yield* providerRegistry.refresh();
859− yield* Ref.set(providersRef, providers);
860− return { providers };
861− }
862−
863− case WS_METHODS.serverUpsertKeybinding: {
864− const body = stripRequestTag(request.body);
865− const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body);
866− return { keybindings: keybindingsConfig, issues: [] };
867− }
868−
869− case WS_METHODS.serverGetSettings: {
870− return yield* serverSettingsManager.getSettings;
871− }
872−
873− case WS_METHODS.serverUpdateSettings: {
874− const body = stripRequestTag(request.body);
875− return yield* serverSettingsManager.updateSettings(body.patch);
876− }
877−
878− default: {
879− const _exhaustiveCheck: never = request.body;
880− return yield* new RouteRequestError({
881− message: `Unknown method: ${String(_exhaustiveCheck)}`,
882− });
883− }
884− }
885− });
886−
887− const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) {
888− const sendWsResponse = (response: WsResponseMessage) =>
889− encodeWsResponse(response).pipe(
890− Effect.tap((encodedResponse) => Effect.sync(() => ws.send(encodedResponse))),
891− Effect.asVoid,
892− );
893−
894− const messageText = websocketRawToString(raw);
895− if (messageText === null) {
896− return yield* sendWsResponse({
897− id: "unknown",
898− error: { message: "Invalid request format: Failed to read message" },
899− });
900− }
901−
902− const request = decodeWebSocketRequest(messageText);
903− if (Result.isFailure(request)) {
904− return yield* sendWsResponse({
905− id: "unknown",
906− error: { message: `Invalid request format: ${formatSchemaError(request.failure)}` },
907− });
908− }
909−
910− const result = yield* Effect.exit(routeRequest(ws, request.success));
911− if (Exit.isFailure(result)) {
912− return yield* sendWsResponse({
913− id: request.success.id,
914− error: { message: Cause.pretty(result.cause) },
915− });
916− }
917−
918− return yield* sendWsResponse({
919− id: request.success.id,
920− result: result.value,
921− });
922− });
923−
924− httpServer.on("upgrade", (request, socket, head) => {
925− socket.on("error", () => {}); // Prevent unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake
926−
927− if (authToken) {
928− let providedToken: string | null = null;
929− try {
930− const url = new URL(request.url ?? "/", `http://localhost:${port}`);
931− providedToken = url.searchParams.get("token");
932− } catch {
933− rejectUpgrade(socket, 400, "Invalid WebSocket URL");
934− return;
935− }
936−
937− if (providedToken !== authToken) {
938− rejectUpgrade(socket, 401, "Unauthorized WebSocket connection");
939− return;
940− }
941− }
942−
943− wss.handleUpgrade(request, socket, head, (ws) => {
944− wss.emit("connection", ws, request);
945− });
946− });
947−
948− wss.on("connection", (ws) => {
949− const segments = cwd.split(/[/\\]/).filter(Boolean);
950− const projectName = segments[segments.length - 1] ?? "project";
951−
952− const welcomeData = {
953− cwd,
954− projectName,
955− ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}),
956− ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}),
957− };
958− // Send welcome before adding to broadcast set so publishAll calls
959− // cannot reach this client before the welcome arrives.
960− void runPromise(
961− readiness.awaitServerReady.pipe(
962− Effect.flatMap(() => pushBus.publishClient(ws, WS_CHANNELS.serverWelcome, welcomeData)),
963− Effect.flatMap((delivered) =>
964− delivered ? Ref.update(clients, (clients) => clients.add(ws)) : Effect.void,
965− ),
966− ),
967− );
968−
969− ws.on("message", (raw) => {
970− void runPromise(handleMessage(ws, raw).pipe(Effect.ignoreCause({ log: true })));
971− });
972−
973− ws.on("close", () => {
974− void runPromise(
975− Ref.update(clients, (clients) => {
976− clients.delete(ws);
977− return clients;
978− }),
979− );
980− });
981−
982− ws.on("error", () => {
983− void runPromise(
984− Ref.update(clients, (clients) => {
985− clients.delete(ws);
986− return clients;
987− }),
988− );
989− });
990− });
991−
992− return httpServer;
993−});
994−
995−export const ServerLive = Layer.succeed(Server, {
996− start: createServer(),
997− stopSignal: Effect.never,
998−} satisfies ServerShape);
apps/server/src/wsServer/pushBus.test.ts+0−101
@@ -1,101 +0,0 @@
1−import type { WebSocket } from "ws";
2−import { it } from "@effect/vitest";
3−import { describe, expect } from "vitest";
4−import { Effect, Ref } from "effect";
5−import { WS_CHANNELS } from "@t3tools/contracts";
6−
7−import { makeServerPushBus } from "./pushBus";
8−
9−class MockWebSocket {
10− static readonly OPEN = 1;
11−
12− readonly OPEN = MockWebSocket.OPEN;
13− readyState = MockWebSocket.OPEN;
14− readonly sent: string[] = [];
15− private readonly waiters = new Set<() => void>();
16−
17− send(message: string) {
18− this.sent.push(message);
19− for (const waiter of this.waiters) {
20− waiter();
21− }
22− }
23−
24− waitForSentCount(count: number): Promise<void> {
25− if (this.sent.length >= count) {
26− return Promise.resolve();
27− }
28−
29− return new Promise((resolve) => {
30− const check = () => {
31− if (this.sent.length < count) {
32− return;
33− }
34− this.waiters.delete(check);
35− resolve();
36− };
37−
38− this.waiters.add(check);
39− });
40− }
41−}
42−
43−describe("makeServerPushBus", () => {
44− it.live("waits for the welcome push before a new client joins broadcast delivery", () =>
45− Effect.scoped(
46− Effect.gen(function* () {
47− const client = new MockWebSocket();
48− const clients = yield* Ref.make(new Set<WebSocket>());
49− const pushBus = yield* makeServerPushBus({
50− clients,
51− logOutgoingPush: () => {},
52− });
53−
54− yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
55− issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }],
56− });
57−
58− const delivered = yield* pushBus.publishClient(
59− client as unknown as WebSocket,
60− WS_CHANNELS.serverWelcome,
61− {
62− cwd: "/tmp/project",
63− projectName: "project",
64− },
65− );
66− expect(delivered).toBe(true);
67−
68− yield* Ref.update(clients, (current) => current.add(client as unknown as WebSocket));
69−
70− yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
71− issues: [],
72− });
73−
74− yield* Effect.promise(() => client.waitForSentCount(2));
75−
76− const messages = client.sent.map(
77− (message) => JSON.parse(message) as { channel: string; data: unknown },
78− );
79−
80− expect(messages).toHaveLength(2);
81− expect(messages[0]).toEqual({
82− type: "push",
83− sequence: 2,
84− channel: WS_CHANNELS.serverWelcome,
85− data: {
86− cwd: "/tmp/project",
87− projectName: "project",
88− },
89− });
90− expect(messages[1]).toEqual({
91− type: "push",
92− sequence: 3,
93− channel: WS_CHANNELS.serverConfigUpdated,
94− data: {
95− issues: [],
96− },
97− });
98− }),
99− ),
100− );
101−});
apps/server/src/wsServer/pushBus.ts+0−114
@@ -1,114 +0,0 @@
1−import {
2− WsPush,
3− type WsPushChannel,
4− type WsPushData,
5− type WsPushEnvelopeBase,
6−} from "@t3tools/contracts";
7−import { Deferred, Effect, Queue, Ref, Schema } from "effect";
8−import type { Scope } from "effect";
9−import type { WebSocket } from "ws";
10−
11−type PushTarget =
12− | { readonly kind: "all" }
13− | { readonly kind: "client"; readonly client: WebSocket };
14−
15−interface PushJob<C extends WsPushChannel = WsPushChannel> {
16− readonly channel: C;
17− readonly data: WsPushData<C>;
18− readonly target: PushTarget;
19− readonly delivered: Deferred.Deferred<boolean> | null;
20−}
21−
22−export interface ServerPushBus {
23− readonly publishAll: <C extends WsPushChannel>(
24− channel: C,
25− data: WsPushData<C>,
26− ) => Effect.Effect<void>;
27− readonly publishClient: <C extends WsPushChannel>(
28− client: WebSocket,
29− channel: C,
30− data: WsPushData<C>,
31− ) => Effect.Effect<boolean>;
32−}
33−
34−export const makeServerPushBus = (input: {
35− readonly clients: Ref.Ref<Set<WebSocket>>;
36− readonly logOutgoingPush: (push: WsPushEnvelopeBase, recipients: number) => void;
37−}): Effect.Effect<ServerPushBus, never, Scope.Scope> =>
38− Effect.gen(function* () {
39− const nextSequence = yield* Ref.make(0);
40− const queue = yield* Queue.unbounded<PushJob>();
41− const encodePush = Schema.encodeUnknownEffect(Schema.fromJsonString(WsPush));
42−
43− const settleDelivery = (job: PushJob, delivered: boolean) =>
44− job.delivered === null
45− ? Effect.void
46− : Deferred.succeed(job.delivered, delivered).pipe(Effect.orDie);
47−
48− const send = Effect.fnUntraced(function* (job: PushJob) {
49− const sequence = yield* Ref.updateAndGet(nextSequence, (current) => current + 1);
50− const push: WsPushEnvelopeBase = {
51− type: "push",
52− sequence,
53− channel: job.channel,
54− data: job.data,
55− };
56− const recipients =
57− job.target.kind === "all" ? yield* Ref.get(input.clients) : new Set([job.target.client]);
58−
59− return yield* encodePush(push).pipe(
60− Effect.map((message) => {
61− let recipientCount = 0;
62− for (const client of recipients) {
63− if (client.readyState !== client.OPEN) {
64− continue;
65− }
66− client.send(message);
67− recipientCount += 1;
68− }
69−
70− input.logOutgoingPush(push, recipientCount);
71− return recipientCount > 0;
72− }),
73− );
74− });
75−
76− yield* Effect.forkScoped(
77− Effect.forever(
78− Queue.take(queue).pipe(
79− Effect.flatMap((job) =>
80− send(job).pipe(
81− Effect.tap((delivered) => settleDelivery(job, delivered)),
82− Effect.tapCause(() => settleDelivery(job, false)),
83− Effect.ignoreCause({ log: true }),
84− ),
85− ),
86− ),
87− ),
88− );
89−
90− const publish =
91− (target: PushTarget) =>
92− <C extends WsPushChannel>(channel: C, data: WsPushData<C>) =>
93− Queue.offer(queue, {
94− channel,
95− data,
96− target,
97− delivered: null,
98− }).pipe(Effect.asVoid);
99−
100− return {
101− publishAll: publish({ kind: "all" }),
102− publishClient: (client, channel, data) =>
103− Effect.gen(function* () {
104− const delivered = yield* Deferred.make<boolean>();
105− yield* Queue.offer(queue, {
106− channel,
107− data,
108− target: { kind: "client", client },
109− delivered,
110− }).pipe(Effect.asVoid);
111− return yield* Deferred.await(delivered);
112− }),
113− } satisfies ServerPushBus;
114− });
apps/server/src/wsServer/readiness.ts+0−36
@@ -1,36 +0,0 @@
1−import { Deferred, Effect } from "effect";
2−
3−export interface ServerReadiness {
4− readonly awaitServerReady: Effect.Effect<void>;
5− readonly markHttpListening: Effect.Effect<void>;
6− readonly markPushBusReady: Effect.Effect<void>;
7− readonly markKeybindingsReady: Effect.Effect<void>;
8− readonly markTerminalSubscriptionsReady: Effect.Effect<void>;
9− readonly markOrchestrationSubscriptionsReady: Effect.Effect<void>;
10−}
11−
12−export const makeServerReadiness = Effect.gen(function* () {
13− const httpListening = yield* Deferred.make<void>();
14− const pushBusReady = yield* Deferred.make<void>();
15− const keybindingsReady = yield* Deferred.make<void>();
16− const terminalSubscriptionsReady = yield* Deferred.make<void>();
17− const orchestrationSubscriptionsReady = yield* Deferred.make<void>();
18−
19− const complete = (deferred: Deferred.Deferred<void>) =>
20− Deferred.succeed(deferred, undefined).pipe(Effect.orDie);
21−
22− return {
23− awaitServerReady: Effect.all([
24− Deferred.await(httpListening),
25− Deferred.await(pushBusReady),
26− Deferred.await(keybindingsReady),
27− Deferred.await(terminalSubscriptionsReady),
28− Deferred.await(orchestrationSubscriptionsReady),
29− ]).pipe(Effect.asVoid),
30− markHttpListening: complete(httpListening),
31− markPushBusReady: complete(pushBusReady),
32− markKeybindingsReady: complete(keybindingsReady),
33− markTerminalSubscriptionsReady: complete(terminalSubscriptionsReady),
34− markOrchestrationSubscriptionsReady: complete(orchestrationSubscriptionsReady),
35− } satisfies ServerReadiness;
36−});
apps/server/tsdown.config.ts+1−1
@@ -1,7 +1,7 @@
11 import { defineConfig } from "tsdown";
22
33 export default defineConfig({
4− entry: ["src/index.ts"],
4+ entry: ["src/bin.ts"],
55 format: ["esm", "cjs"],
66 checks: {
77 legacyCjs: false,
apps/server/vitest.config.ts+7−2
@@ -6,8 +6,13 @@ export default mergeConfig( · 5 unmodified lines
66 baseConfig,
77 defineConfig({
88 test: {
9− testTimeout: 15_000,
10− hookTimeout: 15_000,
9+ // The server suite exercises sqlite, git, temp worktrees, and orchestration
10+ // runtimes heavily. Running files in parallel introduces load-sensitive flakes.
11+ fileParallelism: false,
12+ // Server integration tests exercise sqlite, git, and orchestration together.
13+ // Under package-wide parallel runs they regularly exceed the default 15s budget.
14+ testTimeout: 60_000,
15+ hookTimeout: 60_000,
1116 },
1217 }),
1318 );
apps/web/package.json+1−0
@@ -19,6 +19,7 @@ · 18 unmodified lines
1919 "@dnd-kit/modifiers": "^9.0.0",
2020 "@dnd-kit/sortable": "^10.0.0",
2121 "@dnd-kit/utilities": "^3.2.2",
22+ "@effect/atom-react": "catalog:",
2223 "@formkit/auto-animate": "^0.9.0",
2324 "@lexical/react": "^0.41.0",
2425 "@pierre/diffs": "^1.1.0-beta.16",
apps/web/src/components/ChatView.browser.tsx+65−64
@@ -4,16 +4,14 @@ import "../index.css"; · 3 unmodified lines
44 import {
55 EventId,
66 ORCHESTRATION_WS_METHODS,
7− ORCHESTRATION_WS_CHANNELS,
87 type MessageId,
98 type OrchestrationEvent,
109 type OrchestrationReadModel,
1110 type ProjectId,
1211 type ServerConfig,
12+ type ServerLifecycleWelcomePayload,
1313 type ThreadId,
1414 type TurnId,
15− type WsWelcomePayload,
16− WS_CHANNELS,
1715 WS_METHODS,
1816 OrchestrationSessionStatus,
1917 DEFAULT_SERVER_SETTINGS,
@@ -32,8 +30,10 @@ import { · 12 unmodified lines
3230 removeInlineTerminalContextPlaceholder,
3331 } from "../lib/terminalContext";
3432 import { isMacPlatform } from "../lib/utils";
33+import { __resetNativeApiForTests } from "../nativeApi";
3534 import { getRouter } from "../router";
3635 import { useStore } from "../store";
36+import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
3737 import { estimateTimelineMessageHeight } from "./timelineHeight";
3838 import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
3939
@@ -44,25 +44,16 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z"; · 4 unmodified lines
4444 const BASE_TIME_MS = Date.parse(NOW_ISO);
4545 const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>";
4646
47−interface WsRequestEnvelope {
48− id: string;
49− body: {
50− _tag: string;
51− [key: string]: unknown;
52− };
53−}
54−
5547 interface TestFixture {
5648 snapshot: OrchestrationReadModel;
5749 serverConfig: ServerConfig;
58− welcome: WsWelcomePayload;
50+ welcome: ServerLifecycleWelcomePayload;
5951 }
6052
6153 let fixture: TestFixture;
62−const wsRequests: WsRequestEnvelope["body"][] = [];
63−let customWsRpcResolver: ((body: WsRequestEnvelope["body"]) => unknown | undefined) | null = null;
64−let wsClient: { send: (message: string) => void } | null = null;
65−let pushSequence = 1;
54+const rpcHarness = new BrowserWsRpcHarness();
55+const wsRequests = rpcHarness.requests;
56+let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null;
6657 const wsLink = ws.link(/ws(s)?:\/\/.*/);
6758
6859 interface ViewportSpec {
@@ -388,32 +379,20 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest · 319 unmodified lines
388379 }
389380
390381 function sendOrchestrationDomainEvent(event: OrchestrationEvent): void {
391− if (!wsClient) {
392− throw new Error("WebSocket client not connected");
393− }
394− wsClient.send(
395− JSON.stringify({
396− type: "push",
397− sequence: pushSequence++,
398− channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
399− data: event,
400− }),
401− );
382+ rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event);
402383 }
403384
404−async function waitForWsClient(): Promise<{ send: (message: string) => void }> {
405− let client: { send: (message: string) => void } | null = null;
385+async function waitForWsClient(): Promise<void> {
406386 await vi.waitFor(
407387 () => {
408− client = wsClient;
409− expect(client).toBeTruthy();
388+ expect(
389+ wsRequests.some(
390+ (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents,
391+ ),
392+ ).toBe(true);
410393 },
411394 { timeout: 8_000, interval: 16 },
412395 );
413− if (!client) {
414− throw new Error("WebSocket client not connected");
415− }
416− return client;
417396 }
418397
419398 async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
@@ -637,7 +616,7 @@ function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel { · 217 unmodified lines
637616 };
638617 }
639618
640−function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
619+function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
641620 const customResult = customWsRpcResolver?.(body);
642621 if (customResult !== undefined) {
643622 return customResult;
@@ -684,6 +663,9 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { · 40 unmodified lines
684663 truncated: false,
685664 };
686665 }
666+ if (tag === WS_METHODS.shellOpenInEditor) {
667+ return null;
668+ }
687669 if (tag === WS_METHODS.terminalOpen) {
688670 return {
689671 threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID,
@@ -702,34 +684,11 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { · 12 unmodified lines
702684
703685 const worker = setupWorker(
704686 wsLink.addEventListener("connection", ({ client }) => {
705− wsClient = client;
706− pushSequence = 1;
707− client.send(
708− JSON.stringify({
709− type: "push",
710− sequence: pushSequence++,
711− channel: WS_CHANNELS.serverWelcome,
712− data: fixture.welcome,
713− }),
714− );
687+ void rpcHarness.connect(client);
715688 client.addEventListener("message", (event) => {
716689 const rawData = event.data;
717690 if (typeof rawData !== "string") return;
718− let request: WsRequestEnvelope;
719− try {
720− request = JSON.parse(rawData) as WsRequestEnvelope;
721− } catch {
722− return;
723− }
724− const method = request.body?._tag;
725− if (typeof method !== "string") return;
726− wsRequests.push(request.body);
727− client.send(
728− JSON.stringify({
729− id: request.id,
730− result: resolveWsRpc(request.body),
731− }),
732− );
691+ void rpcHarness.onMessage(rawData);
733692 });
734693 }),
735694 http.get("*/attachments/:attachmentId", () =>
@@ -903,7 +862,9 @@ async function waitForInteractionModeButton( · 167 unmodified lines
903862 async function waitForServerConfigToApply(): Promise<void> {
904863 await vi.waitFor(
905864 () => {
906− expect(wsRequests.some((request) => request._tag === WS_METHODS.serverGetConfig)).toBe(true);
865+ expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe(
866+ true,
867+ );
907868 },
908869 { timeout: 8_000, interval: 16 },
909870 );
@@ -1041,7 +1002,7 @@ async function mountChatView(options: { · 131 unmodified lines
10411002 viewport: ViewportSpec;
10421003 snapshot: OrchestrationReadModel;
10431004 configureFixture?: (fixture: TestFixture) => void;
1044− resolveRpc?: (body: WsRequestEnvelope["body"]) => unknown | undefined;
1005+ resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined;
10451006 }): Promise<MountedChatView> {
10461007 fixture = buildFixture(options.snapshot);
10471008 options.configureFixture?.(fixture);
@@ -1129,10 +1090,37 @@ describe("ChatView timeline estimator parity (full app)", () => { · 81 unmodified lines
11291090 });
11301091
11311092 afterAll(async () => {
1093+ await rpcHarness.disconnect();
11321094 await worker.stop();
11331095 });
11341096
11351097 beforeEach(async () => {
1098+ await rpcHarness.reset({
1099+ resolveUnary: resolveWsRpc,
1100+ getInitialStreamValues: (request) => {
1101+ if (request._tag === WS_METHODS.subscribeServerLifecycle) {
1102+ return [
1103+ {
1104+ version: 1,
1105+ sequence: 1,
1106+ type: "welcome",
1107+ payload: fixture.welcome,
1108+ },
1109+ ];
1110+ }
1111+ if (request._tag === WS_METHODS.subscribeServerConfig) {
1112+ return [
1113+ {
1114+ version: 1,
1115+ type: "snapshot",
1116+ config: fixture.serverConfig,
1117+ },
1118+ ];
1119+ }
1120+ return [];
1121+ },
1122+ });
1123+ __resetNativeApiForTests();
11361124 await setViewport(DEFAULT_VIEWPORT);
11371125 localStorage.clear();
11381126 document.body.innerHTML = "";
@@ -1342,6 +1330,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 203 unmodified lines
13421330 });
13431331
13441332 try {
1333+ await waitForServerConfigToApply();
13451334 const openButton = await waitForElement(
13461335 () =>
13471336 Array.from(document.querySelectorAll("button")).find(
@@ -1349,6 +1338,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1 unmodified line
13491338 ) as HTMLButtonElement | null,
13501339 "Unable to find Open button.",
13511340 );
1341+ await vi.waitFor(() => {
1342+ expect(openButton.disabled).toBe(false);
1343+ });
13521344 openButton.click();
13531345
13541346 await vi.waitFor(
@@ -1384,6 +1376,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 29 unmodified lines
13841376 });
13851377
13861378 try {
1379+ await waitForServerConfigToApply();
13871380 const openButton = await waitForElement(
13881381 () =>
13891382 Array.from(document.querySelectorAll("button")).find(
@@ -1391,6 +1384,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1 unmodified line
13911384 ) as HTMLButtonElement | null,
13921385 "Unable to find Open button.",
13931386 );
1387+ await vi.waitFor(() => {
1388+ expect(openButton.disabled).toBe(false);
1389+ });
13941390 openButton.click();
13951391
13961392 await vi.waitFor(
@@ -1426,6 +1422,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 29 unmodified lines
14261422 });
14271423
14281424 try {
1425+ await waitForServerConfigToApply();
14291426 const menuButton = await waitForElement(
14301427 () => document.querySelector('button[aria-label="Copy options"]'),
14311428 "Unable to find Open picker button.",
@@ -1474,7 +1471,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 42 unmodified lines
14741471 });
14751472
14761473 it("falls back to the first installed editor when the stored favorite is unavailable", async () => {
1477− localStorage.setItem("t3code:last-editor", "vscodium");
1474+ localStorage.setItem("t3code:last-editor", JSON.stringify("vscodium"));
14781475 setDraftThreadWithoutWorktree();
14791476
14801477 const mounted = await mountChatView({
@@ -1489,6 +1486,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 8 unmodified lines
14891486 });
14901487
14911488 try {
1489+ await waitForServerConfigToApply();
14921490 const openButton = await waitForElement(
14931491 () =>
14941492 Array.from(document.querySelectorAll("button")).find(
@@ -1496,6 +1494,9 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1 unmodified line
14961494 ) as HTMLButtonElement | null,
14971495 "Unable to find Open button.",
14981496 );
1497+ await vi.waitFor(() => {
1498+ expect(openButton.disabled).toBe(false);
1499+ });
14991500 openButton.click();
15001501
15011502 await vi.waitFor(
apps/web/src/components/ChatView.tsx+10−11
@@ -11,11 +11,9 @@ import { · 10 unmodified lines
1111 type ProviderApprovalDecision,
1212 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1313 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
14− type ResolvedKeybindingsConfig,
1514 type ServerProvider,
1615 type ThreadId,
1716 type TurnId,
18− type EditorId,
1917 type KeybindingCommand,
2018 OrchestrationThreadActivity,
2119 ProviderInteractionMode,
@@ -30,7 +28,6 @@ import { useDebouncedValue } from "@tanstack/react-pacer"; · 8 unmodified lines
3028 import { useNavigate, useSearch } from "@tanstack/react-router";
3129 import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
3230 import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
33−import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3431 import { isElectron } from "../env";
3532 import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
3633 import {
@@ -195,15 +192,18 @@ import { · 158 unmodified lines
195192 waitForStartedServerThread,
196193 } from "./ChatView.logic";
197194 import { useLocalStorage } from "~/hooks/useLocalStorage";
195+import {
196+ useServerAvailableEditors,
197+ useServerConfig,
198+ useServerKeybindings,
199+} from "~/rpc/serverState";
198200
199201 const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
200202 const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
201203 const IMAGE_ONLY_BOOTSTRAP_PROMPT =
202204 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
203205 const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
204−const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
205206 const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
206−const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
207207 const EMPTY_PROVIDERS: ServerProvider[] = [];
208208 const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
209209
@@ -794,8 +794,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 584 unmodified lines
794794 const lockedProvider: ProviderKind | null = hasThreadStarted
795795 ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)
796796 : null;
797− const serverConfigQuery = useQuery(serverConfigQueryOptions());
798− const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDERS;
797+ const serverConfig = useServerConfig();
798+ const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
799799 const unlockedSelectedProvider = resolveSelectableProvider(
800800 providerStatuses,
801801 selectedProviderByThreadId ?? threadProvider ?? "codex",
@@ -1202,8 +1202,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 400 unmodified lines
12021202 );
12031203 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
12041204 const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
1205− const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
1206− const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
1205+ const keybindings = useServerKeybindings();
1206+ const availableEditors = useServerAvailableEditors();
12071207 const modelOptionsByProvider = useMemo(
12081208 () => ({
12091209 codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [],
@@ -1698,10 +1698,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 488 unmodified lines
16981698
16991699 if (isElectron && keybindingRule) {
17001700 await api.server.upsertKeybinding(keybindingRule);
1701− await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all });
17021701 }
17031702 },
1704− [queryClient],
1703+ [],
17051704 );
17061705 const saveProjectScript = useCallback(
17071706 async (input: NewProjectScriptInput) => {
apps/web/src/components/GitActionsControl.tsx+57−68
@@ -294,74 +294,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 293 unmodified lines
294294 })
295295 : null;
296296
297− useEffect(() => {
298− const api = readNativeApi();
299− if (!api) {
300− return;
301− }
302−
303− const applyProgressEvent = (event: GitActionProgressEvent) => {
304− const progress = activeGitActionProgressRef.current;
305− if (!progress) {
306− return;
307− }
308− if (gitCwd && event.cwd !== gitCwd) {
309− return;
310− }
311− if (progress.actionId !== event.actionId) {
312− return;
313− }
314−
315− const now = Date.now();
316− switch (event.kind) {
317− case "action_started":
318− progress.phaseStartedAtMs = now;
319− progress.hookStartedAtMs = null;
320− progress.hookName = null;
321− progress.lastOutputLine = null;
322− break;
323− case "phase_started":
324− progress.title = event.label;
325− progress.currentPhaseLabel = event.label;
326− progress.phaseStartedAtMs = now;
327− progress.hookStartedAtMs = null;
328− progress.hookName = null;
329− progress.lastOutputLine = null;
330− break;
331− case "hook_started":
332− progress.title = `Running ${event.hookName}...`;
333− progress.hookName = event.hookName;
334− progress.hookStartedAtMs = now;
335− progress.lastOutputLine = null;
336− break;
337− case "hook_output":
338− progress.lastOutputLine = event.text;
339− break;
340− case "hook_finished":
341− progress.title = progress.currentPhaseLabel ?? "Committing...";
342− progress.hookName = null;
343− progress.hookStartedAtMs = null;
344− progress.lastOutputLine = null;
345− break;
346− case "action_finished":
347− // Don't clear timestamps here — the HTTP response handler (line 496)
348− // sets activeGitActionProgressRef to null and shows the success toast.
349− // Clearing timestamps early causes the "Running for Xs" description
350− // to disappear before the success state renders, leaving a bare
351− // "Pushing..." toast in the gap between the WS event and HTTP response.
352− return;
353− case "action_failed":
354− // Same reasoning as action_finished — let the HTTP error handler
355− // manage the final toast state to avoid a flash of bare title.
356− return;
357− }
358−
359− updateActiveProgressToast();
360− };
361−
362− return api.git.onActionProgress(applyProgressEvent);
363− }, [gitCwd, updateActiveProgressToast]);
364−
365297 useEffect(() => {
366298 const interval = window.setInterval(() => {
367299 if (!activeGitActionProgressRef.current) {
@@ -483,12 +415,69 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 115 unmodified lines
483415 });
484416 }
485417
418+ const applyProgressEvent = (event: GitActionProgressEvent) => {
419+ const progress = activeGitActionProgressRef.current;
420+ if (!progress) {
421+ return;
422+ }
423+ if (gitCwd && event.cwd !== gitCwd) {
424+ return;
425+ }
426+ if (progress.actionId !== event.actionId) {
427+ return;
428+ }
429+
430+ const now = Date.now();
431+ switch (event.kind) {
432+ case "action_started":
433+ progress.phaseStartedAtMs = now;
434+ progress.hookStartedAtMs = null;
435+ progress.hookName = null;
436+ progress.lastOutputLine = null;
437+ break;
438+ case "phase_started":
439+ progress.title = event.label;
440+ progress.currentPhaseLabel = event.label;
441+ progress.phaseStartedAtMs = now;
442+ progress.hookStartedAtMs = null;
443+ progress.hookName = null;
444+ progress.lastOutputLine = null;
445+ break;
446+ case "hook_started":
447+ progress.title = `Running ${event.hookName}...`;
448+ progress.hookName = event.hookName;
449+ progress.hookStartedAtMs = now;
450+ progress.lastOutputLine = null;
451+ break;
452+ case "hook_output":
453+ progress.lastOutputLine = event.text;
454+ break;
455+ case "hook_finished":
456+ progress.title = progress.currentPhaseLabel ?? "Committing...";
457+ progress.hookName = null;
458+ progress.hookStartedAtMs = null;
459+ progress.lastOutputLine = null;
460+ break;
461+ case "action_finished":
462+ // Let the resolved mutation update the toast so we keep the
463+ // elapsed description visible until the final success state renders.
464+ return;
465+ case "action_failed":
466+ // Let the rejected mutation publish the error toast to avoid a
467+ // transient intermediate state before the final failure message.
468+ return;
469+ }
470+
471+ updateActiveProgressToast();
472+ };
473+
486474 const promise = runImmediateGitActionMutation.mutateAsync({
487475 actionId,
488476 action,
489477 ...(commitMessage ? { commitMessage } : {}),
490478 ...(featureBranch ? { featureBranch } : {}),
491479 ...(filePaths ? { filePaths } : {}),
480+ onProgress: applyProgressEvent,
492481 });
493482
494483 try {
apps/web/src/components/KeybindingsToast.browser.tsx+41−44
@@ -6,9 +6,8 @@ import { · 5 unmodified lines
66 type OrchestrationReadModel,
77 type ProjectId,
88 type ServerConfig,
9+ type ServerLifecycleWelcomePayload,
910 type ThreadId,
10− type WsWelcomePayload,
11− WS_CHANNELS,
1211 WS_METHODS,
1312 } from "@t3tools/contracts";
1413 import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
@@ -18,8 +17,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } · 3 unmodified lines
1817 import { render } from "vitest-browser-react";
1918
2019 import { useComposerDraftStore } from "../composerDraftStore";
20+import { __resetNativeApiForTests } from "../nativeApi";
2121 import { getRouter } from "../router";
2222 import { useStore } from "../store";
23+import { BrowserWsRpcHarness } from "../../test/wsRpcHarness";
2324
2425 const THREAD_ID = "thread-kb-toast-test" as ThreadId;
2526 const PROJECT_ID = "project-1" as ProjectId;
@@ -28,12 +29,11 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z"; · 2 unmodified lines
2829 interface TestFixture {
2930 snapshot: OrchestrationReadModel;
3031 serverConfig: ServerConfig;
31− welcome: WsWelcomePayload;
32+ welcome: ServerLifecycleWelcomePayload;
3233 }
3334
3435 let fixture: TestFixture;
35−let wsClient: { send: (data: string) => void } | null = null;
36−let pushSequence = 1;
36+const rpcHarness = new BrowserWsRpcHarness();
3737
3838 const wsLink = ws.link(/ws(s)?:\/\/.*/);
3939
@@ -179,52 +179,23 @@ function resolveWsRpc(tag: string): unknown { · 139 unmodified lines
179179
180180 const worker = setupWorker(
181181 wsLink.addEventListener("connection", ({ client }) => {
182− wsClient = client;
183− pushSequence = 1;
184− client.send(
185− JSON.stringify({
186− type: "push",
187− sequence: pushSequence++,
188− channel: WS_CHANNELS.serverWelcome,
189− data: fixture.welcome,
190− }),
191− );
182+ void rpcHarness.connect(client);
192183 client.addEventListener("message", (event) => {
193184 const rawData = event.data;
194185 if (typeof rawData !== "string") return;
195− let request: { id: string; body: { _tag: string; [key: string]: unknown } };
196− try {
197− request = JSON.parse(rawData);
198− } catch {
199− return;
200− }
201− const method = request.body?._tag;
202− if (typeof method !== "string") return;
203− client.send(
204− JSON.stringify({
205− id: request.id,
206− result: resolveWsRpc(method),
207− }),
208− );
186+ void rpcHarness.onMessage(rawData);
209187 });
210188 }),
211189 http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })),
212190 http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),
213191 );
214192
215−function sendServerConfigUpdatedPush(issues: Array<{ kind: string; message: string }>) {
216− if (!wsClient) throw new Error("WebSocket client not connected");
217− wsClient.send(
218− JSON.stringify({
219− type: "push",
220− sequence: pushSequence++,
221− channel: WS_CHANNELS.serverConfigUpdated,
222− data: {
223− issues,
224− providers: fixture.serverConfig.providers,
225− },
226− }),
227− );
193+function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) {
194+ rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, {
195+ version: 1,
196+ type: "keybindingsUpdated",
197+ payload: { issues },
198+ });
228199 }
229200
230201 function queryToastTitles(): string[] {
@@ -308,13 +279,39 @@ describe("Keybindings update toast", () => { · 77 unmodified lines
308279 });
309280
310281 afterAll(async () => {
282+ await rpcHarness.disconnect();
311283 await worker.stop();
312284 });
313285
314− beforeEach(() => {
286+ beforeEach(async () => {
287+ await rpcHarness.reset({
288+ resolveUnary: (request) => resolveWsRpc(request._tag),
289+ getInitialStreamValues: (request) => {
290+ if (request._tag === WS_METHODS.subscribeServerLifecycle) {
291+ return [
292+ {
293+ version: 1,
294+ sequence: 1,
295+ type: "welcome",
296+ payload: fixture.welcome,
297+ },
298+ ];
299+ }
300+ if (request._tag === WS_METHODS.subscribeServerConfig) {
301+ return [
302+ {
303+ version: 1,
304+ type: "snapshot",
305+ config: fixture.serverConfig,
306+ },
307+ ];
308+ }
309+ return [];
310+ },
311+ });
312+ __resetNativeApiForTests();
315313 localStorage.clear();
316314 document.body.innerHTML = "";
317− pushSequence = 1;
318315 useComposerDraftStore.setState({
319316 draftsByThreadId: {},
320317 draftThreadsByThreadId: {},
apps/web/src/components/ProjectFavicon.tsx+6−21
@@ -1,30 +1,15 @@
11 import { FolderIcon } from "lucide-react";
22 import { useState } from "react";
3−
4−function getServerHttpOrigin(): string {
5− const bridgeUrl = window.desktopBridge?.getWsUrl();
6− const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
7− const wsUrl =
8− bridgeUrl && bridgeUrl.length > 0
9− ? bridgeUrl
10− : envUrl && envUrl.length > 0
11− ? envUrl
12− : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`;
13− // Parse to extract just the origin, dropping path/query (e.g. ?token=…)
14− const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
15− try {
16− return new URL(httpUrl).origin;
17− } catch {
18− return httpUrl;
19− }
20−}
21−
22−const serverHttpOrigin = getServerHttpOrigin();
3+import { resolveServerUrl } from "~/lib/utils";
234
245 const loadedProjectFaviconSrcs = new Set<string>();
256
267 export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) {
27− const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`;
8+ const src = resolveServerUrl({
9+ protocol: "http",
10+ pathname: "/api/project-favicon",
11+ searchParams: { cwd },
12+ });
2813 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
2914 loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
3015 );
apps/web/src/components/Sidebar.tsx+3−9
@@ -43,9 +43,8 @@ import { · 42 unmodified lines
4343 ProjectId,
4444 ThreadId,
4545 type GitStatusResult,
46− type ResolvedKeybindingsConfig,
4746 } from "@t3tools/contracts";
48−import { useQueries, useQuery } from "@tanstack/react-query";
47+import { useQueries } from "@tanstack/react-query";
4948 import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
5049 import {
5150 type SidebarProjectSortOrder,
@@ -67,7 +66,6 @@ import { · 15 unmodified lines
6766 } from "../keybindings";
6867 import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
6968 import { gitStatusQueryOptions } from "../lib/gitReactQuery";
70−import { serverConfigQueryOptions } from "../lib/serverReactQuery";
7169 import { readNativeApi } from "../nativeApi";
7270 import { useComposerDraftStore } from "../composerDraftStore";
7371 import { useHandleNewThread } from "../hooks/useHandleNewThread";
@@ -125,9 +123,8 @@ import { · 51 unmodified lines
125123 import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
126124 import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
127125 import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
126+import { useServerKeybindings } from "../rpc/serverState";
128127 import type { Project, Thread } from "../types";
129−
130−const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
131128 const THREAD_PREVIEW_LIMIT = 6;
132129 const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
133130 updated_at: "Last user message",
@@ -469,10 +466,7 @@ export default function Sidebar() { · 335 unmodified lines
469466 strict: false,
470467 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
471468 });
472− const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
473− ...serverConfigQueryOptions(),
474− select: (config) => config.keybindings,
475− });
469+ const keybindings = useServerKeybindings();
476470 const [addingProject, setAddingProject] = useState(false);
477471 const [newCwd, setNewCwd] = useState("");
478472 const [isPickingFolder, setIsPickingFolder] = useState(false);
apps/web/src/components/settings/SettingsPanels.tsx+10−11
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
99 Undo2Icon,
1010 XIcon,
1111 } from "lucide-react";
12−import { useQuery, useQueryClient } from "@tanstack/react-query";
12+import { useQueryClient } from "@tanstack/react-query";
1313 import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
1414 import {
1515 PROVIDER_DISPLAY_NAMES,
@@ -40,7 +40,6 @@ import { · 24 unmodified lines
4040 setDesktopUpdateStateQueryData,
4141 useDesktopUpdateState,
4242 } from "../../lib/desktopUpdateReactQuery";
43−import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery";
4443 import {
4544 MAX_CUSTOM_MODEL_LENGTH,
4645 getCustomModelOptionsByProvider,
@@ -59,6 +58,11 @@ import { Switch } from "../ui/switch"; · 12 unmodified lines
5958 import { toastManager } from "../ui/toast";
6059 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6160 import { ProjectFavicon } from "../ProjectFavicon";
61+import {
62+ useServerAvailableEditors,
63+ useServerKeybindingsConfigPath,
64+ useServerProviders,
65+} from "../../rpc/serverState";
6266
6367 const THEME_OPTIONS = [
6468 {
@@ -81,8 +85,6 @@ const TIMESTAMP_FORMAT_LABELS = { · 16 unmodified lines
8185 "24-hour": "24-hour",
8286 } as const;
8387
84−const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
85−
8688 type InstallProviderSettings = {
8789 provider: ProviderKind;
8890 title: string;
@@ -516,7 +518,6 @@ export function GeneralSettingsPanel() { · 427 unmodified lines
516518 const { theme, setTheme } = useTheme();
517519 const settings = useSettings();
518520 const { updateSettings } = useUpdateSettings();
519− const serverConfigQuery = useQuery(serverConfigQueryOptions());
520521 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
521522 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);
522523 const [openProviderDetails, setOpenProviderDetails] = useState<Record<ProviderKind, boolean>>({
@@ -542,7 +543,6 @@ export function GeneralSettingsPanel() { · 19 unmodified lines
542543 >({});
543544 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
544545 const refreshingRef = useRef(false);
545− const queryClient = useQueryClient();
546546 const modelListRefs = useRef<Partial<Record<ProviderKind, HTMLDivElement | null>>>({});
547547 const refreshProviders = useCallback(() => {
548548 if (refreshingRef.current) return;
@@ -550,7 +550,6 @@ export function GeneralSettingsPanel() { · 1 unmodified line
550550 setIsRefreshingProviders(true);
551551 void ensureNativeApi()
552552 .server.refreshProviders()
553− .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }))
554553 .catch((error: unknown) => {
555554 console.warn("Failed to refresh providers", error);
556555 })
@@ -558,11 +557,11 @@ export function GeneralSettingsPanel() { · 1 unmodified line
558557 refreshingRef.current = false;
559558 setIsRefreshingProviders(false);
560559 });
561− }, [queryClient]);
560+ }, []);
562561
563− const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
564− const availableEditors = serverConfigQuery.data?.availableEditors;
565− const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS;
562+ const keybindingsConfigPath = useServerKeybindingsConfigPath();
563+ const availableEditors = useServerAvailableEditors();
564+ const serverProviders = useServerProviders();
566565 const codexHomePath = settings.providers.codex.homePath;
567566
568567 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
apps/web/src/hooks/useSettings.ts+10−18
@@ -10,15 +10,12 @@ · 9 unmodified lines
1010 * store.
1111 */
1212 import { useCallback, useMemo } from "react";
13−import { useQuery, useQueryClient } from "@tanstack/react-query";
1413 import {
1514 ServerSettings,
1615 ServerSettingsPatch,
17− ServerConfig,
1816 ModelSelection,
1917 ThreadEnvMode,
2018 } from "@t3tools/contracts";
21−import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
2219 import {
2320 type ClientSettings,
2421 ClientSettingsSchema,
@@ -29,13 +26,13 @@ import { · 4 unmodified lines
2926 TimestampFormat,
3027 UnifiedSettings,
3128 } from "@t3tools/contracts/settings";
32−import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3329 import { ensureNativeApi } from "~/nativeApi";
3430 import { useLocalStorage } from "./useLocalStorage";
3531 import { normalizeCustomModelSlugs } from "~/modelSelection";
3632 import { Predicate, Schema, Struct } from "effect";
3733 import { DeepMutable } from "effect/Types";
3834 import { deepMerge } from "@t3tools/shared/Struct";
35+import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState";
3936
4037 const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";
4138 const OLD_SETTINGS_KEY = "t3code:app-settings:v1";
@@ -73,7 +70,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): { · 31 unmodified lines
7370 export function useSettings<T extends UnifiedSettings = UnifiedSettings>(
7471 selector?: (s: UnifiedSettings) => T,
7572 ): T {
76− const { data: serverConfig } = useQuery(serverConfigQueryOptions());
73+ const serverSettings = useServerSettings();
7774 const [clientSettings] = useLocalStorage(
7875 CLIENT_SETTINGS_STORAGE_KEY,
7976 DEFAULT_CLIENT_SETTINGS,
@@ -82,10 +79,10 @@ export function useSettings<T extends UnifiedSettings = UnifiedSettings>( · 2 unmodified lines
8279
8380 const merged = useMemo<UnifiedSettings>(
8481 () => ({
85− ...(serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS),
82+ ...serverSettings,
8683 ...clientSettings,
8784 }),
88− [serverConfig?.settings, clientSettings],
85+ [clientSettings, serverSettings],
8986 );
9087
9188 return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]);
@@ -94,11 +91,10 @@ export function useSettings<T extends UnifiedSettings = UnifiedSettings>( · 2 unmodified lines
9491 /**
9592 * Returns an updater that routes each key to the correct backing store.
9693 *
97− * Server keys are optimistically patched in the React Query cache, then
94+ * Server keys are optimistically patched in atom-backed server state, then
9895 * persisted via RPC. Client keys go straight to localStorage.
9996 */
10097 export function useUpdateSettings() {
101− const queryClient = useQueryClient();
10298 const [, setClientSettings] = useLocalStorage(
10399 CLIENT_SETTINGS_STORAGE_KEY,
104100 DEFAULT_CLIENT_SETTINGS,
@@ -110,14 +106,10 @@ export function useUpdateSettings() { · 5 unmodified lines
110106 const { serverPatch, clientPatch } = splitPatch(patch);
111107
112108 if (Object.keys(serverPatch).length > 0) {
113− // Optimistic update of the React Query cache
114− queryClient.setQueryData<ServerConfig>(serverQueryKeys.config(), (old) => {
115− if (!old) return old;
116− return {
117− ...old,
118− settings: deepMerge(old.settings, serverPatch),
119− };
120− });
109+ const currentServerConfig = getServerConfig();
110+ if (currentServerConfig) {
111+ applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch));
112+ }
121113 // Fire-and-forget RPC — push will reconcile on success
122114 void ensureNativeApi().server.updateSettings(serverPatch);
123115 }
@@ -126,7 +118,7 @@ export function useUpdateSettings() { · 2 unmodified lines
126118 setClientSettings((prev) => ({ ...prev, ...clientPatch }));
127119 }
128120 },
129− [queryClient, setClientSettings],
121+ [setClientSettings],
130122 );
131123
132124 const resetSettings = useCallback(() => {
apps/web/src/lib/gitReactQuery.ts+15−10
@@ -1,6 +1,7 @@
1−import { type GitStackedAction } from "@t3tools/contracts";
1+import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts";
22 import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
33 import { ensureNativeApi } from "../nativeApi";
4+import { getWsRpcClient } from "../wsRpcClient";
45
56 const GIT_STATUS_STALE_TIME_MS = 5_000;
67 const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
@@ -121,23 +122,27 @@ export function gitRunStackedActionMutationOptions(input: { · 114 unmodified lines
121122 commitMessage,
122123 featureBranch,
123124 filePaths,
125+ onProgress,
124126 }: {
125127 actionId: string;
126128 action: GitStackedAction;
127129 commitMessage?: string;
128130 featureBranch?: boolean;
129131 filePaths?: string[];
132+ onProgress?: (event: GitActionProgressEvent) => void;
130133 }) => {
131− const api = ensureNativeApi();
132134 if (!input.cwd) throw new Error("Git action is unavailable.");
133− return api.git.runStackedAction({
134− actionId,
135− cwd: input.cwd,
136− action,
137− ...(commitMessage ? { commitMessage } : {}),
138− ...(featureBranch ? { featureBranch } : {}),
139− ...(filePaths ? { filePaths } : {}),
140− });
135+ return getWsRpcClient().git.runStackedAction(
136+ {
137+ actionId,
138+ cwd: input.cwd,
139+ action,
140+ ...(commitMessage ? { commitMessage } : {}),
141+ ...(featureBranch ? { featureBranch } : {}),
142+ ...(filePaths ? { filePaths } : {}),
143+ },
144+ ...(onProgress ? [{ onProgress }] : []),
145+ );
141146 },
142147 onSettled: async () => {
143148 await invalidateGitQueries(input.queryClient);
apps/web/src/lib/serverReactQuery.ts+0−24
@@ -1,24 +0,0 @@
1−import { queryOptions } from "@tanstack/react-query";
2−import { ensureNativeApi } from "~/nativeApi";
3−
4−export const serverQueryKeys = {
5− all: ["server"] as const,
6− config: () => ["server", "config"] as const,
7−};
8−
9−/**
10− * Server config query options.
11− *
12− * `staleTime` is kept short so that push-driven `invalidateQueries` calls in
13− * the EventRouter always trigger a refetch, and so the query re-fetches when
14− * the component re-mounts (e.g. navigating away from settings and back).
15− */
16−export function serverConfigQueryOptions() {
17− return queryOptions({
18− queryKey: serverQueryKeys.config(),
19− queryFn: async () => {
20− const api = ensureNativeApi();
21− return api.server.getConfig();
22− },
23− });
24−}
apps/web/src/lib/utils.ts+39−0
@@ -1,4 +1,5 @@
11 import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
2+import { String, Predicate } from "effect";
23 import { type CxOptions, cx } from "class-variance-authority";
34 import { twMerge } from "tailwind-merge";
45 import * as Random from "effect/Random";
@@ -34,3 +35,41 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID()); · 29 unmodified lines
3435 export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());
3536
3637 export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());
38+
39+const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty);
40+const firstNonEmptyString = (...values: unknown[]): string => {
41+ for (const value of values) {
42+ if (isNonEmptyString(value)) {
43+ return value;
44+ }
45+ }
46+ throw new Error("No non-empty string provided");
47+};
48+
49+export const resolveServerUrl = (options?: {
50+ url?: string | undefined;
51+ protocol?: "http" | "https" | "ws" | "wss" | undefined;
52+ pathname?: string | undefined;
53+ searchParams?: Record<string, string> | undefined;
54+}): string => {
55+ const rawUrl = firstNonEmptyString(
56+ options?.url,
57+ window.desktopBridge?.getWsUrl(),
58+ import.meta.env.VITE_WS_URL,
59+ window.location.origin,
60+ );
61+
62+ const parsedUrl = new URL(rawUrl);
63+ if (options?.protocol) {
64+ parsedUrl.protocol = options.protocol;
65+ }
66+ if (options?.pathname) {
67+ parsedUrl.pathname = options.pathname;
68+ } else {
69+ parsedUrl.pathname = "/";
70+ }
71+ if (options?.searchParams) {
72+ parsedUrl.search = new URLSearchParams(options.searchParams).toString();
73+ }
74+ return parsedUrl.toString();
75+};
apps/web/src/nativeApi.ts+6−1
@@ -1,6 +1,6 @@
11 import type { NativeApi } from "@t3tools/contracts";
22
3−import { createWsNativeApi } from "./wsNativeApi";
3+import { __resetWsNativeApiForTests, createWsNativeApi } from "./wsNativeApi";
44
55 let cachedApi: NativeApi | undefined;
66
@@ -24,3 +24,8 @@ export function ensureNativeApi(): NativeApi { · 17 unmodified lines
2424 }
2525 return api;
2626 }
27+
28+export function __resetNativeApiForTests() {
29+ cachedApi = undefined;
30+ __resetWsNativeApiForTests();
31+}
apps/web/src/router.ts+7−1
@@ -2,6 +2,7 @@ import { createElement } from "react"; · 1 unmodified line
22 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
33 import { createRouter, RouterHistory } from "@tanstack/react-router";
44
5+import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
56 import { routeTree } from "./routeTree.gen";
67
78 export function getRouter(history: RouterHistory) {
@@ -13,7 +14,12 @@ export function getRouter(history: RouterHistory) { · 5 unmodified lines
1314 context: {
1415 queryClient,
1516 },
16− Wrap: ({ children }) => createElement(QueryClientProvider, { client: queryClient }, children),
17+ Wrap: ({ children }) =>
18+ createElement(
19+ QueryClientProvider,
20+ { client: queryClient },
21+ createElement(AppAtomRegistryProvider, undefined, children),
22+ ),
1723 });
1824 }
1925
apps/web/src/routes/__root.tsx+123−102
@@ -1,12 +1,16 @@
1−import { OrchestrationEvent, ThreadId } from "@t3tools/contracts";
1+import {
2+ OrchestrationEvent,
3+ ThreadId,
4+ type ServerLifecycleWelcomePayload,
5+} from "@t3tools/contracts";
26 import {
37 Outlet,
48 createRootRouteWithContext,
59 type ErrorComponentProps,
610 useNavigate,
7− useRouterState,
11+ useLocation,
812 } from "@tanstack/react-router";
9−import { useEffect, useRef } from "react";
13+import { useEffect, useEffectEvent, useRef } from "react";
1014 import { QueryClient, useQueryClient } from "@tanstack/react-query";
1115 import { Throttler } from "@tanstack/react-pacer";
1216
@@ -15,8 +19,14 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout"; · 2 unmodified lines
1519 import { Button } from "../components/ui/button";
1620 import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
1721 import { resolveAndPersistPreferredEditor } from "../editorPreferences";
18−import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
1922 import { readNativeApi } from "../nativeApi";
23+import {
24+ type ServerConfigUpdateSource,
25+ useServerConfig,
26+ useServerConfigUpdatedSubscription,
27+ useServerWelcomeSubscription,
28+} from "../rpc/serverState";
29+import { ServerStateBootstrap } from "../rpc/serverStateBootstrap";
2030 import {
2131 clearPromotedDraftThread,
2232 clearPromotedDraftThreads,
@@ -26,7 +36,6 @@ import { useStore } from "../store"; · 3 unmodified lines
2636 import { useUiStateStore } from "../uiStateStore";
2737 import { useTerminalStateStore } from "../terminalStateStore";
2838 import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
29−import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi";
3039 import { migrateLocalSettingsToServer } from "../hooks/useSettings";
3140 import { providerQueryKeys } from "../lib/providerReactQuery";
3241 import { projectQueryKeys } from "../lib/projectReactQuery";
@@ -58,15 +67,18 @@ function RootRouteView() { · 25 unmodified lines
5867 }
5968
6069 return (
61− <ToastProvider>
62− <AnchoredToastProvider>
63− <EventRouter />
64− <DesktopProjectBootstrap />
65− <AppSidebarLayout>
66− <Outlet />
67− </AppSidebarLayout>
68− </AnchoredToastProvider>
69− </ToastProvider>
70+ <>
71+ <ServerStateBootstrap />
72+ <ToastProvider>
73+ <AnchoredToastProvider>
74+ <EventRouter />
75+ <DesktopProjectBootstrap />
76+ <AppSidebarLayout>
77+ <Outlet />
78+ </AppSidebarLayout>
79+ </AnchoredToastProvider>
80+ </ToastProvider>
81+ </>
7082 );
7183 }
7284
@@ -154,16 +166,107 @@ function EventRouter() { · 81 unmodified lines
154166 );
155167 const queryClient = useQueryClient();
156168 const navigate = useNavigate();
157− const pathname = useRouterState({ select: (state) => state.location.pathname });
169+ const pathname = useLocation({ select: (loc) => loc.pathname });
158170 const pathnameRef = useRef(pathname);
159171 const handledBootstrapThreadIdRef = useRef<string | null>(null);
172+ const handledConfigReplayRef = useRef(false);
173+ const disposedRef = useRef(false);
174+ const bootstrapFromSnapshotRef = useRef<() => Promise<void>>(async () => undefined);
175+ const serverConfig = useServerConfig();
160176
161177 pathnameRef.current = pathname;
162178
179+ const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload) => {
180+ migrateLocalSettingsToServer();
181+ void (async () => {
182+ await bootstrapFromSnapshotRef.current();
183+ if (disposedRef.current) {
184+ return;
185+ }
186+
187+ if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
188+ return;
189+ }
190+ setProjectExpanded(payload.bootstrapProjectId, true);
191+
192+ if (pathnameRef.current !== "/") {
193+ return;
194+ }
195+ if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {
196+ return;
197+ }
198+ await navigate({
199+ to: "/$threadId",
200+ params: { threadId: payload.bootstrapThreadId },
201+ replace: true,
202+ });
203+ handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
204+ })().catch(() => undefined);
205+ });
206+
207+ const handleServerConfigUpdated = useEffectEvent(
208+ ({
209+ payload,
210+ source,
211+ }: {
212+ readonly payload: import("@t3tools/contracts").ServerConfigUpdatedPayload;
213+ readonly source: ServerConfigUpdateSource;
214+ }) => {
215+ const isReplay = !handledConfigReplayRef.current;
216+ handledConfigReplayRef.current = true;
217+ if (isReplay || source !== "keybindingsUpdated") {
218+ return;
219+ }
220+
221+ const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings."));
222+ if (!issue) {
223+ toastManager.add({
224+ type: "success",
225+ title: "Keybindings updated",
226+ description: "Keybindings configuration reloaded successfully.",
227+ });
228+ return;
229+ }
230+
231+ toastManager.add({
232+ type: "warning",
233+ title: "Invalid keybindings configuration",
234+ description: issue.message,
235+ actionProps: {
236+ children: "Open keybindings.json",
237+ onClick: () => {
238+ const api = readNativeApi();
239+ if (!api) {
240+ return;
241+ }
242+
243+ void Promise.resolve(serverConfig ?? api.server.getConfig())
244+ .then((config) => {
245+ const editor = resolveAndPersistPreferredEditor(config.availableEditors);
246+ if (!editor) {
247+ throw new Error("No available editors found.");
248+ }
249+ return api.shell.openInEditor(config.keybindingsConfigPath, editor);
250+ })
251+ .catch((error) => {
252+ toastManager.add({
253+ type: "error",
254+ title: "Unable to open keybindings file",
255+ description:
256+ error instanceof Error ? error.message : "Unknown error opening file.",
257+ });
258+ });
259+ },
260+ },
261+ });
262+ },
263+ );
264+
163265 useEffect(() => {
164266 const api = readNativeApi();
165267 if (!api) return;
166268 let disposed = false;
269+ disposedRef.current = false;
167270 const recovery = createOrchestrationRecoveryCoordinator();
168271 let needsProviderInvalidation = false;
169272
@@ -299,11 +402,11 @@ function EventRouter() { · 129 unmodified lines
299402 const bootstrapFromSnapshot = async (): Promise<void> => {
300403 await runSnapshotRecovery("bootstrap");
301404 };
405+ bootstrapFromSnapshotRef.current = bootstrapFromSnapshot;
302406
303407 const fallbackToSnapshotRecovery = async (): Promise<void> => {
304408 await runSnapshotRecovery("replay-failed");
305409 };
306−
307410 const unsubDomainEvent = api.orchestration.onDomainEvent((event) => {
308411 const action = recovery.classifyDomainEvent(event.sequence);
309412 if (action === "apply") {
@@ -327,98 +430,13 @@ function EventRouter() { · 17 unmodified lines
327430 hasRunningSubprocess,
328431 );
329432 });
330− const unsubWelcome = onServerWelcome((payload) => {
331− // Migrate old localStorage settings to server on first connect
332− migrateLocalSettingsToServer();
333− void (async () => {
334− await bootstrapFromSnapshot();
335− if (disposed) {
336− return;
337− }
338−
339− if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
340− return;
341− }
342− setProjectExpanded(payload.bootstrapProjectId, true);
343−
344− if (pathnameRef.current !== "/") {
345− return;
346− }
347− if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {
348− return;
349− }
350− await navigate({
351− to: "/$threadId",
352− params: { threadId: payload.bootstrapThreadId },
353− replace: true,
354− });
355− handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
356− })().catch(() => undefined);
357− });
358− // onServerConfigUpdated replays the latest cached value synchronously
359− // during subscribe. Skip the toast for that replay so effect re-runs
360− // don't produce duplicate toasts.
361− let subscribed = false;
362− const unsubServerConfigUpdated = onServerConfigUpdated((payload) => {
363− // Invalidate the config query so active observers refetch fresh data.
364− void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() });
365−
366− if (!subscribed) return;
367−
368− // Only show keybindings toasts for keybindings changes (no settings in payload)
369− if (payload.settings) return;
370−
371− const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings."));
372− if (!issue) {
373− toastManager.add({
374− type: "success",
375− title: "Keybindings updated",
376− description: "Keybindings configuration reloaded successfully.",
377− });
378− return;
379− }
380−
381− toastManager.add({
382− type: "warning",
383− title: "Invalid keybindings configuration",
384− description: issue.message,
385− actionProps: {
386− children: "Open keybindings.json",
387− onClick: () => {
388− void queryClient
389− .ensureQueryData(serverConfigQueryOptions())
390− .then((config) => {
391− const editor = resolveAndPersistPreferredEditor(config.availableEditors);
392− if (!editor) {
393− throw new Error("No available editors found.");
394− }
395− return api.shell.openInEditor(config.keybindingsConfigPath, editor);
396− })
397− .catch((error) => {
398− toastManager.add({
399− type: "error",
400− title: "Unable to open keybindings file",
401− description:
402− error instanceof Error ? error.message : "Unknown error opening file.",
403− });
404− });
405− },
406− },
407− });
408− });
409− const unsubProvidersUpdated = onServerProvidersUpdated(() => {
410− void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() });
411− });
412− subscribed = true;
413433 return () => {
414434 disposed = true;
435+ disposedRef.current = true;
415436 needsProviderInvalidation = false;
416437 queryInvalidationThrottler.cancel();
417438 unsubDomainEvent();
418439 unsubTerminalEvent();
419− unsubWelcome();
420− unsubServerConfigUpdated();
421− unsubProvidersUpdated();
422440 };
423441 }, [
424442 applyOrchestrationEvents,
@@ -433,6 +451,9 @@ function EventRouter() { · 8 unmodified lines
433451 syncThreads,
434452 ]);
435453
454+ useServerWelcomeSubscription(handleWelcome);
455+ useServerConfigUpdatedSubscription(handleServerConfigUpdated);
456+
436457 return null;
437458 }
438459
apps/web/src/routes/_chat.tsx+2−7
@@ -1,26 +1,21 @@
1−import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";
2−import { useQuery } from "@tanstack/react-query";
31 import { Outlet, createFileRoute } from "@tanstack/react-router";
42 import { useEffect } from "react";
53
64 import { useHandleNewThread } from "../hooks/useHandleNewThread";
75 import { isTerminalFocused } from "../lib/terminalFocus";
8−import { serverConfigQueryOptions } from "../lib/serverReactQuery";
96 import { resolveShortcutCommand } from "../keybindings";
107 import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
118 import { useThreadSelectionStore } from "../threadSelectionStore";
129 import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
1310 import { useSettings } from "~/hooks/useSettings";
14−
15−const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
11+import { useServerKeybindings } from "~/rpc/serverState";
1612
1713 function ChatRouteGlobalShortcuts() {
1814 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
1915 const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size);
2016 const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } =
2117 useHandleNewThread();
22− const serverConfigQuery = useQuery(serverConfigQueryOptions());
23− const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
18+ const keybindings = useServerKeybindings();
2419 const terminalOpen = useTerminalStateStore((state) =>
2520 routeThreadId
2621 ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen
apps/web/src/rpc/atomRegistry.tsx+14−0
@@ -0,0 +1,14 @@
1+import { RegistryContext } from "@effect/atom-react";
2+import { AtomRegistry } from "effect/unstable/reactivity";
3+import type { ReactNode } from "react";
4+
5+export let appAtomRegistry = AtomRegistry.make();
6+
7+export function AppAtomRegistryProvider({ children }: { readonly children: ReactNode }) {
8+ return <RegistryContext.Provider value={appAtomRegistry}>{children}</RegistryContext.Provider>;
9+}
10+
11+export function resetAppAtomRegistryForTests() {
12+ appAtomRegistry.dispose();
13+ appAtomRegistry = AtomRegistry.make();
14+}
apps/web/src/rpc/client.test.ts+231−0
@@ -0,0 +1,231 @@
1+import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts";
2+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity";
4+
5+import { __resetWsRpcAtomClientForTests, runRpc, WsRpcAtomClient } from "./client";
6+
7+type WsEventType = "open" | "message" | "close" | "error";
8+type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
9+type WsListener = (event?: WsEvent) => void;
10+
11+const sockets: MockWebSocket[] = [];
12+
13+class MockWebSocket {
14+ static readonly CONNECTING = 0;
15+ static readonly OPEN = 1;
16+ static readonly CLOSING = 2;
17+ static readonly CLOSED = 3;
18+
19+ readyState = MockWebSocket.CONNECTING;
20+ readonly sent: string[] = [];
21+ readonly url: string;
22+ private readonly listeners = new Map<WsEventType, Set<WsListener>>();
23+
24+ constructor(url: string) {
25+ this.url = url;
26+ sockets.push(this);
27+ }
28+
29+ addEventListener(type: WsEventType, listener: WsListener) {
30+ const listeners = this.listeners.get(type) ?? new Set<WsListener>();
31+ listeners.add(listener);
32+ this.listeners.set(type, listeners);
33+ }
34+
35+ removeEventListener(type: WsEventType, listener: WsListener) {
36+ this.listeners.get(type)?.delete(listener);
37+ }
38+
39+ send(data: string) {
40+ this.sent.push(data);
41+ }
42+
43+ close(code = 1000, reason = "") {
44+ this.readyState = MockWebSocket.CLOSED;
45+ this.emit("close", { code, reason, type: "close" });
46+ }
47+
48+ open() {
49+ this.readyState = MockWebSocket.OPEN;
50+ this.emit("open", { type: "open" });
51+ }
52+
53+ serverMessage(data: unknown) {
54+ this.emit("message", { data, type: "message" });
55+ }
56+
57+ private emit(type: WsEventType, event?: WsEvent) {
58+ const listeners = this.listeners.get(type);
59+ if (!listeners) {
60+ return;
61+ }
62+ for (const listener of listeners) {
63+ listener(event);
64+ }
65+ }
66+}
67+
68+const originalWebSocket = globalThis.WebSocket;
69+
70+function getSocket(): MockWebSocket {
71+ const socket = sockets.at(-1);
72+ if (!socket) {
73+ throw new Error("Expected a websocket instance");
74+ }
75+ return socket;
76+}
77+
78+async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> {
79+ const startedAt = Date.now();
80+ for (;;) {
81+ try {
82+ assertion();
83+ return;
84+ } catch (error) {
85+ if (Date.now() - startedAt >= timeoutMs) {
86+ throw error;
87+ }
88+ await new Promise((resolve) => setTimeout(resolve, 10));
89+ }
90+ }
91+}
92+
93+beforeEach(() => {
94+ sockets.length = 0;
95+
96+ Object.defineProperty(globalThis, "window", {
97+ configurable: true,
98+ value: {
99+ location: {
100+ hostname: "localhost",
101+ origin: "http://localhost:3020",
102+ port: "3020",
103+ protocol: "ws:",
104+ },
105+ desktopBridge: undefined,
106+ },
107+ });
108+
109+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
110+});
111+
112+afterEach(() => {
113+ __resetWsRpcAtomClientForTests();
114+ globalThis.WebSocket = originalWebSocket;
115+ vi.restoreAllMocks();
116+});
117+
118+describe("WsRpcAtomClient", () => {
119+ it("runs unary requests through the AtomRpc service", async () => {
120+ const expectedSettings = {
121+ ...DEFAULT_SERVER_SETTINGS,
122+ enableAssistantStreaming: true,
123+ defaultThreadEnvMode: "worktree" as const,
124+ textGenerationModelSelection: {
125+ provider: "codex" as const,
126+ model: "gpt-5.4",
127+ },
128+ providers: {
129+ codex: {
130+ ...DEFAULT_SERVER_SETTINGS.providers.codex,
131+ homePath: "/tmp/codex-home",
132+ },
133+ claudeAgent: {
134+ ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent,
135+ enabled: false,
136+ },
137+ },
138+ };
139+ const requestPromise = runRpc((client) => client(WS_METHODS.serverGetSettings, {}));
140+
141+ await waitFor(() => {
142+ expect(sockets).toHaveLength(1);
143+ });
144+
145+ const socket = getSocket();
146+ socket.open();
147+
148+ await waitFor(() => {
149+ expect(socket.sent).toHaveLength(1);
150+ });
151+
152+ const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string };
153+ expect(requestMessage.tag).toBe(WS_METHODS.serverGetSettings);
154+
155+ socket.serverMessage(
156+ JSON.stringify({
157+ _tag: "Exit",
158+ requestId: requestMessage.id,
159+ exit: {
160+ _tag: "Success",
161+ value: expectedSettings,
162+ },
163+ }),
164+ );
165+
166+ await expect(requestPromise).resolves.toEqual(expectedSettings);
167+ });
168+
169+ it("exposes atom-backed query state for unary RPC methods", async () => {
170+ const expectedSettings = {
171+ ...DEFAULT_SERVER_SETTINGS,
172+ enableAssistantStreaming: true,
173+ defaultThreadEnvMode: "worktree" as const,
174+ textGenerationModelSelection: {
175+ provider: "codex" as const,
176+ model: "gpt-5.4",
177+ },
178+ providers: {
179+ codex: {
180+ ...DEFAULT_SERVER_SETTINGS.providers.codex,
181+ homePath: "/tmp/codex-home",
182+ },
183+ claudeAgent: {
184+ ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent,
185+ enabled: false,
186+ },
187+ },
188+ };
189+ const registry = AtomRegistry.make();
190+ const query = WsRpcAtomClient.query(WS_METHODS.serverGetSettings, {});
191+ const release = registry.mount(query);
192+
193+ await waitFor(() => {
194+ expect(sockets).toHaveLength(1);
195+ });
196+
197+ const socket = getSocket();
198+ socket.open();
199+
200+ await waitFor(() => {
201+ expect(socket.sent).toHaveLength(1);
202+ });
203+
204+ const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string };
205+ expect(requestMessage.tag).toBe(WS_METHODS.serverGetSettings);
206+ expect(registry.get(query)._tag).toBe("Initial");
207+
208+ socket.serverMessage(
209+ JSON.stringify({
210+ _tag: "Exit",
211+ requestId: requestMessage.id,
212+ exit: {
213+ _tag: "Success",
214+ value: expectedSettings,
215+ },
216+ }),
217+ );
218+
219+ await waitFor(() => {
220+ const result = registry.get(query);
221+ expect(AsyncResult.isSuccess(result)).toBe(true);
222+ if (!AsyncResult.isSuccess(result)) {
223+ return;
224+ }
225+ expect(result.value).toEqual(expectedSettings);
226+ });
227+
228+ release();
229+ registry.dispose();
230+ });
231+});
apps/web/src/rpc/client.ts+33−0
@@ -0,0 +1,33 @@
1+import { WsRpcGroup } from "@t3tools/contracts";
2+import { Effect, Layer, ManagedRuntime } from "effect";
3+import { AtomRpc } from "effect/unstable/reactivity";
4+
5+import { createWsRpcProtocolLayer } from "./protocol";
6+
7+export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", {
8+ group: WsRpcGroup,
9+ protocol: Layer.suspend(() => createWsRpcProtocolLayer()),
10+}) {}
11+
12+let sharedRuntime: ManagedRuntime.ManagedRuntime<WsRpcAtomClient, never> | null = null;
13+
14+function getRuntime() {
15+ if (sharedRuntime !== null) {
16+ return sharedRuntime;
17+ }
18+
19+ sharedRuntime = ManagedRuntime.make(WsRpcAtomClient.layer);
20+ return sharedRuntime;
21+}
22+
23+export function runRpc<TSuccess, TError = never>(
24+ execute: (client: typeof WsRpcAtomClient.Service) => Effect.Effect<TSuccess, TError, never>,
25+): Promise<TSuccess> {
26+ return getRuntime().runPromise(WsRpcAtomClient.use(execute));
27+}
28+
29+export async function __resetWsRpcAtomClientForTests() {
30+ const runtime = sharedRuntime;
31+ sharedRuntime = null;
32+ await runtime?.dispose();
33+}
apps/web/src/rpc/protocol.ts+27−0
@@ -0,0 +1,27 @@
1+import { WsRpcGroup } from "@t3tools/contracts";
2+import { Effect, Layer } from "effect";
3+import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
4+import * as Socket from "effect/unstable/socket/Socket";
5+
6+import { resolveServerUrl } from "../lib/utils";
7+
8+export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);
9+
10+type RpcClientFactory = typeof makeWsRpcProtocolClient;
11+export type WsRpcProtocolClient =
12+ RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never;
13+
14+export function createWsRpcProtocolLayer(url?: string) {
15+ const resolvedUrl = resolveServerUrl({
16+ url,
17+ protocol: window.location.protocol === "https:" ? "wss" : "ws",
18+ pathname: "/ws",
19+ });
20+ const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe(
21+ Layer.provide(Socket.layerWebSocketConstructorGlobal),
22+ );
23+
24+ return RpcClient.layerProtocolSocket({ retryTransientErrors: true }).pipe(
25+ Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson)),
26+ );
27+}
apps/web/src/rpc/serverState.test.ts+311−0
@@ -0,0 +1,311 @@
1+import {
2+ DEFAULT_SERVER_SETTINGS,
3+ ProjectId,
4+ ThreadId,
5+ type ServerConfig,
6+ type ServerConfigStreamEvent,
7+ type ServerLifecycleStreamEvent,
8+ type ServerProvider,
9+} from "@t3tools/contracts";
10+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11+
12+import {
13+ getServerConfig,
14+ onProvidersUpdated,
15+ onServerConfigUpdated,
16+ onWelcome,
17+ resetServerStateForTests,
18+ startServerStateSync,
19+} from "./serverState";
20+
21+function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
22+ listeners.add(listener);
23+ return () => {
24+ listeners.delete(listener);
25+ };
26+}
27+
28+function createDeferredPromise<T>() {
29+ let resolve!: (value: T) => void;
30+ const promise = new Promise<T>((nextResolve) => {
31+ resolve = nextResolve;
32+ });
33+
34+ return { promise, resolve };
35+}
36+
37+const lifecycleListeners = new Set<(event: ServerLifecycleStreamEvent) => void>();
38+const configListeners = new Set<(event: ServerConfigStreamEvent) => void>();
39+
40+const defaultProviders: ReadonlyArray<ServerProvider> = [
41+ {
42+ provider: "codex",
43+ enabled: true,
44+ installed: true,
45+ version: "0.116.0",
46+ status: "ready",
47+ auth: { status: "authenticated" },
48+ checkedAt: "2026-01-01T00:00:00.000Z",
49+ models: [],
50+ },
51+];
52+
53+const baseServerConfig: ServerConfig = {
54+ cwd: "/tmp/workspace",
55+ keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
56+ keybindings: [],
57+ issues: [],
58+ providers: defaultProviders,
59+ availableEditors: ["cursor"],
60+ settings: DEFAULT_SERVER_SETTINGS,
61+};
62+
63+const serverApi = {
64+ getConfig: vi.fn<() => Promise<ServerConfig>>(),
65+ subscribeConfig: vi.fn((listener: (event: ServerConfigStreamEvent) => void) =>
66+ registerListener(configListeners, listener),
67+ ),
68+ subscribeLifecycle: vi.fn((listener: (event: ServerLifecycleStreamEvent) => void) =>
69+ registerListener(lifecycleListeners, listener),
70+ ),
71+};
72+
73+function emitLifecycleEvent(event: ServerLifecycleStreamEvent) {
74+ for (const listener of lifecycleListeners) {
75+ listener(event);
76+ }
77+}
78+
79+function emitServerConfigEvent(event: ServerConfigStreamEvent) {
80+ for (const listener of configListeners) {
81+ listener(event);
82+ }
83+}
84+
85+async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> {
86+ const startedAt = Date.now();
87+ for (;;) {
88+ try {
89+ assertion();
90+ return;
91+ } catch (error) {
92+ if (Date.now() - startedAt >= timeoutMs) {
93+ throw error;
94+ }
95+ await new Promise((resolve) => setTimeout(resolve, 10));
96+ }
97+ }
98+}
99+
100+beforeEach(() => {
101+ vi.clearAllMocks();
102+ lifecycleListeners.clear();
103+ configListeners.clear();
104+ resetServerStateForTests();
105+});
106+
107+afterEach(() => {
108+ resetServerStateForTests();
109+});
110+
111+describe("serverState", () => {
112+ it("bootstraps the server config snapshot and replays it to late subscribers", async () => {
113+ serverApi.getConfig.mockResolvedValueOnce(baseServerConfig);
114+
115+ const configListener = vi.fn();
116+ const stop = startServerStateSync(serverApi);
117+ const unsubscribe = onServerConfigUpdated(configListener);
118+
119+ await waitFor(() => {
120+ expect(getServerConfig()).toEqual(baseServerConfig);
121+ });
122+
123+ expect(serverApi.subscribeConfig).toHaveBeenCalledOnce();
124+ expect(serverApi.subscribeLifecycle).toHaveBeenCalledOnce();
125+ expect(serverApi.getConfig).toHaveBeenCalledOnce();
126+ expect(configListener).toHaveBeenCalledWith(
127+ {
128+ issues: [],
129+ providers: defaultProviders,
130+ settings: DEFAULT_SERVER_SETTINGS,
131+ },
132+ "snapshot",
133+ );
134+
135+ const lateListener = vi.fn();
136+ const unsubscribeLate = onServerConfigUpdated(lateListener);
137+ expect(lateListener).toHaveBeenCalledWith(
138+ {
139+ issues: [],
140+ providers: defaultProviders,
141+ settings: DEFAULT_SERVER_SETTINGS,
142+ },
143+ "snapshot",
144+ );
145+
146+ unsubscribeLate();
147+ unsubscribe();
148+ stop();
149+ });
150+
151+ it("keeps the streamed snapshot when it arrives before the fallback fetch resolves", async () => {
152+ const deferred = createDeferredPromise<ServerConfig>();
153+ serverApi.getConfig.mockReturnValueOnce(deferred.promise);
154+ const stop = startServerStateSync(serverApi);
155+
156+ const streamedConfig: ServerConfig = {
157+ ...baseServerConfig,
158+ cwd: "/tmp/from-stream",
159+ };
160+
161+ emitServerConfigEvent({
162+ version: 1,
163+ type: "snapshot",
164+ config: streamedConfig,
165+ });
166+
167+ await waitFor(() => {
168+ expect(getServerConfig()).toEqual(streamedConfig);
169+ });
170+
171+ deferred.resolve(baseServerConfig);
172+ await new Promise((resolve) => setTimeout(resolve, 0));
173+
174+ expect(getServerConfig()).toEqual(streamedConfig);
175+ stop();
176+ });
177+
178+ it("replays welcome events to late subscribers", async () => {
179+ serverApi.getConfig.mockResolvedValueOnce(baseServerConfig);
180+ const stop = startServerStateSync(serverApi);
181+
182+ const listener = vi.fn();
183+ const unsubscribe = onWelcome(listener);
184+
185+ emitLifecycleEvent({
186+ version: 1,
187+ sequence: 1,
188+ type: "welcome",
189+ payload: {
190+ cwd: "/tmp/workspace",
191+ projectName: "t3-code",
192+ bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
193+ bootstrapThreadId: ThreadId.makeUnsafe("thread-1"),
194+ },
195+ });
196+
197+ expect(listener).toHaveBeenCalledWith({
198+ cwd: "/tmp/workspace",
199+ projectName: "t3-code",
200+ bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
201+ bootstrapThreadId: ThreadId.makeUnsafe("thread-1"),
202+ });
203+
204+ const lateListener = vi.fn();
205+ const unsubscribeLate = onWelcome(lateListener);
206+ expect(lateListener).toHaveBeenCalledWith({
207+ cwd: "/tmp/workspace",
208+ projectName: "t3-code",
209+ bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
210+ bootstrapThreadId: ThreadId.makeUnsafe("thread-1"),
211+ });
212+
213+ unsubscribeLate();
214+ unsubscribe();
215+ stop();
216+ });
217+
218+ it("merges provider, settings, and keybinding updates into the cached config", async () => {
219+ serverApi.getConfig.mockResolvedValueOnce(baseServerConfig);
220+ const configListener = vi.fn();
221+ const providersListener = vi.fn();
222+ const stop = startServerStateSync(serverApi);
223+ const unsubscribeConfig = onServerConfigUpdated(configListener);
224+ const unsubscribeProviders = onProvidersUpdated(providersListener);
225+
226+ await waitFor(() => {
227+ expect(getServerConfig()).toEqual(baseServerConfig);
228+ });
229+
230+ const nextProviders: ReadonlyArray<ServerProvider> = [
231+ {
232+ ...defaultProviders[0]!,
233+ status: "warning",
234+ checkedAt: "2026-01-02T00:00:00.000Z",
235+ message: "rate limited",
236+ },
237+ ];
238+
239+ emitServerConfigEvent({
240+ version: 1,
241+ type: "keybindingsUpdated",
242+ payload: {
243+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
244+ },
245+ });
246+ emitServerConfigEvent({
247+ version: 1,
248+ type: "providerStatuses",
249+ payload: {
250+ providers: nextProviders,
251+ },
252+ });
253+ emitServerConfigEvent({
254+ version: 1,
255+ type: "settingsUpdated",
256+ payload: {
257+ settings: {
258+ ...DEFAULT_SERVER_SETTINGS,
259+ enableAssistantStreaming: true,
260+ },
261+ },
262+ });
263+
264+ await waitFor(() => {
265+ expect(getServerConfig()).toEqual({
266+ ...baseServerConfig,
267+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
268+ providers: nextProviders,
269+ settings: {
270+ ...DEFAULT_SERVER_SETTINGS,
271+ enableAssistantStreaming: true,
272+ },
273+ });
274+ });
275+
276+ expect(providersListener).toHaveBeenLastCalledWith({ providers: nextProviders });
277+ expect(configListener).toHaveBeenNthCalledWith(
278+ 2,
279+ {
280+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
281+ providers: defaultProviders,
282+ settings: DEFAULT_SERVER_SETTINGS,
283+ },
284+ "keybindingsUpdated",
285+ );
286+ expect(configListener).toHaveBeenNthCalledWith(
287+ 3,
288+ {
289+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
290+ providers: nextProviders,
291+ settings: DEFAULT_SERVER_SETTINGS,
292+ },
293+ "providerStatuses",
294+ );
295+ expect(configListener).toHaveBeenLastCalledWith(
296+ {
297+ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
298+ providers: nextProviders,
299+ settings: {
300+ ...DEFAULT_SERVER_SETTINGS,
301+ enableAssistantStreaming: true,
302+ },
303+ },
304+ "settingsUpdated",
305+ );
306+
307+ unsubscribeProviders();
308+ unsubscribeConfig();
309+ stop();
310+ });
311+});
apps/web/src/rpc/serverState.ts+280−0
@@ -0,0 +1,280 @@
1+import { useAtomSubscribe, useAtomValue } from "@effect/atom-react";
2+import {
3+ DEFAULT_SERVER_SETTINGS,
4+ type EditorId,
5+ type ServerConfig,
6+ type ServerConfigStreamEvent,
7+ type ServerConfigUpdatedPayload,
8+ type ServerLifecycleWelcomePayload,
9+ type ServerProvider,
10+ type ServerProviderUpdatedPayload,
11+ type ServerSettings,
12+} from "@t3tools/contracts";
13+import { Atom } from "effect/unstable/reactivity";
14+
15+import type { WsRpcClient } from "../wsRpcClient";
16+import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry";
17+
18+export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"];
19+
20+export interface ServerConfigUpdatedNotification {
21+ readonly payload: ServerConfigUpdatedPayload;
22+ readonly source: ServerConfigUpdateSource;
23+}
24+
25+type ServerStateClient = Pick<
26+ WsRpcClient["server"],
27+ "getConfig" | "subscribeConfig" | "subscribeLifecycle"
28+>;
29+
30+function makeStateAtom<A>(label: string, initialValue: A) {
31+ return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label));
32+}
33+
34+function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdatedPayload {
35+ return {
36+ issues: config.issues,
37+ providers: config.providers,
38+ settings: config.settings,
39+ };
40+}
41+
42+const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = [];
43+const EMPTY_KEYBINDINGS: ServerConfig["keybindings"] = [];
44+const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
45+
46+const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray<EditorId> =>
47+ config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
48+const selectKeybindings = (config: ServerConfig | null) => config?.keybindings ?? EMPTY_KEYBINDINGS;
49+const selectKeybindingsConfigPath = (config: ServerConfig | null) =>
50+ config?.keybindingsConfigPath ?? null;
51+const selectProviders = (config: ServerConfig | null) =>
52+ config?.providers ?? EMPTY_SERVER_PROVIDERS;
53+const selectSettings = (config: ServerConfig | null): ServerSettings =>
54+ config?.settings ?? DEFAULT_SERVER_SETTINGS;
55+
56+export const welcomeAtom = makeStateAtom<ServerLifecycleWelcomePayload | null>(
57+ "server-welcome",
58+ null,
59+);
60+export const serverConfigAtom = makeStateAtom<ServerConfig | null>("server-config", null);
61+export const serverConfigUpdatedAtom = makeStateAtom<ServerConfigUpdatedNotification | null>(
62+ "server-config-updated",
63+ null,
64+);
65+export const providersUpdatedAtom = makeStateAtom<ServerProviderUpdatedPayload | null>(
66+ "server-providers-updated",
67+ null,
68+);
69+
70+export function getServerConfig(): ServerConfig | null {
71+ return appAtomRegistry.get(serverConfigAtom);
72+}
73+
74+export function setServerConfigSnapshot(config: ServerConfig): void {
75+ resolveServerConfig(config);
76+ emitProvidersUpdated({ providers: config.providers });
77+ emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot");
78+}
79+
80+export function applyServerConfigEvent(event: ServerConfigStreamEvent): void {
81+ switch (event.type) {
82+ case "snapshot": {
83+ setServerConfigSnapshot(event.config);
84+ return;
85+ }
86+ case "keybindingsUpdated": {
87+ const latestServerConfig = getServerConfig();
88+ if (!latestServerConfig) {
89+ return;
90+ }
91+ const nextConfig = {
92+ ...latestServerConfig,
93+ issues: event.payload.issues,
94+ } satisfies ServerConfig;
95+ resolveServerConfig(nextConfig);
96+ emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type);
97+ return;
98+ }
99+ case "providerStatuses": {
100+ applyProvidersUpdated(event.payload);
101+ return;
102+ }
103+ case "settingsUpdated": {
104+ applySettingsUpdated(event.payload.settings);
105+ return;
106+ }
107+ }
108+}
109+
110+export function applyProvidersUpdated(payload: ServerProviderUpdatedPayload): void {
111+ const latestServerConfig = getServerConfig();
112+ emitProvidersUpdated(payload);
113+
114+ if (!latestServerConfig) {
115+ return;
116+ }
117+
118+ const nextConfig = {
119+ ...latestServerConfig,
120+ providers: payload.providers,
121+ } satisfies ServerConfig;
122+ resolveServerConfig(nextConfig);
123+ emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "providerStatuses");
124+}
125+
126+export function applySettingsUpdated(settings: ServerSettings): void {
127+ const latestServerConfig = getServerConfig();
128+ if (!latestServerConfig) {
129+ return;
130+ }
131+
132+ const nextConfig = {
133+ ...latestServerConfig,
134+ settings,
135+ } satisfies ServerConfig;
136+ resolveServerConfig(nextConfig);
137+ emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "settingsUpdated");
138+}
139+
140+export function emitWelcome(payload: ServerLifecycleWelcomePayload): void {
141+ appAtomRegistry.set(welcomeAtom, payload);
142+}
143+
144+export function onWelcome(listener: (payload: ServerLifecycleWelcomePayload) => void): () => void {
145+ return subscribeLatest(welcomeAtom, listener);
146+}
147+
148+export function onServerConfigUpdated(
149+ listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void,
150+): () => void {
151+ return subscribeLatest(serverConfigUpdatedAtom, (notification) => {
152+ listener(notification.payload, notification.source);
153+ });
154+}
155+
156+export function onProvidersUpdated(
157+ listener: (payload: ServerProviderUpdatedPayload) => void,
158+): () => void {
159+ return subscribeLatest(providersUpdatedAtom, listener);
160+}
161+
162+export function startServerStateSync(client: ServerStateClient): () => void {
163+ let disposed = false;
164+ const cleanups = [
165+ client.subscribeLifecycle((event) => {
166+ if (event.type === "welcome") {
167+ emitWelcome(event.payload);
168+ }
169+ }),
170+ client.subscribeConfig((event) => {
171+ applyServerConfigEvent(event);
172+ }),
173+ ];
174+
175+ if (getServerConfig() === null) {
176+ void client
177+ .getConfig()
178+ .then((config) => {
179+ if (disposed || getServerConfig() !== null) {
180+ return;
181+ }
182+ setServerConfigSnapshot(config);
183+ })
184+ .catch(() => undefined);
185+ }
186+
187+ return () => {
188+ disposed = true;
189+ for (const cleanup of cleanups) {
190+ cleanup();
191+ }
192+ };
193+}
194+
195+export function resetServerStateForTests() {
196+ resetAppAtomRegistryForTests();
197+}
198+
199+function resolveServerConfig(config: ServerConfig): void {
200+ appAtomRegistry.set(serverConfigAtom, config);
201+}
202+
203+function emitProvidersUpdated(payload: ServerProviderUpdatedPayload): void {
204+ appAtomRegistry.set(providersUpdatedAtom, payload);
205+}
206+
207+function emitServerConfigUpdated(
208+ payload: ServerConfigUpdatedPayload,
209+ source: ServerConfigUpdateSource,
210+): void {
211+ appAtomRegistry.set(serverConfigUpdatedAtom, { payload, source });
212+}
213+
214+function subscribeLatest<A>(
215+ atom: Atom.Atom<A | null>,
216+ listener: (value: NonNullable<A>) => void,
217+): () => void {
218+ return appAtomRegistry.subscribe(
219+ atom,
220+ (value) => {
221+ if (value === null) {
222+ return;
223+ }
224+ listener(value as NonNullable<A>);
225+ },
226+ { immediate: true },
227+ );
228+}
229+
230+function useLatestAtomSubscription<A>(
231+ atom: Atom.Atom<A | null>,
232+ listener: (value: NonNullable<A>) => void,
233+) {
234+ useAtomSubscribe(
235+ atom,
236+ (value) => {
237+ if (value === null) {
238+ return;
239+ }
240+ listener(value as NonNullable<A>);
241+ },
242+ { immediate: true },
243+ );
244+}
245+
246+export function useServerConfig(): ServerConfig | null {
247+ return useAtomValue(serverConfigAtom);
248+}
249+
250+export function useServerSettings(): ServerSettings {
251+ return useAtomValue(serverConfigAtom, selectSettings);
252+}
253+
254+export function useServerProviders(): ReadonlyArray<ServerProvider> {
255+ return useAtomValue(serverConfigAtom, selectProviders);
256+}
257+
258+export function useServerKeybindings(): ServerConfig["keybindings"] {
259+ return useAtomValue(serverConfigAtom, selectKeybindings);
260+}
261+
262+export function useServerAvailableEditors(): ReadonlyArray<EditorId> {
263+ return useAtomValue(serverConfigAtom, selectAvailableEditors);
264+}
265+
266+export function useServerKeybindingsConfigPath(): string | null {
267+ return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath);
268+}
269+
270+export function useServerWelcomeSubscription(
271+ listener: (payload: ServerLifecycleWelcomePayload) => void,
272+): void {
273+ useLatestAtomSubscription(welcomeAtom, listener);
274+}
275+
276+export function useServerConfigUpdatedSubscription(
277+ listener: (notification: ServerConfigUpdatedNotification) => void,
278+): void {
279+ useLatestAtomSubscription(serverConfigUpdatedAtom, listener);
280+}
apps/web/src/rpc/serverStateBootstrap.tsx+10−0
@@ -0,0 +1,10 @@
1+import { useEffect } from "react";
2+
3+import { getWsRpcClient } from "../wsRpcClient";
4+import { startServerStateSync } from "./serverState";
5+
6+export function ServerStateBootstrap() {
7+ useEffect(() => startServerStateSync(getWsRpcClient().server), []);
8+
9+ return null;
10+}
apps/web/src/wsNativeApi.test.ts+178−280
@@ -1,23 +1,19 @@
11 import {
22 CommandId,
3− type ContextMenuItem,
3+ DEFAULT_SERVER_SETTINGS,
4+ type DesktopBridge,
45 EventId,
5− ORCHESTRATION_WS_CHANNELS,
6− ORCHESTRATION_WS_METHODS,
7− type OrchestrationEvent,
86 ProjectId,
9− ThreadId,
10− type WsPushChannel,
11− type WsPushData,
12− type WsPushMessage,
13− WS_CHANNELS,
14− WS_METHODS,
15− type WsPush,
7+ type OrchestrationEvent,
8+ type ServerConfig,
169 type ServerProvider,
10+ type TerminalEvent,
11+ ThreadId,
1712 } from "@t3tools/contracts";
1813 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1914
20−const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>();
15+import type { ContextMenuItem } from "@t3tools/contracts";
16+
2117 const showContextMenuFallbackMock =
2218 vi.fn<
2319 <T extends string>(
@@ -25,39 +21,75 @@ const showContextMenuFallbackMock = · 1 unmodified line
2521 position?: { x: number; y: number },
2622 ) => Promise<T | null>
2723 >();
28−const channelListeners = new Map<string, Set<(message: WsPush) => void>>();
29−const latestPushByChannel = new Map<string, WsPush>();
30−const subscribeMock = vi.fn<
31− (
32− channel: string,
33− listener: (message: WsPush) => void,
34− options?: { replayLatest?: boolean },
35− ) => () => void
36−>((channel, listener, options) => {
37− const listeners = channelListeners.get(channel) ?? new Set<(message: WsPush) => void>();
24+
25+function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
3826 listeners.add(listener);
39− channelListeners.set(channel, listeners);
40− const latest = latestPushByChannel.get(channel);
41− if (latest && options?.replayLatest) {
42− listener(latest);
43− }
4427 return () => {
4528 listeners.delete(listener);
46− if (listeners.size === 0) {
47− channelListeners.delete(channel);
48− }
4929 };
50−});
30+}
5131
52−vi.mock("./wsTransport", () => {
32+const terminalEventListeners = new Set<(event: TerminalEvent) => void>();
33+const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>();
34+
35+const rpcClientMock = {
36+ dispose: vi.fn(),
37+ terminal: {
38+ open: vi.fn(),
39+ write: vi.fn(),
40+ resize: vi.fn(),
41+ clear: vi.fn(),
42+ restart: vi.fn(),
43+ close: vi.fn(),
44+ onEvent: vi.fn((listener: (event: TerminalEvent) => void) =>
45+ registerListener(terminalEventListeners, listener),
46+ ),
47+ },
48+ projects: {
49+ searchEntries: vi.fn(),
50+ writeFile: vi.fn(),
51+ },
52+ shell: {
53+ openInEditor: vi.fn(),
54+ },
55+ git: {
56+ pull: vi.fn(),
57+ status: vi.fn(),
58+ runStackedAction: vi.fn(),
59+ listBranches: vi.fn(),
60+ createWorktree: vi.fn(),
61+ removeWorktree: vi.fn(),
62+ createBranch: vi.fn(),
63+ checkout: vi.fn(),
64+ init: vi.fn(),
65+ resolvePullRequest: vi.fn(),
66+ preparePullRequestThread: vi.fn(),
67+ },
68+ server: {
69+ getConfig: vi.fn(),
70+ refreshProviders: vi.fn(),
71+ upsertKeybinding: vi.fn(),
72+ getSettings: vi.fn(),
73+ updateSettings: vi.fn(),
74+ subscribeConfig: vi.fn(),
75+ subscribeLifecycle: vi.fn(),
76+ },
77+ orchestration: {
78+ getSnapshot: vi.fn(),
79+ dispatchCommand: vi.fn(),
80+ getTurnDiff: vi.fn(),
81+ getFullThreadDiff: vi.fn(),
82+ replayEvents: vi.fn(),
83+ onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) =>
84+ registerListener(orchestrationEventListeners, listener),
85+ ),
86+ },
87+};
88+
89+vi.mock("./wsRpcClient", () => {
5390 return {
54− WsTransport: class MockWsTransport {
55− request = requestMock;
56− subscribe = subscribeMock;
57− getLatestPush(channel: string) {
58− return latestPushByChannel.get(channel) ?? null;
59− }
60− },
91+ getWsRpcClient: () => rpcClientMock,
92+ __resetWsRpcClientForTests: vi.fn(),
6193 };
6294 });
6395
@@ -65,20 +97,9 @@ vi.mock("./contextMenuFallback", () => ({ · 1 unmodified line
6597 showContextMenuFallback: showContextMenuFallbackMock,
6698 }));
6799
68−let nextPushSequence = 1;
69−
70−function emitPush<C extends WsPushChannel>(channel: C, data: WsPushData<C>): void {
71− const listeners = channelListeners.get(channel);
72− const message = {
73− type: "push" as const,
74− sequence: nextPushSequence++,
75− channel,
76− data,
77− } as WsPushMessage<C>;
78− latestPushByChannel.set(channel, message);
79− if (!listeners) return;
100+function emitEvent<T>(listeners: Set<(event: T) => void>, event: T) {
80101 for (const listener of listeners) {
81− listener(message);
102+ listener(event);
82103 }
83104 }
84105
@@ -92,6 +113,32 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn · 7 unmodified lines
92113 return testGlobal.window;
93114 }
94115
116+function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge {
117+ return {
118+ getWsUrl: () => null,
119+ pickFolder: async () => null,
120+ confirm: async () => true,
121+ setTheme: async () => undefined,
122+ showContextMenu: async () => null,
123+ openExternal: async () => true,
124+ onMenuAction: () => () => undefined,
125+ getUpdateState: async () => {
126+ throw new Error("getUpdateState not implemented in test");
127+ },
128+ checkForUpdate: async () => {
129+ throw new Error("checkForUpdate not implemented in test");
130+ },
131+ downloadUpdate: async () => {
132+ throw new Error("downloadUpdate not implemented in test");
133+ },
134+ installUpdate: async () => {
135+ throw new Error("installUpdate not implemented in test");
136+ },
137+ onUpdateState: () => () => undefined,
138+ ...overrides,
139+ };
140+}
141+
95142 const defaultProviders: ReadonlyArray<ServerProvider> = [
96143 {
97144 provider: "codex",
@@ -105,14 +152,22 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [ · 7 unmodified lines
105152 },
106153 ];
107154
155+const baseServerConfig: ServerConfig = {
156+ cwd: "/tmp/workspace",
157+ keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
158+ keybindings: [],
159+ issues: [],
160+ providers: defaultProviders,
161+ availableEditors: ["cursor"],
162+ settings: DEFAULT_SERVER_SETTINGS,
163+};
164+
108165 beforeEach(() => {
109166 vi.resetModules();
110− requestMock.mockReset();
167+ vi.clearAllMocks();
111168 showContextMenuFallbackMock.mockReset();
112− subscribeMock.mockClear();
113− channelListeners.clear();
114− latestPushByChannel.clear();
115− nextPushSequence = 1;
169+ terminalEventListeners.clear();
170+ orchestrationEventListeners.clear();
116171 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
117172 });
118173
@@ -121,149 +176,27 @@ afterEach(() => { · 2 unmodified lines
121176 });
122177
123178 describe("wsNativeApi", () => {
124− it("delivers and caches valid server.welcome payloads", async () => {
125− const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
126−
127− createWsNativeApi();
128− const listener = vi.fn();
129− onServerWelcome(listener);
130−
131− const payload = { cwd: "/tmp/workspace", projectName: "t3-code" };
132− emitPush(WS_CHANNELS.serverWelcome, payload);
133−
134− expect(listener).toHaveBeenCalledTimes(1);
135− expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
136−
137− const lateListener = vi.fn();
138− onServerWelcome(lateListener);
139−
140− expect(lateListener).toHaveBeenCalledTimes(1);
141− expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload));
142− });
143−
144− it("preserves bootstrap ids from server.welcome payloads", async () => {
145− const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
146−
147− createWsNativeApi();
148− const listener = vi.fn();
149− onServerWelcome(listener);
150−
151− emitPush(WS_CHANNELS.serverWelcome, {
152− cwd: "/tmp/workspace",
153− projectName: "t3-code",
154− bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
155− bootstrapThreadId: ThreadId.makeUnsafe("thread-1"),
156− });
157−
158− expect(listener).toHaveBeenCalledTimes(1);
159− expect(listener).toHaveBeenCalledWith(
160− expect.objectContaining({
161− cwd: "/tmp/workspace",
162− projectName: "t3-code",
163− bootstrapProjectId: "project-1",
164− bootstrapThreadId: "thread-1",
165− }),
166− );
167− });
168−
169− it("delivers successive server.welcome payloads to active listeners", async () => {
170− const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
171−
172− createWsNativeApi();
173− const listener = vi.fn();
174− onServerWelcome(listener);
175−
176− emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/one", projectName: "one" });
177− emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" });
178−
179− expect(listener).toHaveBeenCalledTimes(2);
180− expect(listener).toHaveBeenLastCalledWith(
181− expect.objectContaining({
182− cwd: "/tmp/workspace",
183− projectName: "t3-code",
184− }),
185− );
186− });
187−
188− it("delivers and caches valid server.configUpdated payloads", async () => {
189− const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi");
190−
191− createWsNativeApi();
192− const listener = vi.fn();
193− onServerConfigUpdated(listener);
194−
195− const payload = {
196− issues: [
197− {
198− kind: "keybindings.invalid-entry",
199− index: 1,
200− message: "Entry at index 1 is invalid.",
201− },
202− ],
203− } as const;
204− emitPush(WS_CHANNELS.serverConfigUpdated, payload);
205−
206− expect(listener).toHaveBeenCalledTimes(1);
207− expect(listener).toHaveBeenCalledWith(payload);
208−
209− const lateListener = vi.fn();
210− onServerConfigUpdated(lateListener);
211− expect(lateListener).toHaveBeenCalledTimes(1);
212− expect(lateListener).toHaveBeenCalledWith(payload);
213− });
214−
215− it("delivers successive server.configUpdated payloads to active listeners", async () => {
216− const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi");
217−
218− createWsNativeApi();
219− const listener = vi.fn();
220− onServerConfigUpdated(listener);
221−
222− emitPush(WS_CHANNELS.serverConfigUpdated, {
223− issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
224− });
225− emitPush(WS_CHANNELS.serverConfigUpdated, {
226− issues: [],
227− });
228−
229− expect(listener).toHaveBeenCalledTimes(2);
230− expect(listener).toHaveBeenLastCalledWith({
231− issues: [],
232− });
233− });
234−
235− it("delivers and caches valid server.providersUpdated payloads", async () => {
236− const { createWsNativeApi, onServerProvidersUpdated } = await import("./wsNativeApi");
237−
238− createWsNativeApi();
239− const listener = vi.fn();
240− onServerProvidersUpdated(listener);
241−
242− const payload = {
243− providers: defaultProviders,
244− } as const;
245− emitPush(WS_CHANNELS.serverProvidersUpdated, payload);
179+ it("forwards server config fetches directly to the RPC client", async () => {
180+ rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig);
181+ const { createWsNativeApi } = await import("./wsNativeApi");
246182
247− expect(listener).toHaveBeenCalledTimes(1);
248− expect(listener).toHaveBeenCalledWith(payload);
183+ const api = createWsNativeApi();
249184
250− const lateListener = vi.fn();
251− onServerProvidersUpdated(lateListener);
252− expect(lateListener).toHaveBeenCalledTimes(1);
253− expect(lateListener).toHaveBeenCalledWith(payload);
185+ await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig);
186+ expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith();
187+ expect(rpcClientMock.server.subscribeConfig).not.toHaveBeenCalled();
188+ expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled();
254189 });
255190
256− it("forwards valid terminal and orchestration events", async () => {
191+ it("forwards terminal and orchestration stream events", async () => {
257192 const { createWsNativeApi } = await import("./wsNativeApi");
258193
259194 const api = createWsNativeApi();
260195 const onTerminalEvent = vi.fn();
261196 const onDomainEvent = vi.fn();
262− const onActionProgress = vi.fn();
263197
264198 api.terminal.onEvent(onTerminalEvent);
265199 api.orchestration.onDomainEvent(onDomainEvent);
266− api.git.onActionProgress(onActionProgress);
267200
268201 const terminalEvent = {
269202 threadId: "thread-1",
@@ -272,7 +205,7 @@ describe("wsNativeApi", () => { · 2 unmodified lines
272205 type: "output",
273206 data: "hello",
274207 } as const;
275− emitPush(WS_CHANNELS.terminalEvent, terminalEvent);
208+ emitEvent(terminalEventListeners, terminalEvent);
276209
277210 const orchestrationEvent = {
278211 sequence: 1,
@@ -289,39 +222,23 @@ describe("wsNativeApi", () => { · 10 unmodified lines
289222 projectId: ProjectId.makeUnsafe("project-1"),
290223 title: "Project",
291224 workspaceRoot: "/tmp/workspace",
292− defaultModelSelection: null,
225+ defaultModelSelection: {
226+ provider: "codex",
227+ model: "gpt-5-codex",
228+ },
293229 scripts: [],
294230 createdAt: "2026-02-24T00:00:00.000Z",
295231 updatedAt: "2026-02-24T00:00:00.000Z",
296232 },
297233 } satisfies Extract<OrchestrationEvent, { type: "project.created" }>;
298− emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent);
299− emitPush(WS_CHANNELS.gitActionProgress, {
300− actionId: "action-1",
301− cwd: "/repo",
302− action: "commit",
303− kind: "phase_started",
304− phase: "commit",
305− label: "Committing...",
306− });
234+ emitEvent(orchestrationEventListeners, orchestrationEvent);
307235
308− expect(onTerminalEvent).toHaveBeenCalledTimes(1);
309236 expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);
310− expect(onDomainEvent).toHaveBeenCalledTimes(1);
311237 expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);
312− expect(onActionProgress).toHaveBeenCalledTimes(1);
313− expect(onActionProgress).toHaveBeenCalledWith({
314− actionId: "action-1",
315− cwd: "/repo",
316− action: "commit",
317− kind: "phase_started",
318− phase: "commit",
319− label: "Committing...",
320− });
321238 });
322239
323− it("wraps orchestration dispatch commands in the command envelope", async () => {
324− requestMock.mockResolvedValue(undefined);
240+ it("sends orchestration dispatch commands as the direct RPC payload", async () => {
241+ rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 });
325242 const { createWsNativeApi } = await import("./wsNativeApi");
326243
327244 const api = createWsNativeApi();
@@ -339,13 +256,11 @@ describe("wsNativeApi", () => { · 11 unmodified lines
339256 } as const;
340257 await api.orchestration.dispatchCommand(command);
341258
342− expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, {
343− command,
344− });
259+ expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command);
345260 });
346261
347− it("forwards workspace file writes to the websocket project method", async () => {
348− requestMock.mockResolvedValue({ relativePath: "plan.md" });
262+ it("forwards workspace file writes to the project RPC", async () => {
263+ rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" });
349264 const { createWsNativeApi } = await import("./wsNativeApi");
350265
351266 const api = createWsNativeApi();
@@ -355,100 +270,83 @@ describe("wsNativeApi", () => { · 3 unmodified lines
355270 contents: "# Plan\n",
356271 });
357272
358− expect(requestMock).toHaveBeenCalledWith(WS_METHODS.projectsWriteFile, {
273+ expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({
359274 cwd: "/tmp/project",
360275 relativePath: "plan.md",
361276 contents: "# Plan\n",
362277 });
363278 });
364279
365− it("uses no client timeout for git.runStackedAction", async () => {
366− requestMock.mockResolvedValue({
367− action: "commit",
368− branch: { status: "skipped_not_requested" },
369− commit: { status: "created", commitSha: "abc1234", subject: "Test" },
370− push: { status: "skipped_not_requested" },
371− pr: { status: "skipped_not_requested" },
372− });
280+ it("forwards full-thread diff requests to the orchestration RPC", async () => {
281+ rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" });
373282 const { createWsNativeApi } = await import("./wsNativeApi");
374283
375284 const api = createWsNativeApi();
376− await api.git.runStackedAction({
377− actionId: "action-1",
378− cwd: "/repo",
379− action: "commit",
285+ await api.orchestration.getFullThreadDiff({
286+ threadId: ThreadId.makeUnsafe("thread-1"),
287+ toTurnCount: 1,
380288 });
381289
382− expect(requestMock).toHaveBeenCalledWith(
383− WS_METHODS.gitRunStackedAction,
290+ expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({
291+ threadId: "thread-1",
292+ toTurnCount: 1,
293+ });
294+ });
295+
296+ it("forwards provider refreshes directly to the RPC client", async () => {
297+ const nextProviders: ReadonlyArray<ServerProvider> = [
384298 {
385− actionId: "action-1",
386− cwd: "/repo",
387− action: "commit",
299+ ...defaultProviders[0]!,
300+ checkedAt: "2026-01-03T00:00:00.000Z",
388301 },
389− { timeoutMs: null },
390− );
302+ ];
303+ rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders });
304+ const { createWsNativeApi } = await import("./wsNativeApi");
305+
306+ const api = createWsNativeApi();
307+
308+ await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders });
309+ expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith();
391310 });
392311
393− it("forwards full-thread diff requests to the orchestration websocket method", async () => {
394− requestMock.mockResolvedValue({ diff: "patch" });
312+ it("forwards server settings updates directly to the RPC client", async () => {
313+ const nextSettings = {
314+ ...DEFAULT_SERVER_SETTINGS,
315+ enableAssistantStreaming: true,
316+ };
317+ rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings);
395318 const { createWsNativeApi } = await import("./wsNativeApi");
396319
397320 const api = createWsNativeApi();
398− await api.orchestration.getFullThreadDiff({
399− threadId: ThreadId.makeUnsafe("thread-1"),
400− toTurnCount: 1,
401− });
402321
403− expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.getFullThreadDiff, {
404− threadId: "thread-1",
405− toTurnCount: 1,
322+ await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual(
323+ nextSettings,
324+ );
325+ expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({
326+ enableAssistantStreaming: true,
406327 });
407328 });
408329
409− it("forwards context menu metadata to desktop bridge", async () => {
330+ it("forwards context menu metadata to the desktop bridge", async () => {
410331 const showContextMenu = vi.fn().mockResolvedValue("delete");
411− Object.defineProperty(getWindowForTest(), "desktopBridge", {
412− configurable: true,
413− writable: true,
414− value: {
415− showContextMenu,
416− },
417− });
332+ getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu });
418333
419334 const { createWsNativeApi } = await import("./wsNativeApi");
420335 const api = createWsNativeApi();
421− await api.contextMenu.show(
422− [
423− { id: "rename", label: "Rename thread" },
424− { id: "delete", label: "Delete", destructive: true },
425− ],
426− { x: 200, y: 300 },
427− );
336+ const items = [{ id: "delete", label: "Delete" }] as const;
428337
429− expect(showContextMenu).toHaveBeenCalledWith(
430− [
431− { id: "rename", label: "Rename thread" },
432− { id: "delete", label: "Delete", destructive: true },
433− ],
434− { x: 200, y: 300 },
435− );
338+ await expect(api.contextMenu.show(items)).resolves.toBe("delete");
339+ expect(showContextMenu).toHaveBeenCalledWith(items, undefined);
436340 });
437341
438− it("uses fallback context menu when desktop bridge is unavailable", async () => {
439− showContextMenuFallbackMock.mockResolvedValue("delete");
440− Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
441−
342+ it("falls back to the browser context menu helper when the desktop bridge is missing", async () => {
343+ showContextMenuFallbackMock.mockResolvedValue("rename");
442344 const { createWsNativeApi } = await import("./wsNativeApi");
345+
443346 const api = createWsNativeApi();
444− await api.contextMenu.show([{ id: "delete", label: "Delete", destructive: true }], {
445− x: 20,
446− y: 30,
447− });
347+ const items = [{ id: "rename", label: "Rename" }] as const;
448348
449− expect(showContextMenuFallbackMock).toHaveBeenCalledWith(
450− [{ id: "delete", label: "Delete", destructive: true }],
451− { x: 20, y: 30 },
452− );
349+ await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename");
350+ expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });
453351 });
454352 });
apps/web/src/wsNativeApi.ts+46−175
@@ -1,136 +1,23 @@
1−import {
2− type GitActionProgressEvent,
3− ORCHESTRATION_WS_CHANNELS,
4− ORCHESTRATION_WS_METHODS,
5− type ContextMenuItem,
6− type NativeApi,
7− ServerConfigUpdatedPayload,
8− ServerProviderUpdatedPayload,
9− WS_CHANNELS,
10− WS_METHODS,
11− type WsWelcomePayload,
12−} from "@t3tools/contracts";
1+import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts";
132
143 import { showContextMenuFallback } from "./contextMenuFallback";
15−import { WsTransport } from "./wsTransport";
4+import { resetServerStateForTests } from "./rpc/serverState";
5+import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient";
166
17−let instance: { api: NativeApi; transport: WsTransport } | null = null;
18−const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
19−const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();
20−const providersUpdatedListeners = new Set<(payload: ServerProviderUpdatedPayload) => void>();
21−const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>();
7+let instance: { api: NativeApi } | null = null;
228
23−/**
24− * Subscribe to the server welcome message. If a welcome was already received
25− * before this call, the listener fires synchronously with the cached payload.
26− * This avoids the race between WebSocket connect and React effect registration.
27− */
28−export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void {
29− welcomeListeners.add(listener);
30−
31− const latestWelcome = instance?.transport.getLatestPush(WS_CHANNELS.serverWelcome)?.data ?? null;
32− if (latestWelcome) {
33− try {
34− listener(latestWelcome);
35− } catch {
36− // Swallow listener errors
37− }
38− }
39−
40− return () => {
41− welcomeListeners.delete(listener);
42− };
43−}
44−
45−/**
46− * Subscribe to server config update events. Replays the latest update for
47− * late subscribers to avoid missing config validation feedback.
48− */
49−export function onServerConfigUpdated(
50− listener: (payload: ServerConfigUpdatedPayload) => void,
51−): () => void {
52− serverConfigUpdatedListeners.add(listener);
53−
54− const latestConfig =
55− instance?.transport.getLatestPush(WS_CHANNELS.serverConfigUpdated)?.data ?? null;
56− if (latestConfig) {
57− try {
58− listener(latestConfig);
59− } catch {
60− // Swallow listener errors
61− }
62− }
63−
64− return () => {
65− serverConfigUpdatedListeners.delete(listener);
66− };
67−}
68−
69−export function onServerProvidersUpdated(
70− listener: (payload: ServerProviderUpdatedPayload) => void,
71−): () => void {
72− providersUpdatedListeners.add(listener);
73−
74− const latestProviders =
75− instance?.transport.getLatestPush(WS_CHANNELS.serverProvidersUpdated)?.data ?? null;
76− if (latestProviders) {
77− try {
78− listener(latestProviders);
79− } catch {
80− // Swallow listener errors
81− }
82− }
83−
84− return () => {
85− providersUpdatedListeners.delete(listener);
86− };
9+export function __resetWsNativeApiForTests() {
10+ instance = null;
11+ __resetWsRpcClientForTests();
12+ resetServerStateForTests();
8713 }
8814
8915 export function createWsNativeApi(): NativeApi {
90− if (instance) return instance.api;
91−
92− const transport = new WsTransport();
16+ if (instance) {
17+ return instance.api;
18+ }
9319
94− transport.subscribe(WS_CHANNELS.serverWelcome, (message) => {
95− const payload = message.data;
96− for (const listener of welcomeListeners) {
97− try {
98− listener(payload);
99− } catch {
100− // Swallow listener errors
101− }
102− }
103− });
104− transport.subscribe(WS_CHANNELS.serverConfigUpdated, (message) => {
105− const payload = message.data;
106− for (const listener of serverConfigUpdatedListeners) {
107− try {
108− listener(payload);
109− } catch {
110− // Swallow listener errors
111− }
112− }
113− });
114− transport.subscribe(WS_CHANNELS.serverProvidersUpdated, (message) => {
115− const payload = message.data;
116− for (const listener of providersUpdatedListeners) {
117− try {
118− listener(payload);
119− } catch {
120− // Swallow listener errors
121− }
122− }
123− });
124− transport.subscribe(WS_CHANNELS.gitActionProgress, (message) => {
125− const payload = message.data;
126− for (const listener of gitActionProgressListeners) {
127− try {
128− listener(payload);
129− } catch {
130− // Swallow listener errors
131− }
132− }
133− });
20+ const rpcClient = getWsRpcClient();
13421
13522 const api: NativeApi = {
13623 dialogs: {
@@ -146,22 +33,20 @@ export function createWsNativeApi(): NativeApi { · 9 unmodified lines
14633 },
14734 },
14835 terminal: {
149− open: (input) => transport.request(WS_METHODS.terminalOpen, input),
150− write: (input) => transport.request(WS_METHODS.terminalWrite, input),
151− resize: (input) => transport.request(WS_METHODS.terminalResize, input),
152− clear: (input) => transport.request(WS_METHODS.terminalClear, input),
153− restart: (input) => transport.request(WS_METHODS.terminalRestart, input),
154− close: (input) => transport.request(WS_METHODS.terminalClose, input),
155− onEvent: (callback) =>
156− transport.subscribe(WS_CHANNELS.terminalEvent, (message) => callback(message.data)),
36+ open: (input) => rpcClient.terminal.open(input as never),
37+ write: (input) => rpcClient.terminal.write(input as never),
38+ resize: (input) => rpcClient.terminal.resize(input as never),
39+ clear: (input) => rpcClient.terminal.clear(input as never),
40+ restart: (input) => rpcClient.terminal.restart(input as never),
41+ close: (input) => rpcClient.terminal.close(input as never),
42+ onEvent: (callback) => rpcClient.terminal.onEvent(callback),
15743 },
15844 projects: {
159− searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
160− writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input),
45+ searchEntries: rpcClient.projects.searchEntries,
46+ writeFile: rpcClient.projects.writeFile,
16147 },
16248 shell: {
163− openInEditor: (cwd, editor) =>
164− transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),
49+ openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }),
16550 openExternal: async (url) => {
16651 if (window.desktopBridge) {
16752 const opened = await window.desktopBridge.openExternal(url);
@@ -171,31 +56,20 @@ export function createWsNativeApi(): NativeApi { · 3 unmodified lines
17156 return;
17257 }
17358
174− // Some mobile browsers can return null here even when the tab opens.
175− // Avoid false negatives and let the browser handle popup policy.
17659 window.open(url, "_blank", "noopener,noreferrer");
17760 },
17861 },
17962 git: {
180− pull: (input) => transport.request(WS_METHODS.gitPull, input),
181− status: (input) => transport.request(WS_METHODS.gitStatus, input),
182− runStackedAction: (input) =>
183− transport.request(WS_METHODS.gitRunStackedAction, input, { timeoutMs: null }),
184− listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
185− createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
186− removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
187− createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),
188− checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),
189− init: (input) => transport.request(WS_METHODS.gitInit, input),
190− resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input),
191− preparePullRequestThread: (input) =>
192− transport.request(WS_METHODS.gitPreparePullRequestThread, input),
193− onActionProgress: (callback) => {
194− gitActionProgressListeners.add(callback);
195− return () => {
196− gitActionProgressListeners.delete(callback);
197− };
198− },
63+ pull: rpcClient.git.pull,
64+ status: rpcClient.git.status,
65+ listBranches: rpcClient.git.listBranches,
66+ createWorktree: rpcClient.git.createWorktree,
67+ removeWorktree: rpcClient.git.removeWorktree,
68+ createBranch: rpcClient.git.createBranch,
69+ checkout: rpcClient.git.checkout,
70+ init: rpcClient.git.init,
71+ resolvePullRequest: rpcClient.git.resolvePullRequest,
72+ preparePullRequestThread: rpcClient.git.preparePullRequestThread,
19973 },
20074 contextMenu: {
20175 show: async <T extends string>(
@@ -209,28 +83,25 @@ export function createWsNativeApi(): NativeApi { · 7 unmodified lines
20983 },
21084 },
21185 server: {
212− getConfig: () => transport.request(WS_METHODS.serverGetConfig),
213− refreshProviders: () => transport.request(WS_METHODS.serverRefreshProviders),
214− upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input),
215− getSettings: () => transport.request(WS_METHODS.serverGetSettings),
216− updateSettings: (patch) => transport.request(WS_METHODS.serverUpdateSettings, { patch }),
86+ getConfig: rpcClient.server.getConfig,
87+ refreshProviders: rpcClient.server.refreshProviders,
88+ upsertKeybinding: rpcClient.server.upsertKeybinding,
89+ getSettings: rpcClient.server.getSettings,
90+ updateSettings: rpcClient.server.updateSettings,
21791 },
21892 orchestration: {
219− getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot),
220− dispatchCommand: (command) =>
221− transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }),
222− getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input),
223− getFullThreadDiff: (input) =>
224− transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input),
93+ getSnapshot: rpcClient.orchestration.getSnapshot,
94+ dispatchCommand: rpcClient.orchestration.dispatchCommand,
95+ getTurnDiff: rpcClient.orchestration.getTurnDiff,
96+ getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff,
22597 replayEvents: (fromSequenceExclusive) =>
226− transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }),
227− onDomainEvent: (callback) =>
228− transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (message) =>
229− callback(message.data),
230− ),
98+ rpcClient.orchestration
99+ .replayEvents({ fromSequenceExclusive })
100+ .then((events) => [...events]),
101+ onDomainEvent: (callback) => rpcClient.orchestration.onDomainEvent(callback),
231102 },
232103 };
233104
234− instance = { api, transport };
105+ instance = { api };
235106 return api;
236107 }
apps/web/src/wsRpcClient.ts+207−0
@@ -0,0 +1,207 @@
1+import {
2+ type GitActionProgressEvent,
3+ type GitRunStackedActionInput,
4+ type GitRunStackedActionResult,
5+ type NativeApi,
6+ ORCHESTRATION_WS_METHODS,
7+ type ServerSettingsPatch,
8+ WS_METHODS,
9+} from "@t3tools/contracts";
10+import { Effect, Stream } from "effect";
11+
12+import { type WsRpcProtocolClient } from "./rpc/protocol";
13+import { WsTransport } from "./wsTransport";
14+
15+type RpcTag = keyof WsRpcProtocolClient & string;
16+type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag];
17+type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0];
18+
19+type RpcUnaryMethod<TTag extends RpcTag> =
20+ RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>
21+ ? (input: RpcInput<TTag>) => Promise<TSuccess>
22+ : never;
23+
24+type RpcUnaryNoArgMethod<TTag extends RpcTag> =
25+ RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>
26+ ? () => Promise<TSuccess>
27+ : never;
28+
29+type RpcStreamMethod<TTag extends RpcTag> =
30+ RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any>
31+ ? (listener: (event: TEvent) => void) => () => void
32+ : never;
33+
34+interface GitRunStackedActionOptions {
35+ readonly onProgress?: (event: GitActionProgressEvent) => void;
36+}
37+
38+export interface WsRpcClient {
39+ readonly dispose: () => Promise<void>;
40+ readonly terminal: {
41+ readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>;
42+ readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>;
43+ readonly resize: RpcUnaryMethod<typeof WS_METHODS.terminalResize>;
44+ readonly clear: RpcUnaryMethod<typeof WS_METHODS.terminalClear>;
45+ readonly restart: RpcUnaryMethod<typeof WS_METHODS.terminalRestart>;
46+ readonly close: RpcUnaryMethod<typeof WS_METHODS.terminalClose>;
47+ readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>;
48+ };
49+ readonly projects: {
50+ readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>;
51+ readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>;
52+ };
53+ readonly shell: {
54+ readonly openInEditor: (input: {
55+ readonly cwd: Parameters<NativeApi["shell"]["openInEditor"]>[0];
56+ readonly editor: Parameters<NativeApi["shell"]["openInEditor"]>[1];
57+ }) => ReturnType<NativeApi["shell"]["openInEditor"]>;
58+ };
59+ readonly git: {
60+ readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>;
61+ readonly status: RpcUnaryMethod<typeof WS_METHODS.gitStatus>;
62+ readonly runStackedAction: (
63+ input: GitRunStackedActionInput,
64+ options?: GitRunStackedActionOptions,
65+ ) => Promise<GitRunStackedActionResult>;
66+ readonly listBranches: RpcUnaryMethod<typeof WS_METHODS.gitListBranches>;
67+ readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.gitCreateWorktree>;
68+ readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.gitRemoveWorktree>;
69+ readonly createBranch: RpcUnaryMethod<typeof WS_METHODS.gitCreateBranch>;
70+ readonly checkout: RpcUnaryMethod<typeof WS_METHODS.gitCheckout>;
71+ readonly init: RpcUnaryMethod<typeof WS_METHODS.gitInit>;
72+ readonly resolvePullRequest: RpcUnaryMethod<typeof WS_METHODS.gitResolvePullRequest>;
73+ readonly preparePullRequestThread: RpcUnaryMethod<
74+ typeof WS_METHODS.gitPreparePullRequestThread
75+ >;
76+ };
77+ readonly server: {
78+ readonly getConfig: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetConfig>;
79+ readonly refreshProviders: RpcUnaryNoArgMethod<typeof WS_METHODS.serverRefreshProviders>;
80+ readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>;
81+ readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>;
82+ readonly updateSettings: (
83+ patch: ServerSettingsPatch,
84+ ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverUpdateSettings>>;
85+ readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>;
86+ readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>;
87+ };
88+ readonly orchestration: {
89+ readonly getSnapshot: RpcUnaryNoArgMethod<typeof ORCHESTRATION_WS_METHODS.getSnapshot>;
90+ readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>;
91+ readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>;
92+ readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>;
93+ readonly replayEvents: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.replayEvents>;
94+ readonly onDomainEvent: RpcStreamMethod<typeof WS_METHODS.subscribeOrchestrationDomainEvents>;
95+ };
96+}
97+
98+let sharedWsRpcClient: WsRpcClient | null = null;
99+
100+export function getWsRpcClient(): WsRpcClient {
101+ if (sharedWsRpcClient) {
102+ return sharedWsRpcClient;
103+ }
104+ sharedWsRpcClient = createWsRpcClient();
105+ return sharedWsRpcClient;
106+}
107+
108+export async function __resetWsRpcClientForTests() {
109+ await sharedWsRpcClient?.dispose();
110+ sharedWsRpcClient = null;
111+}
112+
113+export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {
114+ return {
115+ dispose: () => transport.dispose(),
116+ terminal: {
117+ open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)),
118+ write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)),
119+ resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)),
120+ clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)),
121+ restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)),
122+ close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)),
123+ onEvent: (listener) =>
124+ transport.subscribe((client) => client[WS_METHODS.subscribeTerminalEvents]({}), listener),
125+ },
126+ projects: {
127+ searchEntries: (input) =>
128+ transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)),
129+ writeFile: (input) =>
130+ transport.request((client) => client[WS_METHODS.projectsWriteFile](input)),
131+ },
132+ shell: {
133+ openInEditor: (input) =>
134+ transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)),
135+ },
136+ git: {
137+ pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)),
138+ status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)),
139+ runStackedAction: async (input, options) => {
140+ let result: GitRunStackedActionResult | null = null;
141+
142+ await transport.requestStream(
143+ (client) => client[WS_METHODS.gitRunStackedAction](input),
144+ (event) => {
145+ options?.onProgress?.(event);
146+ if (event.kind === "action_finished") {
147+ result = event.result;
148+ }
149+ },
150+ );
151+
152+ if (result) {
153+ return result;
154+ }
155+
156+ throw new Error("Git action stream completed without a final result.");
157+ },
158+ listBranches: (input) =>
159+ transport.request((client) => client[WS_METHODS.gitListBranches](input)),
160+ createWorktree: (input) =>
161+ transport.request((client) => client[WS_METHODS.gitCreateWorktree](input)),
162+ removeWorktree: (input) =>
163+ transport.request((client) => client[WS_METHODS.gitRemoveWorktree](input)),
164+ createBranch: (input) =>
165+ transport.request((client) => client[WS_METHODS.gitCreateBranch](input)),
166+ checkout: (input) => transport.request((client) => client[WS_METHODS.gitCheckout](input)),
167+ init: (input) => transport.request((client) => client[WS_METHODS.gitInit](input)),
168+ resolvePullRequest: (input) =>
169+ transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)),
170+ preparePullRequestThread: (input) =>
171+ transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)),
172+ },
173+ server: {
174+ getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})),
175+ refreshProviders: () =>
176+ transport.request((client) => client[WS_METHODS.serverRefreshProviders]({})),
177+ upsertKeybinding: (input) =>
178+ transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)),
179+ getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),
180+ updateSettings: (patch) =>
181+ transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })),
182+ subscribeConfig: (listener) =>
183+ transport.subscribe((client) => client[WS_METHODS.subscribeServerConfig]({}), listener),
184+ subscribeLifecycle: (listener) =>
185+ transport.subscribe((client) => client[WS_METHODS.subscribeServerLifecycle]({}), listener),
186+ },
187+ orchestration: {
188+ getSnapshot: () =>
189+ transport.request((client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})),
190+ dispatchCommand: (input) =>
191+ transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)),
192+ getTurnDiff: (input) =>
193+ transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)),
194+ getFullThreadDiff: (input) =>
195+ transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)),
196+ replayEvents: (input) =>
197+ transport
198+ .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input))
199+ .then((events) => [...events]),
200+ onDomainEvent: (listener) =>
201+ transport.subscribe(
202+ (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}),
203+ listener,
204+ ),
205+ },
206+ };
207+}
apps/web/src/wsTransport.test.ts+297−110
@@ -1,10 +1,11 @@
1−import { WS_CHANNELS } from "@t3tools/contracts";
1+import { WS_METHODS } from "@t3tools/contracts";
22 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
44 import { WsTransport } from "./wsTransport";
55
66 type WsEventType = "open" | "message" | "close" | "error";
7−type WsListener = (event?: { data?: unknown }) => void;
7+type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
8+type WsListener = (event?: WsEvent) => void;
89
910 const sockets: MockWebSocket[] = [];
1011
@@ -16,9 +17,11 @@ class MockWebSocket { · 5 unmodified lines
1617
1718 readyState = MockWebSocket.CONNECTING;
1819 readonly sent: string[] = [];
20+ readonly url: string;
1921 private readonly listeners = new Map<WsEventType, Set<WsListener>>();
2022
21− constructor(_url: string) {
23+ constructor(url: string) {
24+ this.url = url;
2225 sockets.push(this);
2326 }
2427
@@ -28,25 +31,29 @@ class MockWebSocket { · 3 unmodified lines
2831 this.listeners.set(type, listeners);
2932 }
3033
34+ removeEventListener(type: WsEventType, listener: WsListener) {
35+ this.listeners.get(type)?.delete(listener);
36+ }
37+
3138 send(data: string) {
3239 this.sent.push(data);
3340 }
3441
35− close() {
42+ close(code = 1000, reason = "") {
3643 this.readyState = MockWebSocket.CLOSED;
37− this.emit("close");
44+ this.emit("close", { code, reason, type: "close" });
3845 }
3946
4047 open() {
4148 this.readyState = MockWebSocket.OPEN;
42− this.emit("open");
49+ this.emit("open", { type: "open" });
4350 }
4451
4552 serverMessage(data: unknown) {
46− this.emit("message", { data });
53+ this.emit("message", { data, type: "message" });
4754 }
4855
49− private emit(type: WsEventType, event?: { data?: unknown }) {
56+ private emit(type: WsEventType, event?: WsEvent) {
5057 const listeners = this.listeners.get(type);
5158 if (!listeners) return;
5259 for (const listener of listeners) {
@@ -65,13 +72,33 @@ function getSocket(): MockWebSocket { · 12 unmodified lines
6572 return socket;
6673 }
6774
75+async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> {
76+ const startedAt = Date.now();
77+ for (;;) {
78+ try {
79+ assertion();
80+ return;
81+ } catch (error) {
82+ if (Date.now() - startedAt >= timeoutMs) {
83+ throw error;
84+ }
85+ await new Promise((resolve) => setTimeout(resolve, 10));
86+ }
87+ }
88+}
89+
6890 beforeEach(() => {
6991 sockets.length = 0;
7092
7193 Object.defineProperty(globalThis, "window", {
7294 configurable: true,
7395 value: {
74− location: { hostname: "localhost", port: "3020" },
96+ location: {
97+ origin: "http://localhost:3020",
98+ hostname: "localhost",
99+ port: "3020",
100+ protocol: "http:",
101+ },
75102 desktopBridge: undefined,
76103 },
77104 });
@@ -85,172 +112,332 @@ afterEach(() => { · 7 unmodified lines
85112 });
86113
87114 describe("WsTransport", () => {
88− it("routes valid push envelopes to channel listeners", () => {
115+ it("normalizes root websocket urls to /ws and preserves query params", async () => {
116+ const transport = new WsTransport("ws://localhost:3020/?token=secret-token");
117+
118+ await waitFor(() => {
119+ expect(sockets).toHaveLength(1);
120+ });
121+
122+ expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token");
123+ await transport.dispose();
124+ });
125+
126+ it("uses wss when falling back to an https page origin", async () => {
127+ Object.assign(window.location, {
128+ origin: "https://app.example.com",
129+ hostname: "app.example.com",
130+ port: "",
131+ protocol: "https:",
132+ });
133+
134+ const transport = new WsTransport();
135+
136+ await waitFor(() => {
137+ expect(sockets).toHaveLength(1);
138+ });
139+
140+ expect(getSocket().url).toBe("wss://app.example.com/ws");
141+ await transport.dispose();
142+ });
143+
144+ it("sends unary RPC requests and resolves successful exits", async () => {
89145 const transport = new WsTransport("ws://localhost:3020");
146+
147+ const requestPromise = transport.request((client) =>
148+ client[WS_METHODS.serverUpsertKeybinding]({
149+ command: "terminal.toggle",
150+ key: "ctrl+k",
151+ }),
152+ );
153+
154+ await waitFor(() => {
155+ expect(sockets).toHaveLength(1);
156+ });
157+
90158 const socket = getSocket();
91159 socket.open();
92160
93− const listener = vi.fn();
94− transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener);
161+ await waitFor(() => {
162+ expect(socket.sent).toHaveLength(1);
163+ });
164+
165+ const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as {
166+ _tag: string;
167+ id: string;
168+ payload: unknown;
169+ tag: string;
170+ };
171+ expect(requestMessage).toMatchObject({
172+ _tag: "Request",
173+ tag: WS_METHODS.serverUpsertKeybinding,
174+ payload: {
175+ command: "terminal.toggle",
176+ key: "ctrl+k",
177+ },
178+ });
95179
96180 socket.serverMessage(
97181 JSON.stringify({
98− type: "push",
99− sequence: 1,
100− channel: WS_CHANNELS.serverConfigUpdated,
101− data: { issues: [] },
182+ _tag: "Exit",
183+ requestId: requestMessage.id,
184+ exit: {
185+ _tag: "Success",
186+ value: {
187+ keybindings: [],
188+ issues: [],
189+ },
190+ },
102191 }),
103192 );
104193
105− expect(listener).toHaveBeenCalledTimes(1);
106− expect(listener).toHaveBeenCalledWith({
107− type: "push",
108− sequence: 1,
109− channel: WS_CHANNELS.serverConfigUpdated,
110− data: { issues: [] },
194+ await expect(requestPromise).resolves.toEqual({
195+ keybindings: [],
196+ issues: [],
111197 });
112198
113− transport.dispose();
199+ await transport.dispose();
114200 });
115201
116− it("resolves pending requests for valid response envelopes", async () => {
202+ it("delivers stream chunks to subscribers", async () => {
117203 const transport = new WsTransport("ws://localhost:3020");
204+ const listener = vi.fn();
205+
206+ const unsubscribe = transport.subscribe(
207+ (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
208+ listener,
209+ );
210+ await waitFor(() => {
211+ expect(sockets).toHaveLength(1);
212+ });
213+
118214 const socket = getSocket();
119215 socket.open();
120216
121− const requestPromise = transport.request("projects.list");
122− const sent = socket.sent.at(-1);
123− if (!sent) {
124− throw new Error("Expected request envelope to be sent");
125− }
217+ await waitFor(() => {
218+ expect(socket.sent).toHaveLength(1);
219+ });
220+
221+ const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string };
222+ expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle);
223+
224+ const welcomeEvent = {
225+ version: 1,
226+ sequence: 1,
227+ type: "welcome",
228+ payload: {
229+ cwd: "/tmp/workspace",
230+ projectName: "workspace",
231+ },
232+ };
126233
127− const requestEnvelope = JSON.parse(sent) as { id: string };
128234 socket.serverMessage(
129235 JSON.stringify({
130− id: requestEnvelope.id,
131− result: { projects: [] },
236+ _tag: "Chunk",
237+ requestId: requestMessage.id,
238+ values: [welcomeEvent],
132239 }),
133240 );
134241
135− await expect(requestPromise).resolves.toEqual({ projects: [] });
242+ await waitFor(() => {
243+ expect(listener).toHaveBeenCalledWith(welcomeEvent);
244+ });
136245
137− transport.dispose();
246+ unsubscribe();
247+ await transport.dispose();
138248 });
139249
140− it("drops malformed envelopes without crashing transport", () => {
141− const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
250+ it("re-subscribes stream listeners after the stream exits", async () => {
142251 const transport = new WsTransport("ws://localhost:3020");
252+ const listener = vi.fn();
253+
254+ const unsubscribe = transport.subscribe(
255+ (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
256+ listener,
257+ );
258+ await waitFor(() => {
259+ expect(sockets).toHaveLength(1);
260+ });
261+
143262 const socket = getSocket();
144263 socket.open();
145264
146− const listener = vi.fn();
147− transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener);
265+ await waitFor(() => {
266+ expect(socket.sent).toHaveLength(1);
267+ });
148268
149− socket.serverMessage("{ invalid-json");
269+ const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
150270 socket.serverMessage(
151271 JSON.stringify({
152− type: "push",
153− sequence: 2,
154− channel: 42,
155− data: { bad: true },
272+ _tag: "Chunk",
273+ requestId: firstRequest.id,
274+ values: [
275+ {
276+ version: 1,
277+ sequence: 1,
278+ type: "welcome",
279+ payload: {
280+ cwd: "/tmp/one",
281+ projectName: "one",
282+ },
283+ },
284+ ],
156285 }),
157286 );
158287 socket.serverMessage(
159288 JSON.stringify({
160− type: "push",
161− sequence: 3,
162− channel: WS_CHANNELS.serverConfigUpdated,
163− data: { issues: [] },
289+ _tag: "Exit",
290+ requestId: firstRequest.id,
291+ exit: {
292+ _tag: "Success",
293+ value: null,
294+ },
164295 }),
165296 );
166297
167− expect(listener).toHaveBeenCalledTimes(1);
168− expect(listener).toHaveBeenCalledWith({
169− type: "push",
170− sequence: 3,
171− channel: WS_CHANNELS.serverConfigUpdated,
172− data: { issues: [] },
298+ await waitFor(() => {
299+ const nextRequest = socket.sent
300+ .map((message) => JSON.parse(message) as { _tag?: string; id?: string })
301+ .find((message) => message._tag === "Request" && message.id !== firstRequest.id);
302+ expect(nextRequest).toBeDefined();
173303 });
174− expect(warnSpy).toHaveBeenCalledTimes(2);
175− expect(warnSpy).toHaveBeenNthCalledWith(
176− 1,
177− "Dropped inbound WebSocket envelope",
178− expect.stringMatching(/^SyntaxError:/),
179− );
180− expect(warnSpy).toHaveBeenNthCalledWith(
181− 2,
182− "Dropped inbound WebSocket envelope",
183− expect.stringContaining('Expected "server.configUpdated"'),
184− );
185−
186− transport.dispose();
187− });
188304
189− it("queues requests until the websocket opens", async () => {
190− const transport = new WsTransport("ws://localhost:3020");
191− const socket = getSocket();
192−
193− const requestPromise = transport.request("projects.list");
194− expect(socket.sent).toHaveLength(0);
195−
196− socket.open();
197− expect(socket.sent).toHaveLength(1);
198− const requestEnvelope = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
305+ const secondRequest = socket.sent
306+ .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string })
307+ .find(
308+ (message): message is { _tag: "Request"; id: string; tag: string } =>
309+ message._tag === "Request" && message.id !== firstRequest.id,
310+ );
311+ if (!secondRequest) {
312+ throw new Error("Expected a resubscribe request");
313+ }
314+ expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle);
315+ expect(secondRequest.id).not.toBe(firstRequest.id);
316+
317+ const secondEvent = {
318+ version: 1,
319+ sequence: 2,
320+ type: "welcome",
321+ payload: {
322+ cwd: "/tmp/two",
323+ projectName: "two",
324+ },
325+ };
199326 socket.serverMessage(
200327 JSON.stringify({
201− id: requestEnvelope.id,
202− result: { projects: [] },
328+ _tag: "Chunk",
329+ requestId: secondRequest.id,
330+ values: [secondEvent],
203331 }),
204332 );
205333
206− await expect(requestPromise).resolves.toEqual({ projects: [] });
207− transport.dispose();
334+ await waitFor(() => {
335+ expect(listener).toHaveBeenLastCalledWith(secondEvent);
336+ });
337+
338+ unsubscribe();
339+ await transport.dispose();
208340 });
209341
210− it("does not create a timeout for requests with timeoutMs null", async () => {
211− const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
342+ it("streams finite request events without re-subscribing", async () => {
212343 const transport = new WsTransport("ws://localhost:3020");
344+ const listener = vi.fn();
345+
346+ await waitFor(() => {
347+ expect(sockets).toHaveLength(1);
348+ });
213349 const socket = getSocket();
214350 socket.open();
215351
216− const requestPromise = transport.request(
217− "git.runStackedAction",
218− { cwd: "/repo" },
219− { timeoutMs: null },
352+ const requestPromise = transport.requestStream(
353+ (client) =>
354+ client[WS_METHODS.gitRunStackedAction]({
355+ actionId: "action-1",
356+ cwd: "/repo",
357+ action: "commit",
358+ }),
359+ listener,
220360 );
221− const sent = socket.sent.at(-1);
222− if (!sent) {
223− throw new Error("Expected request envelope to be sent");
224− }
225− const requestEnvelope = JSON.parse(sent) as { id: string };
361+
362+ await waitFor(() => {
363+ expect(socket.sent).toHaveLength(1);
364+ });
365+
366+ const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
367+ const progressEvent = {
368+ actionId: "action-1",
369+ cwd: "/repo",
370+ action: "commit",
371+ kind: "phase_started",
372+ phase: "commit",
373+ label: "Committing...",
374+ } as const;
226375
227376 socket.serverMessage(
228377 JSON.stringify({
229− id: requestEnvelope.id,
230− result: { ok: true },
378+ _tag: "Chunk",
379+ requestId: requestMessage.id,
380+ values: [progressEvent],
381+ }),
382+ );
383+ socket.serverMessage(
384+ JSON.stringify({
385+ _tag: "Exit",
386+ requestId: requestMessage.id,
387+ exit: {
388+ _tag: "Success",
389+ value: null,
390+ },
231391 }),
232392 );
233393
234− await expect(requestPromise).resolves.toEqual({ ok: true });
235− expect(timeoutSpy.mock.calls.some(([callback]) => typeof callback === "function")).toBe(false);
236−
237− transport.dispose();
394+ await expect(requestPromise).resolves.toBeUndefined();
395+ expect(listener).toHaveBeenCalledWith(progressEvent);
396+ expect(
397+ socket.sent.filter((message) => {
398+ const parsed = JSON.parse(message) as { _tag?: string; tag?: string };
399+ return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction;
400+ }),
401+ ).toHaveLength(1);
402+ await transport.dispose();
238403 });
239404
240− it("rejects pending requests when the websocket closes", async () => {
241− const transport = new WsTransport("ws://localhost:3020");
242− const socket = getSocket();
243− socket.open();
405+ it("closes the client scope on the transport runtime before disposing the runtime", async () => {
406+ const callOrder: string[] = [];
407+ let resolveClose!: () => void;
408+ const closePromise = new Promise<void>((resolve) => {
409+ resolveClose = resolve;
410+ });
244411
245− const requestPromise = transport.request(
246− "git.runStackedAction",
247− { cwd: "/repo" },
248− { timeoutMs: null },
249− );
412+ const runtime = {
413+ runPromise: vi.fn(async () => {
414+ callOrder.push("close:start");
415+ await closePromise;
416+ callOrder.push("close:done");
417+ return undefined;
418+ }),
419+ dispose: vi.fn(async () => {
420+ callOrder.push("runtime:dispose");
421+ }),
422+ };
423+ const transport = {
424+ disposed: false,
425+ clientScope: {} as never,
426+ runtime,
427+ } as unknown as WsTransport;
428+
429+ WsTransport.prototype.dispose.call(transport);
430+
431+ expect(runtime.runPromise).toHaveBeenCalledTimes(1);
432+ expect(runtime.dispose).not.toHaveBeenCalled();
433+ expect((transport as unknown as { disposed: boolean }).disposed).toBe(true);
250434
251− socket.close();
435+ resolveClose();
436+
437+ await waitFor(() => {
438+ expect(runtime.dispose).toHaveBeenCalledTimes(1);
439+ });
252440
253− await expect(requestPromise).rejects.toThrow("WebSocket connection closed.");
254− transport.dispose();
441+ expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]);
255442 });
256443 });
apps/web/src/wsTransport.ts+93−271
@@ -1,309 +1,131 @@
1−import {
2− type WsPush,
3− type WsPushChannel,
4− type WsPushMessage,
5− WebSocketResponse,
6− type WsResponse as WsResponseMessage,
7− WsResponse as WsResponseSchema,
8−} from "@t3tools/contracts";
9−import { decodeUnknownJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
10−import { Result, Schema } from "effect";
11−
12−type PushListener<C extends WsPushChannel> = (message: WsPushMessage<C>) => void;
1+import { Duration, Effect, Exit, ManagedRuntime, Option, Scope, Stream } from "effect";
132
14−interface PendingRequest {
15− resolve: (result: unknown) => void;
16− reject: (error: Error) => void;
17− timeout: ReturnType<typeof setTimeout> | null;
18−}
3+import {
4+ createWsRpcProtocolLayer,
5+ makeWsRpcProtocolClient,
6+ type WsRpcProtocolClient,
7+} from "./rpc/protocol";
8+import { RpcClient } from "effect/unstable/rpc";
199
2010 interface SubscribeOptions {
21− readonly replayLatest?: boolean;
11+ readonly retryDelay?: Duration.Input;
2212 }
2313
2414 interface RequestOptions {
25− readonly timeoutMs?: number | null;
15+ readonly timeout?: Option.Option<Duration.Input>;
2616 }
2717
28−type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed";
29−
30−const REQUEST_TIMEOUT_MS = 60_000;
31−const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
32−const decodeWsResponse = decodeUnknownJsonResult(WsResponseSchema);
33−const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse);
34−
35−const isWsPushMessage = (value: WsResponseMessage): value is WsPush =>
36− "type" in value && value.type === "push";
18+const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250);
3719
38−interface WsRequestEnvelope {
39− id: string;
40− body: {
41− _tag: string;
42− [key: string]: unknown;
43− };
44−}
45−
46−function asError(value: unknown, fallback: string): Error {
47− if (value instanceof Error) {
48− return value;
20+function formatErrorMessage(error: unknown): string {
21+ if (error instanceof Error && error.message.trim().length > 0) {
22+ return error.message;
4923 }
50− return new Error(fallback);
24+ return String(error);
5125 }
5226
5327 export class WsTransport {
54− private ws: WebSocket | null = null;
55− private nextId = 1;
56− private readonly pending = new Map<string, PendingRequest>();
57− private readonly listeners = new Map<string, Set<(message: WsPush) => void>>();
58− private readonly latestPushByChannel = new Map<string, WsPush>();
59− private readonly outboundQueue: string[] = [];
60− private reconnectAttempt = 0;
61− private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
28+ private readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>;
29+ private readonly clientScope: Scope.Closeable;
30+ private readonly clientPromise: Promise<WsRpcProtocolClient>;
6231 private disposed = false;
63− private state: TransportState = "connecting";
64− private readonly url: string;
6532
6633 constructor(url?: string) {
67− const bridgeUrl = window.desktopBridge?.getWsUrl();
68− const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
69− this.url =
70− url ??
71− (bridgeUrl && bridgeUrl.length > 0
72− ? bridgeUrl
73− : envUrl && envUrl.length > 0
74− ? envUrl
75− : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`);
76− this.connect();
34+ this.runtime = ManagedRuntime.make(createWsRpcProtocolLayer(url));
35+ this.clientScope = this.runtime.runSync(Scope.make());
36+ this.clientPromise = this.runtime.runPromise(
37+ Scope.provide(this.clientScope)(makeWsRpcProtocolClient),
38+ );
7739 }
7840
79− async request<T = unknown>(
80− method: string,
81− params?: unknown,
82− options?: RequestOptions,
83− ): Promise<T> {
84− if (typeof method !== "string" || method.length === 0) {
85− throw new Error("Request method is required");
86− }
87−
88− const id = String(this.nextId++);
89− const body = params != null ? { ...params, _tag: method } : { _tag: method };
90− const message: WsRequestEnvelope = { id, body };
91− const encoded = JSON.stringify(message);
92−
93− return new Promise<T>((resolve, reject) => {
94− const timeoutMs = options?.timeoutMs === undefined ? REQUEST_TIMEOUT_MS : options.timeoutMs;
95− const timeout =
96− timeoutMs === null
97− ? null
98− : setTimeout(() => {
99− this.pending.delete(id);
100− reject(new Error(`Request timed out: ${method}`));
101− }, timeoutMs);
102−
103− this.pending.set(id, {
104− resolve: resolve as (result: unknown) => void,
105− reject,
106− timeout,
107− });
108−
109− this.send(encoded);
110− });
111− }
112−
113− subscribe<C extends WsPushChannel>(
114− channel: C,
115− listener: PushListener<C>,
116− options?: SubscribeOptions,
117− ): () => void {
118− let channelListeners = this.listeners.get(channel);
119− if (!channelListeners) {
120− channelListeners = new Set<(message: WsPush) => void>();
121− this.listeners.set(channel, channelListeners);
122− }
123−
124− const wrappedListener = (message: WsPush) => {
125− listener(message as WsPushMessage<C>);
126− };
127− channelListeners.add(wrappedListener);
128−
129− if (options?.replayLatest) {
130− const latest = this.latestPushByChannel.get(channel);
131− if (latest) {
132− wrappedListener(latest);
133− }
134− }
135−
136− return () => {
137− channelListeners?.delete(wrappedListener);
138− if (channelListeners?.size === 0) {
139− this.listeners.delete(channel);
140− }
141− };
142− }
143−
144− getLatestPush<C extends WsPushChannel>(channel: C): WsPushMessage<C> | null {
145− const latest = this.latestPushByChannel.get(channel);
146− return latest ? (latest as WsPushMessage<C>) : null;
147− }
148−
149− getState(): TransportState {
150− return this.state;
151− }
152−
153− dispose() {
154− this.disposed = true;
155− this.state = "disposed";
156− if (this.reconnectTimer !== null) {
157− clearTimeout(this.reconnectTimer);
158− this.reconnectTimer = null;
159− }
160− for (const pending of this.pending.values()) {
161− if (pending.timeout !== null) {
162− clearTimeout(pending.timeout);
163− }
164− pending.reject(new Error("Transport disposed"));
165− }
166− this.pending.clear();
167− this.outboundQueue.length = 0;
168− this.ws?.close();
169− this.ws = null;
170− }
171−
172− private connect() {
41+ async request<TSuccess>(
42+ execute: (client: WsRpcProtocolClient) => Effect.Effect<TSuccess, Error, never>,
43+ _options?: RequestOptions,
44+ ): Promise<TSuccess> {
17345 if (this.disposed) {
174− return;
46+ throw new Error("Transport disposed");
17547 }
17648
177− this.state = this.reconnectAttempt > 0 ? "reconnecting" : "connecting";
178− const ws = new WebSocket(this.url);
179−
180− ws.addEventListener("open", () => {
181− this.ws = ws;
182− this.state = "open";
183− this.reconnectAttempt = 0;
184− this.flushQueue();
185− });
186−
187− ws.addEventListener("message", (event) => {
188− this.handleMessage(event.data);
189− });
190−
191− ws.addEventListener("close", () => {
192− if (this.ws === ws) {
193− this.ws = null;
194− this.outboundQueue.length = 0;
195− for (const [id, pending] of this.pending.entries()) {
196− if (pending.timeout !== null) {
197− clearTimeout(pending.timeout);
198− }
199− this.pending.delete(id);
200− pending.reject(new Error("WebSocket connection closed."));
201− }
202− }
203− if (this.disposed) {
204− this.state = "disposed";
205− return;
206− }
207− this.state = "closed";
208− this.scheduleReconnect();
209− });
210−
211− ws.addEventListener("error", (event) => {
212− // Log WebSocket errors for debugging (close event will follow)
213− console.warn("WebSocket connection error", { type: event.type, url: this.url });
214− });
49+ const client = await this.clientPromise;
50+ return await this.runtime.runPromise(Effect.suspend(() => execute(client)));
21551 }
21652
217− private handleMessage(raw: unknown) {
218− const result = decodeWsResponse(raw);
219− if (Result.isFailure(result)) {
220− console.warn("Dropped inbound WebSocket envelope", formatSchemaError(result.failure));
221− return;
53+ async requestStream<TValue>(
54+ connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,
55+ listener: (value: TValue) => void,
56+ ): Promise<void> {
57+ if (this.disposed) {
58+ throw new Error("Transport disposed");
22259 }
22360
224− const message = result.success;
225− if (isWsPushMessage(message)) {
226− this.latestPushByChannel.set(message.channel, message);
227− const channelListeners = this.listeners.get(message.channel);
228− if (channelListeners) {
229− for (const listener of channelListeners) {
61+ const client = await this.clientPromise;
62+ await this.runtime.runPromise(
63+ Stream.runForEach(connect(client), (value) =>
64+ Effect.sync(() => {
23065 try {
231− listener(message);
66+ listener(value);
23267 } catch {
233− // Swallow listener errors
68+ // Swallow listener errors so the stream can finish cleanly.
23469 }
235− }
236− }
237− return;
238− }
239−
240− if (!isWebSocketResponseEnvelope(message)) {
241− return;
242− }
243−
244− const pending = this.pending.get(message.id);
245− if (!pending) {
246− return;
247− }
248−
249− if (pending.timeout !== null) {
250− clearTimeout(pending.timeout);
251− }
252− this.pending.delete(message.id);
253−
254− if (message.error) {
255− pending.reject(new Error(message.error.message));
256− return;
257− }
258−
259− pending.resolve(message.result);
70+ }),
71+ ),
72+ );
26073 }
26174
262− private send(encodedMessage: string) {
75+ subscribe<TValue>(
76+ connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,
77+ listener: (value: TValue) => void,
78+ options?: SubscribeOptions,
79+ ): () => void {
26380 if (this.disposed) {
264− return;
265− }
266−
267− this.outboundQueue.push(encodedMessage);
268− try {
269− this.flushQueue();
270− } catch {
271− // Swallow: flushQueue has queued the message for retry on reconnect
272− }
273− }
274−
275− private flushQueue() {
276− if (this.ws?.readyState !== WebSocket.OPEN) {
277− return;
278− }
81+ return () => undefined;
82+ }
83+
84+ let active = true;
85+ const retryDelayMs = options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS;
86+ const cancel = this.runtime.runCallback(
87+ Effect.promise(() => this.clientPromise).pipe(
88+ Effect.flatMap((client) =>
89+ Stream.runForEach(connect(client), (value) =>
90+ Effect.sync(() => {
91+ if (!active) {
92+ return;
93+ }
94+ try {
95+ listener(value);
96+ } catch {
97+ // Swallow listener errors so the stream stays live.
98+ }
99+ }),
100+ ),
101+ ),
102+ Effect.catch((error) => {
103+ if (!active || this.disposed) {
104+ return Effect.interrupt;
105+ }
106+ return Effect.sync(() => {
107+ console.warn("WebSocket RPC subscription disconnected", {
108+ error: formatErrorMessage(error),
109+ });
110+ }).pipe(Effect.andThen(Effect.sleep(retryDelayMs)));
111+ }),
112+ Effect.forever,
113+ ),
114+ );
279115
280− while (this.outboundQueue.length > 0) {
281− const message = this.outboundQueue.shift();
282− if (!message) {
283− continue;
284− }
285− try {
286− this.ws.send(message);
287− } catch (error) {
288− this.outboundQueue.unshift(message);
289− throw asError(error, "Failed to send WebSocket request.");
290− }
291− }
116+ return () => {
117+ active = false;
118+ cancel();
119+ };
292120 }
293121
294− private scheduleReconnect() {
295− if (this.disposed || this.reconnectTimer !== null) {
122+ async dispose() {
123+ if (this.disposed) {
296124 return;
297125 }
298−
299− const delay =
300− RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ??
301− RECONNECT_DELAYS_MS[0]!;
302−
303− this.reconnectAttempt += 1;
304− this.reconnectTimer = setTimeout(() => {
305− this.reconnectTimer = null;
306− this.connect();
307− }, delay);
126+ this.disposed = true;
127+ await this.runtime.runPromise(Scope.close(this.clientScope, Exit.void)).finally(() => {
128+ this.runtime.dispose();
129+ });
308130 }
309131 }
apps/web/test/wsRpcHarness.ts+169−0
@@ -0,0 +1,169 @@
1+import { Effect, Exit, PubSub, Scope, Stream } from "effect";
2+import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts";
3+import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
4+
5+type RpcServerInstance = RpcServer.RpcServer<any>;
6+
7+type BrowserWsClient = {
8+ send: (data: string) => void;
9+};
10+
11+export type NormalizedWsRpcRequestBody = {
12+ _tag: string;
13+ [key: string]: unknown;
14+};
15+
16+type UnaryResolverResult = unknown | Promise<unknown>;
17+
18+interface BrowserWsRpcHarnessOptions {
19+ readonly resolveUnary?: (request: NormalizedWsRpcRequestBody) => UnaryResolverResult;
20+ readonly getInitialStreamValues?: (
21+ request: NormalizedWsRpcRequestBody,
22+ ) => ReadonlyArray<unknown> | undefined;
23+}
24+
25+const STREAM_METHODS = new Set<string>([
26+ WS_METHODS.gitRunStackedAction,
27+ WS_METHODS.subscribeOrchestrationDomainEvents,
28+ WS_METHODS.subscribeTerminalEvents,
29+ WS_METHODS.subscribeServerConfig,
30+ WS_METHODS.subscribeServerLifecycle,
31+]);
32+
33+const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys());
34+
35+function normalizeRequest(tag: string, payload: unknown): NormalizedWsRpcRequestBody {
36+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
37+ return {
38+ _tag: tag,
39+ ...(payload as Record<string, unknown>),
40+ };
41+ }
42+ return { _tag: tag, payload };
43+}
44+
45+function asEffect(result: UnaryResolverResult): Effect.Effect<unknown> {
46+ if (result instanceof Promise) {
47+ return Effect.promise(() => result);
48+ }
49+ return Effect.succeed(result);
50+}
51+
52+export class BrowserWsRpcHarness {
53+ readonly requests: Array<NormalizedWsRpcRequestBody> = [];
54+
55+ private readonly parser = RpcSerialization.json.makeUnsafe();
56+ private client: BrowserWsClient | null = null;
57+ private scope: Scope.Closeable | null = null;
58+ private serverReady: Promise<RpcServerInstance> | null = null;
59+ private resolveUnary: NonNullable<BrowserWsRpcHarnessOptions["resolveUnary"]> = () => ({});
60+ private getInitialStreamValues: NonNullable<
61+ BrowserWsRpcHarnessOptions["getInitialStreamValues"]
62+ > = () => [];
63+ private streamPubSubs = new Map<string, PubSub.PubSub<unknown>>();
64+
65+ async reset(options?: BrowserWsRpcHarnessOptions): Promise<void> {
66+ await this.disconnect();
67+ this.requests.length = 0;
68+ this.resolveUnary = options?.resolveUnary ?? (() => ({}));
69+ this.getInitialStreamValues = options?.getInitialStreamValues ?? (() => []);
70+ this.initializeStreamPubSubs();
71+ }
72+
73+ connect(client: BrowserWsClient): void {
74+ if (this.scope) {
75+ void Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined);
76+ }
77+ if (this.streamPubSubs.size === 0) {
78+ this.initializeStreamPubSubs();
79+ }
80+ this.client = client;
81+ this.scope = Effect.runSync(Scope.make());
82+ this.serverReady = Effect.runPromise(
83+ Scope.provide(this.scope)(
84+ RpcServer.makeNoSerialization(WsRpcGroup, this.makeServerOptions()),
85+ ).pipe(Effect.provide(this.makeLayer())),
86+ ) as Promise<RpcServerInstance>;
87+ }
88+
89+ async disconnect(): Promise<void> {
90+ if (this.scope) {
91+ await Effect.runPromise(Scope.close(this.scope, Exit.void)).catch(() => undefined);
92+ this.scope = null;
93+ }
94+ for (const pubsub of this.streamPubSubs.values()) {
95+ Effect.runSync(PubSub.shutdown(pubsub));
96+ }
97+ this.streamPubSubs.clear();
98+ this.serverReady = null;
99+ this.client = null;
100+ }
101+
102+ private initializeStreamPubSubs(): void {
103+ this.streamPubSubs = new Map(
104+ Array.from(STREAM_METHODS, (method) => [method, Effect.runSync(PubSub.unbounded<unknown>())]),
105+ );
106+ }
107+
108+ async onMessage(rawData: string): Promise<void> {
109+ const server = await this.serverReady;
110+ if (!server) {
111+ throw new Error("RPC test server is not connected");
112+ }
113+ const messages = this.parser.decode(rawData);
114+ for (const message of messages) {
115+ await Effect.runPromise(server.write(0, message as never));
116+ }
117+ }
118+
119+ emitStreamValue(method: string, value: unknown): void {
120+ const pubsub = this.streamPubSubs.get(method);
121+ if (!pubsub) {
122+ throw new Error(`No stream registered for ${method}`);
123+ }
124+ Effect.runSync(PubSub.publish(pubsub, value));
125+ }
126+
127+ private makeLayer() {
128+ const handlers: Record<string, (payload: unknown) => unknown> = {};
129+ for (const method of ALL_RPC_METHODS) {
130+ handlers[method] = STREAM_METHODS.has(method)
131+ ? (payload) => this.handleStream(method, payload)
132+ : (payload) => this.handleUnary(method, payload);
133+ }
134+ return WsRpcGroup.toLayer(handlers as never);
135+ }
136+
137+ private makeServerOptions() {
138+ return {
139+ onFromServer: (response: unknown) =>
140+ Effect.sync(() => {
141+ if (!this.client) {
142+ return;
143+ }
144+ const encoded = this.parser.encode(response);
145+ if (typeof encoded === "string") {
146+ this.client.send(encoded);
147+ }
148+ }),
149+ };
150+ }
151+
152+ private handleUnary(method: string, payload: unknown) {
153+ const request = normalizeRequest(method, payload);
154+ this.requests.push(request);
155+ return asEffect(this.resolveUnary(request));
156+ }
157+
158+ private handleStream(method: string, payload: unknown) {
159+ const request = normalizeRequest(method, payload);
160+ this.requests.push(request);
161+ const pubsub = this.streamPubSubs.get(method);
162+ if (!pubsub) {
163+ throw new Error(`No stream registered for ${method}`);
164+ }
165+ return Stream.fromIterable(this.getInitialStreamValues(request) ?? []).pipe(
166+ Stream.concat(Stream.fromPubSub(pubsub)),
167+ );
168+ }
169+}
apps/web/tsconfig.json+1−1
@@ -21,5 +21,5 @@ · 20 unmodified lines
2121 }
2222 ]
2323 },
24− "include": ["src", "vite.config.ts"]
24+ "include": ["src", "vite.config.ts", "test"]
2525 }
bun.lock+9−3
@@ -44,17 +44,17 @@ · 43 unmodified lines
4444 "name": "t3",
4545 "version": "0.0.15",
4646 "bin": {
47− "t3": "./dist/index.mjs",
47+ "t3": "./dist/bin.mjs",
4848 },
4949 "dependencies": {
5050 "@anthropic-ai/claude-agent-sdk": "^0.2.77",
51+ "@effect/platform-bun": "catalog:",
5152 "@effect/platform-node": "catalog:",
5253 "@effect/sql-sqlite-bun": "catalog:",
5354 "@pierre/diffs": "^1.1.0-beta.16",
5455 "effect": "catalog:",
5556 "node-pty": "^1.1.0",
5657 "open": "^10.1.0",
57− "ws": "^8.18.0",
5858 },
5959 "devDependencies": {
6060 "@effect/language-service": "catalog:",
@@ -64,7 +64,6 @@ · 3 unmodified lines
6464 "@t3tools/web": "workspace:*",
6565 "@types/bun": "catalog:",
6666 "@types/node": "catalog:",
67− "@types/ws": "^8.5.13",
6867 "tsdown": "catalog:",
6968 "typescript": "catalog:",
7069 "vitest": "catalog:",
@@ -79,6 +78,7 @@ · 8 unmodified lines
7978 "@dnd-kit/modifiers": "^9.0.0",
8079 "@dnd-kit/sortable": "^10.0.0",
8180 "@dnd-kit/utilities": "^3.2.2",
81+ "@effect/atom-react": "catalog:",
8282 "@formkit/auto-animate": "^0.9.0",
8383 "@lexical/react": "^0.41.0",
8484 "@pierre/diffs": "^1.1.0-beta.16",
@@ -175,7 +175,9 @@ · 90 unmodified lines
175175 "vite": "^8.0.0",
176176 },
177177 "catalog": {
178+ "@effect/atom-react": "4.0.0-beta.43",
178179 "@effect/language-service": "0.84.2",
180+ "@effect/platform-bun": "4.0.0-beta.43",
179181 "@effect/platform-node": "4.0.0-beta.43",
180182 "@effect/sql-sqlite-bun": "4.0.0-beta.43",
181183 "@effect/vitest": "4.0.0-beta.43",
@@ -267,8 +269,12 @@ · 85 unmodified lines
267269
268270 "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
269271
272+ "@effect/atom-react": ["@effect/atom-react@4.0.0-beta.43", "", { "peerDependencies": { "effect": "^4.0.0-beta.43", "react": "^19.2.4", "scheduler": "*" } }, "sha512-xSrRbGXuo4d0g4ph66TQST1GNSjtQZrZj8V7OiAQFuzMYcZ0kwRIPUUFwOBtbWHK43/zNENdNWOnlXh/iYM1dw=="],
273+
270274 "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],
271275
276+ "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-nMZ9JsD6CzJNQ+5pDUFbPw7PSZdQdTQ092MbYrocVtvlf6qEFU/hji3ITvRIOX7eabyQ8AUyp55qFPQUeq+GIA=="],
277+
272278 "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="],
273279
274280 "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="],
package.json+2−0
@@ -9,6 +9,8 @@ · 8 unmodified lines
99 ],
1010 "catalog": {
1111 "effect": "4.0.0-beta.43",
12+ "@effect/atom-react": "4.0.0-beta.43",
13+ "@effect/platform-bun": "4.0.0-beta.43",
1214 "@effect/platform-node": "4.0.0-beta.43",
1315 "@effect/sql-sqlite-bun": "4.0.0-beta.43",
1416 "@effect/vitest": "4.0.0-beta.43",
packages/contracts/src/editor.ts+5−0
@@ -24,3 +24,8 @@ export const OpenInEditorInput = Schema.Struct({ · 23 unmodified lines
2424 editor: EditorId,
2525 });
2626 export type OpenInEditorInput = typeof OpenInEditorInput.Type;
27+
28+export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
29+ message: Schema.String,
30+ cause: Schema.optional(Schema.Defect),
31+}) {}
packages/contracts/src/git.ts+54−0
@@ -223,6 +223,60 @@ export const GitPullResult = Schema.Struct({ · 222 unmodified lines
223223 });
224224 export type GitPullResult = typeof GitPullResult.Type;
225225
226+// RPC / domain errors
227+export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()("GitCommandError", {
228+ operation: Schema.String,
229+ command: Schema.String,
230+ cwd: Schema.String,
231+ detail: Schema.String,
232+ cause: Schema.optional(Schema.Defect),
233+}) {
234+ override get message(): string {
235+ return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`;
236+ }
237+}
238+
239+export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", {
240+ operation: Schema.String,
241+ detail: Schema.String,
242+ cause: Schema.optional(Schema.Defect),
243+}) {
244+ override get message(): string {
245+ return `GitHub CLI failed in ${this.operation}: ${this.detail}`;
246+ }
247+}
248+
249+export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationError>()(
250+ "TextGenerationError",
251+ {
252+ operation: Schema.String,
253+ detail: Schema.String,
254+ cause: Schema.optional(Schema.Defect),
255+ },
256+) {
257+ override get message(): string {
258+ return `Text generation failed in ${this.operation}: ${this.detail}`;
259+ }
260+}
261+
262+export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", {
263+ operation: Schema.String,
264+ detail: Schema.String,
265+ cause: Schema.optional(Schema.Defect),
266+}) {
267+ override get message(): string {
268+ return `Git manager failed in ${this.operation}: ${this.detail}`;
269+ }
270+}
271+
272+export const GitManagerServiceError = Schema.Union([
273+ GitManagerError,
274+ GitCommandError,
275+ GitHubCliError,
276+ TextGenerationError,
277+]);
278+export type GitManagerServiceError = typeof GitManagerServiceError.Type;
279+
226280 const GitActionProgressBase = Schema.Struct({
227281 actionId: TrimmedNonEmptyStringSchema,
228282 cwd: TrimmedNonEmptyStringSchema,
packages/contracts/src/index.ts+1−1
@@ -4,7 +4,6 @@ export * from "./terminal"; · 3 unmodified lines
44 export * from "./provider";
55 export * from "./providerRuntime";
66 export * from "./model";
7−export * from "./ws";
87 export * from "./keybindings";
98 export * from "./server";
109 export * from "./settings";
@@ -12,3 +11,4 @@ export * from "./git"; · 1 unmodified line
1211 export * from "./orchestration";
1312 export * from "./editor";
1413 export * from "./project";
14+export * from "./rpc";
packages/contracts/src/ipc.ts+0−5
@@ -1,6 +1,5 @@
11 import type {
22 GitCheckoutInput,
3− GitActionProgressEvent,
43 GitCreateBranchInput,
54 GitPreparePullRequestThreadInput,
65 GitPreparePullRequestThreadResult,
@@ -14,8 +13,6 @@ import type { · 7 unmodified lines
1413 GitPullResult,
1514 GitRemoveWorktreeInput,
1615 GitResolvePullRequestResult,
17− GitRunStackedActionInput,
18− GitRunStackedActionResult,
1916 GitStatusInput,
2017 GitStatusResult,
2118 } from "./git";
@@ -161,8 +158,6 @@ export interface NativeApi { · 139 unmodified lines
161158 // Stacked action API
162159 pull: (input: GitPullInput) => Promise<GitPullResult>;
163160 status: (input: GitStatusInput) => Promise<GitStatusResult>;
164− runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>;
165− onActionProgress: (callback: (event: GitActionProgressEvent) => void) => () => void;
166161 };
167162 contextMenu: {
168163 show: <T extends string>(
packages/contracts/src/keybindings.ts+22−6
@@ -85,24 +85,27 @@ export const KeybindingShortcut = Schema.Struct({ · 84 unmodified lines
8585 });
8686 export type KeybindingShortcut = typeof KeybindingShortcut.Type;
8787
88−export const KeybindingWhenNode: Schema.Schema<KeybindingWhenNode> = Schema.Union([
88+const KeybindingWhenNodeRef = Schema.suspend(
89+ (): Schema.Codec<KeybindingWhenNode> => KeybindingWhenNode,
90+);
91+export const KeybindingWhenNode = Schema.Union([
8992 Schema.Struct({
9093 type: Schema.Literal("identifier"),
9194 name: Schema.NonEmptyString,
9295 }),
9396 Schema.Struct({
9497 type: Schema.Literal("not"),
95− node: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),
98+ node: KeybindingWhenNodeRef,
9699 }),
97100 Schema.Struct({
98101 type: Schema.Literal("and"),
99− left: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),
100− right: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),
102+ left: KeybindingWhenNodeRef,
103+ right: KeybindingWhenNodeRef,
101104 }),
102105 Schema.Struct({
103106 type: Schema.Literal("or"),
104− left: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),
105− right: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),
107+ left: KeybindingWhenNodeRef,
108+ right: KeybindingWhenNodeRef,
106109 }),
107110 ]);
108111 export type KeybindingWhenNode =
@@ -122,3 +125,16 @@ export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).ch · 13 unmodified lines
122125 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
123126 );
124127 export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type;
128+
129+export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()(
130+ "KeybindingsConfigParseError",
131+ {
132+ configPath: Schema.String,
133+ detail: Schema.String,
134+ cause: Schema.optional(Schema.Defect),
135+ },
136+) {
137+ override get message(): string {
138+ return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`;
139+ }
140+}
packages/contracts/src/orchestration.ts+40−4
@@ -23,10 +23,6 @@ export const ORCHESTRATION_WS_METHODS = { · 22 unmodified lines
2323 replayEvents: "orchestration.replayEvents",
2424 } as const;
2525
26−export const ORCHESTRATION_WS_CHANNELS = {
27− domainEvent: "orchestration.domainEvent",
28−} as const;
29−
3026 export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]);
3127 export type ProviderKind = typeof ProviderKind.Type;
3228 export const ProviderApprovalPolicy = Schema.Literals([
@@ -1037,3 +1033,43 @@ export const OrchestrationRpcSchemas = { · 1004 unmodified lines
10371033 output: OrchestrationReplayEventsResult,
10381034 },
10391035 } as const;
1036+
1037+export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass<OrchestrationGetSnapshotError>()(
1038+ "OrchestrationGetSnapshotError",
1039+ {
1040+ message: TrimmedNonEmptyString,
1041+ cause: Schema.optional(Schema.Defect),
1042+ },
1043+) {}
1044+
1045+export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass<OrchestrationDispatchCommandError>()(
1046+ "OrchestrationDispatchCommandError",
1047+ {
1048+ message: TrimmedNonEmptyString,
1049+ cause: Schema.optional(Schema.Defect),
1050+ },
1051+) {}
1052+
1053+export class OrchestrationGetTurnDiffError extends Schema.TaggedErrorClass<OrchestrationGetTurnDiffError>()(
1054+ "OrchestrationGetTurnDiffError",
1055+ {
1056+ message: TrimmedNonEmptyString,
1057+ cause: Schema.optional(Schema.Defect),
1058+ },
1059+) {}
1060+
1061+export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass<OrchestrationGetFullThreadDiffError>()(
1062+ "OrchestrationGetFullThreadDiffError",
1063+ {
1064+ message: TrimmedNonEmptyString,
1065+ cause: Schema.optional(Schema.Defect),
1066+ },
1067+) {}
1068+
1069+export class OrchestrationReplayEventsError extends Schema.TaggedErrorClass<OrchestrationReplayEventsError>()(
1070+ "OrchestrationReplayEventsError",
1071+ {
1072+ message: TrimmedNonEmptyString,
1073+ cause: Schema.optional(Schema.Defect),
1074+ },
1075+) {}
packages/contracts/src/project.ts+16−0
@@ -26,6 +26,14 @@ export const ProjectSearchEntriesResult = Schema.Struct({ · 25 unmodified lines
2626 });
2727 export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;
2828
29+export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSearchEntriesError>()(
30+ "ProjectSearchEntriesError",
31+ {
32+ message: TrimmedNonEmptyString,
33+ cause: Schema.optional(Schema.Defect),
34+ },
35+) {}
36+
2937 export const ProjectWriteFileInput = Schema.Struct({
3038 cwd: TrimmedNonEmptyString,
3139 relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)),
@@ -37,3 +45,11 @@ export const ProjectWriteFileResult = Schema.Struct({ · 5 unmodified lines
3745 relativePath: TrimmedNonEmptyString,
3846 });
3947 export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type;
48+
49+export class ProjectWriteFileError extends Schema.TaggedErrorClass<ProjectWriteFileError>()(
50+ "ProjectWriteFileError",
51+ {
52+ message: TrimmedNonEmptyString,
53+ cause: Schema.optional(Schema.Defect),
54+ },
55+) {}
packages/contracts/src/rpc.ts+359−0
@@ -0,0 +1,359 @@
1+import { Schema } from "effect";
2+import * as Rpc from "effect/unstable/rpc/Rpc";
3+import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
4+
5+import { OpenError, OpenInEditorInput } from "./editor";
6+import {
7+ GitActionProgressEvent,
8+ GitCheckoutInput,
9+ GitCommandError,
10+ GitCreateBranchInput,
11+ GitCreateWorktreeInput,
12+ GitCreateWorktreeResult,
13+ GitInitInput,
14+ GitListBranchesInput,
15+ GitListBranchesResult,
16+ GitManagerServiceError,
17+ GitPreparePullRequestThreadInput,
18+ GitPreparePullRequestThreadResult,
19+ GitPullInput,
20+ GitPullRequestRefInput,
21+ GitPullResult,
22+ GitRemoveWorktreeInput,
23+ GitResolvePullRequestResult,
24+ GitRunStackedActionInput,
25+ GitStatusInput,
26+ GitStatusResult,
27+} from "./git";
28+import { KeybindingsConfigError } from "./keybindings";
29+import {
30+ ClientOrchestrationCommand,
31+ OrchestrationEvent,
32+ ORCHESTRATION_WS_METHODS,
33+ OrchestrationDispatchCommandError,
34+ OrchestrationGetFullThreadDiffError,
35+ OrchestrationGetFullThreadDiffInput,
36+ OrchestrationGetSnapshotError,
37+ OrchestrationGetSnapshotInput,
38+ OrchestrationGetTurnDiffError,
39+ OrchestrationGetTurnDiffInput,
40+ OrchestrationReplayEventsError,
41+ OrchestrationReplayEventsInput,
42+ OrchestrationRpcSchemas,
43+} from "./orchestration";
44+import {
45+ ProjectSearchEntriesError,
46+ ProjectSearchEntriesInput,
47+ ProjectSearchEntriesResult,
48+ ProjectWriteFileError,
49+ ProjectWriteFileInput,
50+ ProjectWriteFileResult,
51+} from "./project";
52+import {
53+ TerminalClearInput,
54+ TerminalCloseInput,
55+ TerminalError,
56+ TerminalEvent,
57+ TerminalOpenInput,
58+ TerminalResizeInput,
59+ TerminalRestartInput,
60+ TerminalSessionSnapshot,
61+ TerminalWriteInput,
62+} from "./terminal";
63+import {
64+ ServerConfigStreamEvent,
65+ ServerConfig,
66+ ServerLifecycleStreamEvent,
67+ ServerProviderUpdatedPayload,
68+ ServerUpsertKeybindingInput,
69+ ServerUpsertKeybindingResult,
70+} from "./server";
71+import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings";
72+
73+export const WS_METHODS = {
74+ // Project registry methods
75+ projectsList: "projects.list",
76+ projectsAdd: "projects.add",
77+ projectsRemove: "projects.remove",
78+ projectsSearchEntries: "projects.searchEntries",
79+ projectsWriteFile: "projects.writeFile",
80+
81+ // Shell methods
82+ shellOpenInEditor: "shell.openInEditor",
83+
84+ // Git methods
85+ gitPull: "git.pull",
86+ gitStatus: "git.status",
87+ gitRunStackedAction: "git.runStackedAction",
88+ gitListBranches: "git.listBranches",
89+ gitCreateWorktree: "git.createWorktree",
90+ gitRemoveWorktree: "git.removeWorktree",
91+ gitCreateBranch: "git.createBranch",
92+ gitCheckout: "git.checkout",
93+ gitInit: "git.init",
94+ gitResolvePullRequest: "git.resolvePullRequest",
95+ gitPreparePullRequestThread: "git.preparePullRequestThread",
96+
97+ // Terminal methods
98+ terminalOpen: "terminal.open",
99+ terminalWrite: "terminal.write",
100+ terminalResize: "terminal.resize",
101+ terminalClear: "terminal.clear",
102+ terminalRestart: "terminal.restart",
103+ terminalClose: "terminal.close",
104+
105+ // Server meta
106+ serverGetConfig: "server.getConfig",
107+ serverRefreshProviders: "server.refreshProviders",
108+ serverUpsertKeybinding: "server.upsertKeybinding",
109+ serverGetSettings: "server.getSettings",
110+ serverUpdateSettings: "server.updateSettings",
111+
112+ // Streaming subscriptions
113+ subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents",
114+ subscribeTerminalEvents: "subscribeTerminalEvents",
115+ subscribeServerConfig: "subscribeServerConfig",
116+ subscribeServerLifecycle: "subscribeServerLifecycle",
117+} as const;
118+
119+export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {
120+ payload: ServerUpsertKeybindingInput,
121+ success: ServerUpsertKeybindingResult,
122+ error: KeybindingsConfigError,
123+});
124+
125+export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, {
126+ payload: Schema.Struct({}),
127+ success: ServerConfig,
128+ error: Schema.Union([KeybindingsConfigError, ServerSettingsError]),
129+});
130+
131+export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, {
132+ payload: Schema.Struct({}),
133+ success: ServerProviderUpdatedPayload,
134+});
135+
136+export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
137+ payload: Schema.Struct({}),
138+ success: ServerSettings,
139+ error: ServerSettingsError,
140+});
141+
142+export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, {
143+ payload: Schema.Struct({ patch: ServerSettingsPatch }),
144+ success: ServerSettings,
145+ error: ServerSettingsError,
146+});
147+
148+export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, {
149+ payload: ProjectSearchEntriesInput,
150+ success: ProjectSearchEntriesResult,
151+ error: ProjectSearchEntriesError,
152+});
153+
154+export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {
155+ payload: ProjectWriteFileInput,
156+ success: ProjectWriteFileResult,
157+ error: ProjectWriteFileError,
158+});
159+
160+export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
161+ payload: OpenInEditorInput,
162+ error: OpenError,
163+});
164+
165+export const WsGitStatusRpc = Rpc.make(WS_METHODS.gitStatus, {
166+ payload: GitStatusInput,
167+ success: GitStatusResult,
168+ error: GitManagerServiceError,
169+});
170+
171+export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, {
172+ payload: GitPullInput,
173+ success: GitPullResult,
174+ error: GitCommandError,
175+});
176+
177+export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, {
178+ payload: GitRunStackedActionInput,
179+ success: GitActionProgressEvent,
180+ error: GitManagerServiceError,
181+ stream: true,
182+});
183+
184+export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, {
185+ payload: GitPullRequestRefInput,
186+ success: GitResolvePullRequestResult,
187+ error: GitManagerServiceError,
188+});
189+
190+export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, {
191+ payload: GitPreparePullRequestThreadInput,
192+ success: GitPreparePullRequestThreadResult,
193+ error: GitManagerServiceError,
194+});
195+
196+export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, {
197+ payload: GitListBranchesInput,
198+ success: GitListBranchesResult,
199+ error: GitCommandError,
200+});
201+
202+export const WsGitCreateWorktreeRpc = Rpc.make(WS_METHODS.gitCreateWorktree, {
203+ payload: GitCreateWorktreeInput,
204+ success: GitCreateWorktreeResult,
205+ error: GitCommandError,
206+});
207+
208+export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, {
209+ payload: GitRemoveWorktreeInput,
210+ error: GitCommandError,
211+});
212+
213+export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, {
214+ payload: GitCreateBranchInput,
215+ error: GitCommandError,
216+});
217+
218+export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, {
219+ payload: GitCheckoutInput,
220+ error: GitCommandError,
221+});
222+
223+export const WsGitInitRpc = Rpc.make(WS_METHODS.gitInit, {
224+ payload: GitInitInput,
225+ error: GitCommandError,
226+});
227+
228+export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {
229+ payload: TerminalOpenInput,
230+ success: TerminalSessionSnapshot,
231+ error: TerminalError,
232+});
233+
234+export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, {
235+ payload: TerminalWriteInput,
236+ error: TerminalError,
237+});
238+
239+export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, {
240+ payload: TerminalResizeInput,
241+ error: TerminalError,
242+});
243+
244+export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, {
245+ payload: TerminalClearInput,
246+ error: TerminalError,
247+});
248+
249+export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, {
250+ payload: TerminalRestartInput,
251+ success: TerminalSessionSnapshot,
252+ error: TerminalError,
253+});
254+
255+export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, {
256+ payload: TerminalCloseInput,
257+ error: TerminalError,
258+});
259+
260+export const WsOrchestrationGetSnapshotRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getSnapshot, {
261+ payload: OrchestrationGetSnapshotInput,
262+ success: OrchestrationRpcSchemas.getSnapshot.output,
263+ error: OrchestrationGetSnapshotError,
264+});
265+
266+export const WsOrchestrationDispatchCommandRpc = Rpc.make(
267+ ORCHESTRATION_WS_METHODS.dispatchCommand,
268+ {
269+ payload: ClientOrchestrationCommand,
270+ success: OrchestrationRpcSchemas.dispatchCommand.output,
271+ error: OrchestrationDispatchCommandError,
272+ },
273+);
274+
275+export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, {
276+ payload: OrchestrationGetTurnDiffInput,
277+ success: OrchestrationRpcSchemas.getTurnDiff.output,
278+ error: OrchestrationGetTurnDiffError,
279+});
280+
281+export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(
282+ ORCHESTRATION_WS_METHODS.getFullThreadDiff,
283+ {
284+ payload: OrchestrationGetFullThreadDiffInput,
285+ success: OrchestrationRpcSchemas.getFullThreadDiff.output,
286+ error: OrchestrationGetFullThreadDiffError,
287+ },
288+);
289+
290+export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, {
291+ payload: OrchestrationReplayEventsInput,
292+ success: OrchestrationRpcSchemas.replayEvents.output,
293+ error: OrchestrationReplayEventsError,
294+});
295+
296+export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make(
297+ WS_METHODS.subscribeOrchestrationDomainEvents,
298+ {
299+ payload: Schema.Struct({}),
300+ success: OrchestrationEvent,
301+ stream: true,
302+ },
303+);
304+
305+export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, {
306+ payload: Schema.Struct({}),
307+ success: TerminalEvent,
308+ stream: true,
309+});
310+
311+export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, {
312+ payload: Schema.Struct({}),
313+ success: ServerConfigStreamEvent,
314+ error: Schema.Union([KeybindingsConfigError, ServerSettingsError]),
315+ stream: true,
316+});
317+
318+export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, {
319+ payload: Schema.Struct({}),
320+ success: ServerLifecycleStreamEvent,
321+ stream: true,
322+});
323+
324+export const WsRpcGroup = RpcGroup.make(
325+ WsServerGetConfigRpc,
326+ WsServerRefreshProvidersRpc,
327+ WsServerUpsertKeybindingRpc,
328+ WsServerGetSettingsRpc,
329+ WsServerUpdateSettingsRpc,
330+ WsProjectsSearchEntriesRpc,
331+ WsProjectsWriteFileRpc,
332+ WsShellOpenInEditorRpc,
333+ WsGitStatusRpc,
334+ WsGitPullRpc,
335+ WsGitRunStackedActionRpc,
336+ WsGitResolvePullRequestRpc,
337+ WsGitPreparePullRequestThreadRpc,
338+ WsGitListBranchesRpc,
339+ WsGitCreateWorktreeRpc,
340+ WsGitRemoveWorktreeRpc,
341+ WsGitCreateBranchRpc,
342+ WsGitCheckoutRpc,
343+ WsGitInitRpc,
344+ WsTerminalOpenRpc,
345+ WsTerminalWriteRpc,
346+ WsTerminalResizeRpc,
347+ WsTerminalClearRpc,
348+ WsTerminalRestartRpc,
349+ WsTerminalCloseRpc,
350+ WsSubscribeOrchestrationDomainEventsRpc,
351+ WsSubscribeTerminalEventsRpc,
352+ WsSubscribeServerConfigRpc,
353+ WsSubscribeServerLifecycleRpc,
354+ WsOrchestrationGetSnapshotRpc,
355+ WsOrchestrationDispatchCommandRpc,
356+ WsOrchestrationGetTurnDiffRpc,
357+ WsOrchestrationGetFullThreadDiffRpc,
358+ WsOrchestrationReplayEventsRpc,
359+);
packages/contracts/src/server.ts+100−2
@@ -1,5 +1,11 @@
11 import { Schema } from "effect";
2−import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas";
2+import {
3+ IsoDateTime,
4+ NonNegativeInt,
5+ ProjectId,
6+ ThreadId,
7+ TrimmedNonEmptyString,
8+} from "./baseSchemas";
39 import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
410 import { EditorId } from "./editor";
511 import { ModelCapabilities } from "./model";
@@ -63,7 +69,8 @@ export const ServerProvider = Schema.Struct({ · 57 unmodified lines
6369 });
6470 export type ServerProvider = typeof ServerProvider.Type;
6571
66−const ServerProviders = Schema.Array(ServerProvider);
72+export const ServerProviders = Schema.Array(ServerProvider);
73+export type ServerProviders = typeof ServerProviders.Type;
6774
6875 export const ServerConfig = Schema.Struct({
6976 cwd: TrimmedNonEmptyString,
@@ -87,10 +94,101 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T · 17 unmodified lines
8794
8895 export const ServerConfigUpdatedPayload = Schema.Struct({
8996 issues: ServerConfigIssues,
97+ providers: ServerProviders,
9098 settings: Schema.optional(ServerSettings),
9199 });
92100 export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;
93101
102+export const ServerConfigKeybindingsUpdatedPayload = Schema.Struct({
103+ issues: ServerConfigIssues,
104+});
105+export type ServerConfigKeybindingsUpdatedPayload =
106+ typeof ServerConfigKeybindingsUpdatedPayload.Type;
107+
108+export const ServerConfigProviderStatusesPayload = Schema.Struct({
109+ providers: ServerProviders,
110+});
111+export type ServerConfigProviderStatusesPayload = typeof ServerConfigProviderStatusesPayload.Type;
112+
113+export const ServerConfigSettingsUpdatedPayload = Schema.Struct({
114+ settings: ServerSettings,
115+});
116+export type ServerConfigSettingsUpdatedPayload = typeof ServerConfigSettingsUpdatedPayload.Type;
117+
118+export const ServerConfigStreamSnapshotEvent = Schema.Struct({
119+ version: Schema.Literal(1),
120+ type: Schema.Literal("snapshot"),
121+ config: ServerConfig,
122+});
123+export type ServerConfigStreamSnapshotEvent = typeof ServerConfigStreamSnapshotEvent.Type;
124+
125+export const ServerConfigStreamKeybindingsUpdatedEvent = Schema.Struct({
126+ version: Schema.Literal(1),
127+ type: Schema.Literal("keybindingsUpdated"),
128+ payload: ServerConfigKeybindingsUpdatedPayload,
129+});
130+export type ServerConfigStreamKeybindingsUpdatedEvent =
131+ typeof ServerConfigStreamKeybindingsUpdatedEvent.Type;
132+
133+export const ServerConfigStreamProviderStatusesEvent = Schema.Struct({
134+ version: Schema.Literal(1),
135+ type: Schema.Literal("providerStatuses"),
136+ payload: ServerConfigProviderStatusesPayload,
137+});
138+export type ServerConfigStreamProviderStatusesEvent =
139+ typeof ServerConfigStreamProviderStatusesEvent.Type;
140+
141+export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({
142+ version: Schema.Literal(1),
143+ type: Schema.Literal("settingsUpdated"),
144+ payload: ServerConfigSettingsUpdatedPayload,
145+});
146+export type ServerConfigStreamSettingsUpdatedEvent =
147+ typeof ServerConfigStreamSettingsUpdatedEvent.Type;
148+
149+export const ServerConfigStreamEvent = Schema.Union([
150+ ServerConfigStreamSnapshotEvent,
151+ ServerConfigStreamKeybindingsUpdatedEvent,
152+ ServerConfigStreamProviderStatusesEvent,
153+ ServerConfigStreamSettingsUpdatedEvent,
154+]);
155+export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;
156+
157+export const ServerLifecycleReadyPayload = Schema.Struct({
158+ at: IsoDateTime,
159+});
160+export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type;
161+
162+export const ServerLifecycleWelcomePayload = Schema.Struct({
163+ cwd: TrimmedNonEmptyString,
164+ projectName: TrimmedNonEmptyString,
165+ bootstrapProjectId: Schema.optional(ProjectId),
166+ bootstrapThreadId: Schema.optional(ThreadId),
167+});
168+export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type;
169+
170+export const ServerLifecycleStreamWelcomeEvent = Schema.Struct({
171+ version: Schema.Literal(1),
172+ sequence: NonNegativeInt,
173+ type: Schema.Literal("welcome"),
174+ payload: ServerLifecycleWelcomePayload,
175+});
176+export type ServerLifecycleStreamWelcomeEvent = typeof ServerLifecycleStreamWelcomeEvent.Type;
177+
178+export const ServerLifecycleStreamReadyEvent = Schema.Struct({
179+ version: Schema.Literal(1),
180+ sequence: NonNegativeInt,
181+ type: Schema.Literal("ready"),
182+ payload: ServerLifecycleReadyPayload,
183+});
184+export type ServerLifecycleStreamReadyEvent = typeof ServerLifecycleStreamReadyEvent.Type;
185+
186+export const ServerLifecycleStreamEvent = Schema.Union([
187+ ServerLifecycleStreamWelcomeEvent,
188+ ServerLifecycleStreamReadyEvent,
189+]);
190+export type ServerLifecycleStreamEvent = typeof ServerLifecycleStreamEvent.Type;
191+
94192 export const ServerProviderUpdatedPayload = Schema.Struct({
95193 providers: ServerProviders,
96194 });
packages/contracts/src/settings.ts+13−0
@@ -93,6 +93,19 @@ export type ServerSettings = typeof ServerSettings.Type; · 92 unmodified lines
9393
9494 export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({});
9595
96+export class ServerSettingsError extends Schema.TaggedErrorClass<ServerSettingsError>()(
97+ "ServerSettingsError",
98+ {
99+ settingsPath: Schema.String,
100+ detail: Schema.String,
101+ cause: Schema.optional(Schema.Defect),
102+ },
103+) {
104+ override get message(): string {
105+ return `Server settings error at ${this.settingsPath}: ${this.detail}`;
106+ }
107+}
108+
96109 // ── Unified type ─────────────────────────────────────────────────────
97110
98111 export type UnifiedSettings = ServerSettings & ClientSettings;
packages/contracts/src/terminal.ts+71−0
@@ -149,3 +149,74 @@ export const TerminalEvent = Schema.Union([ · 148 unmodified lines
149149 TerminalActivityEvent,
150150 ]);
151151 export type TerminalEvent = typeof TerminalEvent.Type;
152+
153+export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()(
154+ "TerminalCwdError",
155+ {
156+ cwd: Schema.String,
157+ reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]),
158+ cause: Schema.optional(Schema.Defect),
159+ },
160+) {
161+ override get message() {
162+ if (this.reason === "notDirectory") {
163+ return `Terminal cwd is not a directory: ${this.cwd}`;
164+ }
165+ if (this.reason === "notFound") {
166+ return `Terminal cwd does not exist: ${this.cwd}`;
167+ }
168+ const causeMessage =
169+ this.cause && typeof this.cause === "object" && "message" in this.cause
170+ ? this.cause.message
171+ : undefined;
172+ return causeMessage
173+ ? `Failed to access terminal cwd: ${this.cwd} (${causeMessage})`
174+ : `Failed to access terminal cwd: ${this.cwd}`;
175+ }
176+}
177+
178+export class TerminalHistoryError extends Schema.TaggedErrorClass<TerminalHistoryError>()(
179+ "TerminalHistoryError",
180+ {
181+ operation: Schema.Literals(["read", "truncate", "migrate"]),
182+ threadId: Schema.String,
183+ terminalId: Schema.String,
184+ cause: Schema.optional(Schema.Defect),
185+ },
186+) {
187+ override get message() {
188+ return `Failed to ${this.operation} terminal history for thread: ${this.threadId}, terminal: ${this.terminalId}`;
189+ }
190+}
191+
192+export class TerminalSessionLookupError extends Schema.TaggedErrorClass<TerminalSessionLookupError>()(
193+ "TerminalSessionLookupError",
194+ {
195+ threadId: Schema.String,
196+ terminalId: Schema.String,
197+ },
198+) {
199+ override get message() {
200+ return `Unknown terminal thread: ${this.threadId}, terminal: ${this.terminalId}`;
201+ }
202+}
203+
204+export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNotRunningError>()(
205+ "TerminalNotRunningError",
206+ {
207+ threadId: Schema.String,
208+ terminalId: Schema.String,
209+ },
210+) {
211+ override get message() {
212+ return `Terminal is not running for thread: ${this.threadId}, terminal: ${this.terminalId}`;
213+ }
214+}
215+
216+export const TerminalError = Schema.Union([
217+ TerminalCwdError,
218+ TerminalHistoryError,
219+ TerminalSessionLookupError,
220+ TerminalNotRunningError,
221+]);
222+export type TerminalError = typeof TerminalError.Type;
packages/contracts/src/ws.test.ts+0−157
@@ -1,157 +0,0 @@
1−import { assert, it } from "@effect/vitest";
2−import { Effect, Schema } from "effect";
3−
4−import { ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS } from "./orchestration";
5−import { WebSocketRequest, WsResponse, WS_CHANNELS, WS_METHODS } from "./ws";
6−
7−const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest);
8−const decodeWsResponse = Schema.decodeUnknownEffect(WsResponse);
9−
10−it.effect("accepts getTurnDiff requests when fromTurnCount <= toTurnCount", () =>
11− Effect.gen(function* () {
12− const parsed = yield* decodeWebSocketRequest({
13− id: "req-1",
14− body: {
15− _tag: ORCHESTRATION_WS_METHODS.getTurnDiff,
16− threadId: "thread-1",
17− fromTurnCount: 1,
18− toTurnCount: 2,
19− },
20− });
21− assert.strictEqual(parsed.body._tag, ORCHESTRATION_WS_METHODS.getTurnDiff);
22− }),
23−);
24−
25−it.effect("rejects getTurnDiff requests when fromTurnCount > toTurnCount", () =>
26− Effect.gen(function* () {
27− const result = yield* Effect.exit(
28− decodeWebSocketRequest({
29− id: "req-1",
30− body: {
31− _tag: ORCHESTRATION_WS_METHODS.getTurnDiff,
32− threadId: "thread-1",
33− fromTurnCount: 3,
34− toTurnCount: 2,
35− },
36− }),
37− );
38− assert.strictEqual(result._tag, "Failure");
39− }),
40−);
41−
42−it.effect("trims websocket request id and nested orchestration ids", () =>
43− Effect.gen(function* () {
44− const parsed = yield* decodeWebSocketRequest({
45− id: " req-1 ",
46− body: {
47− _tag: ORCHESTRATION_WS_METHODS.getTurnDiff,
48− threadId: " thread-1 ",
49− fromTurnCount: 0,
50− toTurnCount: 0,
51− },
52− });
53− assert.strictEqual(parsed.id, "req-1");
54− assert.strictEqual(parsed.body._tag, ORCHESTRATION_WS_METHODS.getTurnDiff);
55− if (parsed.body._tag === ORCHESTRATION_WS_METHODS.getTurnDiff) {
56− assert.strictEqual(parsed.body.threadId, "thread-1");
57− }
58− }),
59−);
60−
61−it.effect("accepts git.preparePullRequestThread requests", () =>
62− Effect.gen(function* () {
63− const parsed = yield* decodeWebSocketRequest({
64− id: "req-pr-1",
65− body: {
66− _tag: WS_METHODS.gitPreparePullRequestThread,
67− cwd: "/repo",
68− reference: "#42",
69− mode: "worktree",
70− },
71− });
72− assert.strictEqual(parsed.body._tag, WS_METHODS.gitPreparePullRequestThread);
73− }),
74−);
75−
76−it.effect("accepts typed websocket push envelopes with sequence", () =>
77− Effect.gen(function* () {
78− const parsed = yield* decodeWsResponse({
79− type: "push",
80− sequence: 1,
81− channel: WS_CHANNELS.serverWelcome,
82− data: {
83− cwd: "/tmp/workspace",
84− projectName: "workspace",
85− },
86− });
87−
88− if (!("type" in parsed) || parsed.type !== "push") {
89− assert.fail("expected websocket response to decode as a push envelope");
90− }
91−
92− assert.strictEqual(parsed.type, "push");
93− assert.strictEqual(parsed.sequence, 1);
94− assert.strictEqual(parsed.channel, WS_CHANNELS.serverWelcome);
95− }),
96−);
97−
98−it.effect("accepts git.actionProgress push envelopes", () =>
99− Effect.gen(function* () {
100− const parsed = yield* decodeWsResponse({
101− type: "push",
102− sequence: 3,
103− channel: WS_CHANNELS.gitActionProgress,
104− data: {
105− actionId: "action-1",
106− cwd: "/repo",
107− action: "commit",
108− kind: "phase_started",
109− phase: "commit",
110− label: "Committing...",
111− },
112− });
113−
114− if (!("type" in parsed) || parsed.type !== "push") {
115− assert.fail("expected websocket response to decode as a push envelope");
116− }
117−
118− assert.strictEqual(parsed.channel, WS_CHANNELS.gitActionProgress);
119− }),
120−);
121−
122−it.effect("accepts server.providersUpdated push envelopes", () =>
123− Effect.gen(function* () {
124− const parsed = yield* decodeWsResponse({
125− type: "push",
126− sequence: 4,
127− channel: WS_CHANNELS.serverProvidersUpdated,
128− data: {
129− providers: [],
130− },
131− });
132−
133− if (!("type" in parsed) || parsed.type !== "push") {
134− assert.fail("expected websocket response to decode as a push envelope");
135− }
136−
137− assert.strictEqual(parsed.channel, WS_CHANNELS.serverProvidersUpdated);
138− }),
139−);
140−
141−it.effect("rejects push envelopes when channel payload does not match the channel schema", () =>
142− Effect.gen(function* () {
143− const result = yield* Effect.exit(
144− decodeWsResponse({
145− type: "push",
146− sequence: 2,
147− channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
148− data: {
149− cwd: "/tmp/workspace",
150− projectName: "workspace",
151− },
152− }),
153− );
154−
155− assert.strictEqual(result._tag, "Failure");
156− }),
157−);
packages/contracts/src/ws.ts+0−257
@@ -1,257 +0,0 @@
1−import { Schema, Struct } from "effect";
2−import { NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas";
3−
4−import {
5− ClientOrchestrationCommand,
6− OrchestrationEvent,
7− ORCHESTRATION_WS_CHANNELS,
8− OrchestrationGetFullThreadDiffInput,
9− ORCHESTRATION_WS_METHODS,
10− OrchestrationGetSnapshotInput,
11− OrchestrationGetTurnDiffInput,
12− OrchestrationReplayEventsInput,
13−} from "./orchestration";
14−import {
15− GitActionProgressEvent,
16− GitCheckoutInput,
17− GitCreateBranchInput,
18− GitPreparePullRequestThreadInput,
19− GitCreateWorktreeInput,
20− GitInitInput,
21− GitListBranchesInput,
22− GitPullInput,
23− GitPullRequestRefInput,
24− GitRemoveWorktreeInput,
25− GitRunStackedActionInput,
26− GitStatusInput,
27−} from "./git";
28−import {
29− TerminalClearInput,
30− TerminalCloseInput,
31− TerminalEvent,
32− TerminalOpenInput,
33− TerminalResizeInput,
34− TerminalRestartInput,
35− TerminalWriteInput,
36−} from "./terminal";
37−import { KeybindingRule } from "./keybindings";
38−import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project";
39−import { OpenInEditorInput } from "./editor";
40−import { ServerConfigUpdatedPayload, ServerProviderUpdatedPayload } from "./server";
41−import { ServerSettingsPatch } from "./settings";
42−
43−// ── WebSocket RPC Method Names ───────────────────────────────────────
44−
45−export const WS_METHODS = {
46− // Project registry methods
47− projectsList: "projects.list",
48− projectsAdd: "projects.add",
49− projectsRemove: "projects.remove",
50− projectsSearchEntries: "projects.searchEntries",
51− projectsWriteFile: "projects.writeFile",
52−
53− // Shell methods
54− shellOpenInEditor: "shell.openInEditor",
55−
56− // Git methods
57− gitPull: "git.pull",
58− gitStatus: "git.status",
59− gitRunStackedAction: "git.runStackedAction",
60− gitListBranches: "git.listBranches",
61− gitCreateWorktree: "git.createWorktree",
62− gitRemoveWorktree: "git.removeWorktree",
63− gitCreateBranch: "git.createBranch",
64− gitCheckout: "git.checkout",
65− gitInit: "git.init",
66− gitResolvePullRequest: "git.resolvePullRequest",
67− gitPreparePullRequestThread: "git.preparePullRequestThread",
68−
69− // Terminal methods
70− terminalOpen: "terminal.open",
71− terminalWrite: "terminal.write",
72− terminalResize: "terminal.resize",
73− terminalClear: "terminal.clear",
74− terminalRestart: "terminal.restart",
75− terminalClose: "terminal.close",
76−
77− // Server meta
78− serverGetConfig: "server.getConfig",
79− serverRefreshProviders: "server.refreshProviders",
80− serverUpsertKeybinding: "server.upsertKeybinding",
81− serverGetSettings: "server.getSettings",
82− serverUpdateSettings: "server.updateSettings",
83−} as const;
84−
85−// ── Push Event Channels ──────────────────────────────────────────────
86−
87−export const WS_CHANNELS = {
88− gitActionProgress: "git.actionProgress",
89− terminalEvent: "terminal.event",
90− serverWelcome: "server.welcome",
91− serverConfigUpdated: "server.configUpdated",
92− serverProvidersUpdated: "server.providersUpdated",
93−} as const;
94−
95−// -- Tagged Union of all request body schemas ─────────────────────────
96−
97−const tagRequestBody = <const Tag extends string, const Fields extends Schema.Struct.Fields>(
98− tag: Tag,
99− schema: Schema.Struct<Fields>,
100−) =>
101− schema.mapFields(
102− Struct.assign({ _tag: Schema.tag(tag) }),
103− // PreserveChecks is safe here. No existing schema should have checks depending on the tag
104− { unsafePreserveChecks: true },
105− );
106−
107−const WebSocketRequestBody = Schema.Union([
108− // Orchestration methods
109− tagRequestBody(
110− ORCHESTRATION_WS_METHODS.dispatchCommand,
111− Schema.Struct({ command: ClientOrchestrationCommand }),
112− ),
113− tagRequestBody(ORCHESTRATION_WS_METHODS.getSnapshot, OrchestrationGetSnapshotInput),
114− tagRequestBody(ORCHESTRATION_WS_METHODS.getTurnDiff, OrchestrationGetTurnDiffInput),
115− tagRequestBody(ORCHESTRATION_WS_METHODS.getFullThreadDiff, OrchestrationGetFullThreadDiffInput),
116− tagRequestBody(ORCHESTRATION_WS_METHODS.replayEvents, OrchestrationReplayEventsInput),
117−
118− // Project Search
119− tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput),
120− tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput),
121−
122− // Shell methods
123− tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput),
124−
125− // Git methods
126− tagRequestBody(WS_METHODS.gitPull, GitPullInput),
127− tagRequestBody(WS_METHODS.gitStatus, GitStatusInput),
128− tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),
129− tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),
130− tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),
131− tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),
132− tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput),
133− tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput),
134− tagRequestBody(WS_METHODS.gitInit, GitInitInput),
135− tagRequestBody(WS_METHODS.gitResolvePullRequest, GitPullRequestRefInput),
136− tagRequestBody(WS_METHODS.gitPreparePullRequestThread, GitPreparePullRequestThreadInput),
137−
138− // Terminal methods
139− tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput),
140− tagRequestBody(WS_METHODS.terminalWrite, TerminalWriteInput),
141− tagRequestBody(WS_METHODS.terminalResize, TerminalResizeInput),
142− tagRequestBody(WS_METHODS.terminalClear, TerminalClearInput),
143− tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput),
144− tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput),
145−
146− // Server meta
147− tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})),
148− tagRequestBody(WS_METHODS.serverRefreshProviders, Schema.Struct({})),
149− tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule),
150− tagRequestBody(WS_METHODS.serverGetSettings, Schema.Struct({})),
151− tagRequestBody(WS_METHODS.serverUpdateSettings, Schema.Struct({ patch: ServerSettingsPatch })),
152−]);
153−
154−export const WebSocketRequest = Schema.Struct({
155− id: TrimmedNonEmptyString,
156− body: WebSocketRequestBody,
157−});
158−export type WebSocketRequest = typeof WebSocketRequest.Type;
159−
160−export const WebSocketResponse = Schema.Struct({
161− id: TrimmedNonEmptyString,
162− result: Schema.optional(Schema.Unknown),
163− error: Schema.optional(
164− Schema.Struct({
165− message: Schema.String,
166− }),
167− ),
168−});
169−export type WebSocketResponse = typeof WebSocketResponse.Type;
170−
171−export const WsPushSequence = NonNegativeInt;
172−export type WsPushSequence = typeof WsPushSequence.Type;
173−
174−export const WsWelcomePayload = Schema.Struct({
175− cwd: TrimmedNonEmptyString,
176− projectName: TrimmedNonEmptyString,
177− bootstrapProjectId: Schema.optional(ProjectId),
178− bootstrapThreadId: Schema.optional(ThreadId),
179−});
180−export type WsWelcomePayload = typeof WsWelcomePayload.Type;
181−
182−export interface WsPushPayloadByChannel {
183− readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload;
184− readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type;
185− readonly [WS_CHANNELS.serverProvidersUpdated]: typeof ServerProviderUpdatedPayload.Type;
186− readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type;
187− readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type;
188− readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent;
189−}
190−
191−export type WsPushChannel = keyof WsPushPayloadByChannel;
192−export type WsPushData<C extends WsPushChannel> = WsPushPayloadByChannel[C];
193−
194−const makeWsPushSchema = <const Channel extends string, Payload extends Schema.Schema<any>>(
195− channel: Channel,
196− payload: Payload,
197−) =>
198− Schema.Struct({
199− type: Schema.Literal("push"),
200− sequence: WsPushSequence,
201− channel: Schema.Literal(channel),
202− data: payload,
203− });
204−
205−export const WsPushServerWelcome = makeWsPushSchema(WS_CHANNELS.serverWelcome, WsWelcomePayload);
206−export const WsPushServerConfigUpdated = makeWsPushSchema(
207− WS_CHANNELS.serverConfigUpdated,
208− ServerConfigUpdatedPayload,
209−);
210−export const WsPushServerProvidersUpdated = makeWsPushSchema(
211− WS_CHANNELS.serverProvidersUpdated,
212− ServerProviderUpdatedPayload,
213−);
214−export const WsPushGitActionProgress = makeWsPushSchema(
215− WS_CHANNELS.gitActionProgress,
216− GitActionProgressEvent,
217−);
218−export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent);
219−export const WsPushOrchestrationDomainEvent = makeWsPushSchema(
220− ORCHESTRATION_WS_CHANNELS.domainEvent,
221− OrchestrationEvent,
222−);
223−
224−export const WsPushChannelSchema = Schema.Literals([
225− WS_CHANNELS.gitActionProgress,
226− WS_CHANNELS.serverWelcome,
227− WS_CHANNELS.serverConfigUpdated,
228− WS_CHANNELS.serverProvidersUpdated,
229− WS_CHANNELS.terminalEvent,
230− ORCHESTRATION_WS_CHANNELS.domainEvent,
231−]);
232−export type WsPushChannelSchema = typeof WsPushChannelSchema.Type;
233−
234−export const WsPush = Schema.Union([
235− WsPushServerWelcome,
236− WsPushServerConfigUpdated,
237− WsPushServerProvidersUpdated,
238− WsPushGitActionProgress,
239− WsPushTerminalEvent,
240− WsPushOrchestrationDomainEvent,
241−]);
242−export type WsPush = typeof WsPush.Type;
243−
244−export type WsPushMessage<C extends WsPushChannel> = Extract<WsPush, { channel: C }>;
245−
246−export const WsPushEnvelopeBase = Schema.Struct({
247− type: Schema.Literal("push"),
248− sequence: WsPushSequence,
249− channel: WsPushChannelSchema,
250− data: Schema.Unknown,
251−});
252−export type WsPushEnvelopeBase = typeof WsPushEnvelopeBase.Type;
253−
254−// ── Union of all server → client messages ─────────────────────────────
255−
256−export const WsResponse = Schema.Union([WebSocketResponse, WsPush]);
257−export type WsResponse = typeof WsResponse.Type;